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
+1961 -1834

No files matched your search

+9 -9
View File
@@ -1,17 +1,17 @@
name: Relatar um problema name: Report a bug
description: Algo não funcionou como esperado description: Something did not work as expected
labels: [bug] labels: [bug]
body: body:
- type: textarea - type: textarea
id: o-que id: what-happened
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." } attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
validations: { required: true } validations: { required: true }
- type: input - type: input
id: sistema id: system
attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" } attributes: { label: "Operating system and Guará version", placeholder: "Ubuntu 24.04 / guara 1.3.2" }
- type: input - type: input
id: midia id: media
attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" } attributes: { label: "Media type and filesystem", placeholder: "32 GB USB drive, FAT32" }
- type: textarea - type: textarea
id: log 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 ## Checklist
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos - [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
- [ ] `cargo test --workspace` passa - [ ] `cargo test --workspace` passes
- [ ] Nada neste PR escreve no dispositivo de origem - [ ] Nothing in this PR writes to the source device
- [ ] CHANGELOG atualizado (se houver mudança visível) - [ ] 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 name: CI
on: 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: push:
branches: [main] 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'] 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: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings RUSTFLAGS: -D warnings
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs: # The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
# a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório # cargo registry, npm/apt caches, and target directories live there so nothing
# target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job # is re-downloaded or rebuilt from scratch on every run. Each job uses its own
# usa o seu próprio target (flags diferentes invalidariam o de outro job). # target directory so differing RUSTFLAGS do not invalidate another job's cache.
CARGO_HOME: /opt/hostedtoolcache/cargo CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
jobs: jobs:
lint: lint:
name: Formatação e clippy name: Format and clippy
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint
@@ -37,64 +37,63 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
- name: Cobertura da tradução da interface - name: UI translation coverage
run: node scripts/check-ui-i18n.mjs 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: python3 scripts/test_make_latest_json.py
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets - run: cargo clippy --workspace --all-targets
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64. # Linux-only runner (ARM64).
test: test:
name: Testes (Linux) name: Tests (Linux)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- name: Ferramentas para imagens de teste (Linux) - name: Test image tools (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
timeout-minutes: 25 timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/guara-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip 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 shell: bash
run: | run: |
mkdir -p /opt/hostedtoolcache/guara-testdata target mkdir -p /opt/hostedtoolcache/guara-testdata target
ln -sfn /opt/hostedtoolcache/guara-testdata target/testdata ln -sfn /opt/hostedtoolcache/guara-testdata target/testdata
scripts/fetch-testdata.sh scripts/fetch-testdata.sh
# Monta a imagem por loop (mount -o loop), o que exige um container # Mounts an image via loop device (mount -o loop), which requires a
# privilegiado. O runner do Gitea roda os jobs sem privilégio de propósito # privileged container. Unprivileged runners skip this step cleanly.
# (o CI de repositório público executa código de terceiros), então este - name: Real XFS deletion fixture (Linux)
# 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
if: runner.os == 'Linux' if: runner.os == 'Linux'
continue-on-error: true continue-on-error: true
run: scripts/make-xfs-real-deletion.sh run: |
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux) if [ ! -f target/testdata/xfs-real-delete/disk.img ]; then
sudo scripts/make-xfs-real-deletion.sh
fi
- name: Run tests
shell: bash shell: bash
run: | 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 cargo test --workspace
audit: audit:
name: Auditoria de dependências name: Dependency audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself # rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
# needs nothing but the advisory database, and installing it once into the # 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 - name: cargo audit
run: | run: |
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit cargo audit
gui: gui:
name: Compila o app desktop (Linux) name: Check desktop app (Linux)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui
@@ -104,10 +103,10 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
- name: Dependências do Tauri - name: Tauri system dependencies
timeout-minutes: 25 timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf 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: 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 - run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
env: env:
+24 -25
View File
@@ -1,17 +1,16 @@
name: Release name: Release
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do # Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
# CHANGELOG.md, compila o CLI (Linux x86_64 e aarch64) e o app desktop (Linux # from CHANGELOG.md, builds the CLI (Linux x86_64 and aarch64) and the desktop
# e Windows, este por compilação cruzada com cargo-xwin), e monta o # app (Linux aarch64, Linux x86_64, and Windows via cargo-xwin cross-compilation),
# latest.json que o atualizador do app lê. # 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 # No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which
# hardware Apple e a uma conta de desenvolvedor, que este runner não tem. # this Linux ARM64 runner does not have.
# #
# Segredos de usuário necessários: REGISTRY_TOKEN (não usado aqui, fica para o # Required repository/user secrets: GUARA_TAURI_SIGNING_PRIVATE_KEY and
# registro de containers de outros projetos), GUARA_TAURI_SIGNING_PRIVATE_KEY e # GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (sign updater bundles). Unprefixed
# GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (assinam os pacotes para o # secret names remain supported as fallback.
# atualizador). Os nomes antigos, sem prefixo, continuam aceitos como fallback.
on: on:
push: push:
tags: ['v*'] tags: ['v*']
@@ -25,7 +24,7 @@ env:
jobs: jobs:
release: release:
name: Cria a release name: Create release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -37,9 +36,9 @@ jobs:
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: | 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 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 fi
scripts/release-notes.sh "$TAG" > notes.md scripts/release-notes.sh "$TAG" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac pre=false; case "$TAG" in *-*) pre=true ;; esac
@@ -65,19 +64,19 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Linker desta plataforma - name: Platform cross-linker
if: ${{ matrix.apt != '' }} if: ${{ matrix.apt != '' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ 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 run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
- name: Anexa à release - name: Upload to release
env: env:
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt) run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
gui: gui:
name: App desktop (${{ matrix.platform }}) name: Desktop app (${{ matrix.platform }})
needs: release needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@@ -94,11 +93,11 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
with: with:
node-version: 22 node-version: 22
- name: Dependências do Tauri (Linux aarch64) - name: Tauri dependencies (Linux aarch64)
if: matrix.platform == 'linux-aarch64' if: matrix.platform == 'linux-aarch64'
timeout-minutes: 25 timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf 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' if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25 timeout-minutes: 25
run: | run: |
@@ -118,21 +117,21 @@ jobs:
else else
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
fi fi
- name: Ferramentas para compilação cruzada (Windows) - name: Cross-compilation tools (Windows)
if: matrix.platform == 'windows' if: matrix.platform == 'windows'
run: | run: |
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang 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 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 }}" run: scripts/build-gui.sh "${{ matrix.platform }}"
- name: Anexa à release - name: Upload to release
env: env:
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/* run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
atualizador: updater:
name: Monta o latest.json name: Build latest.json
needs: gui needs: gui
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -145,7 +144,7 @@ jobs:
run: | run: |
scripts/release-notes.sh "$TAG" > notes.md scripts/release-notes.sh "$TAG" > notes.md
python3 scripts/make-latest-json.py "$TAG" notes.md latest.json python3 scripts/make-latest-json.py "$TAG" notes.md latest.json
- name: Anexa à release - name: Upload to release
env: env:
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} 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 #!/usr/bin/env bash
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia). set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh" exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
+7 -7
View File
@@ -1,4 +1,4 @@
# Build # Build artifacts
target/ target/
apps/gui/src-tauri/target/ apps/gui/src-tauri/target/
node_modules/ node_modules/
@@ -7,15 +7,15 @@ node_modules/
.DS_Store .DS_Store
**/*.rs.bk **/*.rs.bk
# Capturas e estado local do Sabiá # Sabiá local captures and audit log
*.pcap *.pcap
*.cap *.cap
.sabia-audit.log .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 /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/ .claude/
.wolf/ .wolf/
.opencode/ .opencode/
@@ -27,16 +27,16 @@ CLAUDE.md
AGENTS.md AGENTS.md
GEMINI.md GEMINI.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz) # Local environment files
.env .env
.env.* .env.*
# Lixo local # Local scratch files
*.eml *.eml
__pycache__/ __pycache__/
*.pyc *.pyc
# Pacotes gerados por scripts/package-cli.sh # Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
/guara-v* /guara-v*
/sabia-v* /sabia-v*
/dist-gui /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. 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`). 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. 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 ## Workflow
```bash ```bash
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit 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. 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}}'`. 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`. 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 ## 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. 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`). 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. 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 ## Fluxo
```bash ```bash
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit 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. 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}}'`. 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. 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 ## 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 strip = true
panic = "abort" 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."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3
+3 -3
View File
@@ -1,8 +1,8 @@
# Aplicativo desktop (Tauri 2). Fica fora do workspace principal porque exige # Desktop application (Tauri 2). Kept outside the main workspace because it
# as bibliotecas de WebView do sistema (WebKitGTK no Linux). # requires system WebView libraries (WebKitGTK on Linux).
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos" description = "Guará: graphical file recovery interface"
version = "1.3.2" version = "1.3.2"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" 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"); let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
#[cfg(windows)] #[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 manifest = include_str!("windows-app-manifest.xml");
let attrs = tauri_build::Attributes::new() let attrs = tauri_build::Attributes::new()
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest)); .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))] #[cfg(not(windows))]
tauri_build::build(); tauri_build::build();
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Permissões da janela principal", "description": "Main window permissions",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"] "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"]}}
+123 -48
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")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use guara_core::service::Engine; use guara_core::service::Engine;
@@ -6,23 +6,29 @@ use std::sync::Arc;
struct State(Arc<Engine>); 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] #[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(); 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 || {
.await eng.rpc(&method, params).map_err(|e| e.to_string())
.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)] #[derive(serde::Serialize)]
struct UpdateInfo { struct UpdateInfo {
current: String, current: String,
version: String, version: String,
notes: String, notes: String,
/// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote /// Whether the app can self-update (AppImage, Windows, and macOS). Package installs
/// (.deb/.rpm) só avisa e leva à página da release. /// (.deb/.rpm) only notify the user and open the release page.
auto: bool, auto: bool,
url: String, url: String,
} }
@@ -31,22 +37,30 @@ fn self_update_possible() -> bool {
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some() !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] #[tauri::command]
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> { async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
use tauri_plugin_updater::UpdaterExt; 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 { Ok(found.map(|u| UpdateInfo {
current: u.current_version.clone(), current: u.current_version.clone(),
notes: u.body.clone().unwrap_or_default(), 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, version: u.version,
auto: self_update_possible(), auto: self_update_possible(),
})) }))
} }
/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app. /// Downloads, verifies the signature (embedded public key), installs, and restarts the app.
/// O progresso sai no evento `update-progress`. /// Progress is emitted via the `update-progress` event.
#[tauri::command] #[tauri::command]
async fn update_install(app: tauri::AppHandle) -> Result<(), String> { async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter; use tauri::Emitter;
@@ -67,7 +81,10 @@ async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
.download_and_install( .download_and_install(
move |chunk, total| { move |chunk, total| {
got += chunk as u64; 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 || { move || {
let _ = b.emit("update-progress", serde_json::json!({ "installing": true })); let _ = b.emit("update-progress", serde_json::json!({ "installing": true }));
@@ -75,16 +92,16 @@ async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
) )
.await .await
.map_err(|e| format!("Não foi possível atualizar: {e}"))?; .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") { if let Some(p) = std::env::var_os("APPIMAGE") {
guara_core::owner::give_to_user(std::path::Path::new(&p)); guara_core::owner::give_to_user(std::path::Path::new(&p));
} }
app.restart() app.restart()
} }
/// `true` quando o processo já tem privilégio suficiente para ler discos brutos. /// `true` when the process already has sufficient privileges to read raw disks.
/// No Windows o manifesto já exige administrador antes mesmo do app abrir, /// On Windows the manifest already requires administrator privileges before launch,
/// então aqui é sempre `true`. /// so this is always `true` here.
#[tauri::command] #[tauri::command]
fn is_elevated() -> bool { fn is_elevated() -> bool {
#[cfg(unix)] #[cfg(unix)]
@@ -97,9 +114,9 @@ fn is_elevated() -> bool {
} }
} }
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o /// Relaunches the app with administrator privileges (Linux/macOS). On Windows the
/// manifesto já exige elevação. O app novo encerra este quando abre; se a /// manifest already enforces elevation. The new instance terminates this one on startup; if
/// autorização falhar, a janela continua aqui e a interface mostra o motivo. /// authorization fails, this window stays open and the UI displays the reason.
#[tauri::command] #[tauri::command]
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))] #[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 cfg = guara_core::settings::config_dir();
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id())); let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b""); 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 err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().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 cmd = {
let mut c = std::process::Command::new("pkexec"); let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env"); 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) { if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}")); c.arg(format!("{k}={v}"));
} }
@@ -146,16 +173,21 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
pid, pid,
exe.display() 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"); let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]); c.args(["-e", &script]);
c c
}; };
cmd.stdout(out).stderr(err); 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 || { std::thread::spawn(move || {
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app // On Linux, pkexec only exits with success when the elevated app closes (and the elevated
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso. // app will have already terminated this process). Any failure before that becomes a warning.
let status = child.wait(); let status = child.wait();
if matches!(&status, Ok(s) if s.success()) { if matches!(&status, Ok(s) if s.success()) {
let _ = std::fs::remove_file(&log); 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()) { let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(), Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".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 _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log); let _ = std::fs::remove_file(&log);
@@ -173,31 +208,43 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para /// Executable path accessible to root. Inside an AppImage, `current_exe` points
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o /// inside a user FUSE mount that root cannot access: use the
/// próprio arquivo `.AppImage` (variável `APPIMAGE`). /// `.AppImage` file itself (`APPIMAGE` environment variable).
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> { 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); return Ok(p);
} }
std::env::current_exe().map_err(|e| e.to_string()) std::env::current_exe().map_err(|e| e.to_string())
} }
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`), /// Serves recovered files for preview (`guara://localhost/file/<job>/<id>`),
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem. /// 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>> { fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
use tauri::http::Response; use tauri::http::Response;
if let Some(rest) = req.uri().path().strip_prefix("/thumb/") { if let Some(rest) = req.uri().path().strip_prefix("/thumb/") {
let mut it = rest.split('/'); 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 let size = req
.uri() .uri()
.query() .query()
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s="))) .and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
.and_then(|v| v.parse::<u32>().ok()) .and_then(|v| v.parse::<u32>().ok())
.unwrap_or(256); .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) }; let Some(id) = id else { return err(400) };
return match eng.thumb(job, id, size) { return match eng.thumb(job, id, size) {
Ok(b) => Response::builder() 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; const CAP: u64 = 8 * 1024 * 1024;
let mut it = req.uri().path().trim_start_matches("/file/").split('/'); 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 (job, id) = (
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap(); 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 Some(id) = id else { return err(400) };
let range = req.headers().get("range").and_then(|v| v.to_str().ok()); let range = req.headers().get("range").and_then(|v| v.to_str().ok());
let slice = match eng.file_slice(job, id, range) { 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 want = slice.len();
let mut headers = slice.headers(); let mut headers = slice.headers();
let partial = slice.partial || want > CAP; 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 }); let mut b = Response::builder().status(if partial { 206 } else { 200 });
headers.retain(|(k, _)| *k != "Content-Range"); headers.retain(|(k, _)| *k != "Content-Range");
for (k, v) in &headers { for (k, v) in &headers {
b = b.header(*k, v); b = b.header(*k, v);
} }
if partial && !data.is_empty() { 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)) b.body(data).unwrap_or_else(|_| err(500))
} }
@@ -249,12 +315,15 @@ fn main() {
std::thread::spawn(move || responder.respond(serve_file(&eng, &request))); std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
}) })
.setup(|app| { .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)] #[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::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200)); 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 { unsafe {
if libc::geteuid() == 0 { if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM); libc::kill(pid, libc::SIGTERM);
@@ -263,9 +332,9 @@ fn main() {
}); });
} }
// Ler discos brutos exige root: pede elevação assim que o app abre, // Reading raw disks requires root: request elevation as soon as the app launches,
// sem esperar o usuário procurar um botão. Se a autorização falhar, // without waiting for the user to look for a button. If authorization fails,
// a janela continua aberta e a interface mostra o aviso. // the window stays open and the UI displays a warning.
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() { if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
let handle = app.handle().clone(); let handle = app.handle().clone();
@@ -275,7 +344,13 @@ fn main() {
} }
Ok(()) 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!()) .run(tauri::generate_context!())
.expect("erro ao iniciar o Guará"); .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); } .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; } } @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ------------------------------------------------------------ componentes */ /* ------------------------------------------------------------ components */
.hero { padding: 56px 0 28px; max-width: 640px; } .hero { padding: 56px 0 28px; max-width: 640px; }
.hero h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.05; font-weight: 700; } .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; } .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 { 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; } .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; } .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 { height: 10px; border-radius: 99px; background: color-mix(in srgb, var(--text) 9%, transparent); overflow: hidden; }
.bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .3s ease; } .bar > 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 b { display: block; font-size: 28px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat span { color: var(--text-2); font-size: 13px; } .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; } .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 { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.chips::-webkit-scrollbar { display: 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[aria-pressed="true"] { background: var(--text); color: var(--bg); }
.chip small { opacity: .6; margin-left: 4px; font-variant-numeric: tabular-nums; } .chip small { opacity: .6; margin-left: 4px; font-variant-numeric: tabular-nums; }
.input, .select { border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; min-width: 0; } .input, .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); } .select option, .select optgroup { background-color: var(--surface); color: var(--text); }
.input { flex: 1 1 200px; } .input { flex: 1 1 200px; }
.input::placeholder { color: var(--text-3); } .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; } .scrim { display: none; }
.back { color: var(--accent); background: none; border: 0; cursor: pointer; font-weight: 500; padding: 0; margin-bottom: 8px; } .back { color: var(--accent); background: none; border: 0; cursor: pointer; font-weight: 500; padding: 0; margin-bottom: 8px; }
/* ------------------------------------------------------------ responsivo */ /* ------------------------------------------------------------ responsive */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.split { grid-template-columns: 1fr; } .split { grid-template-columns: 1fr; }
.preview { position: static; order: -1; grid-template-columns: 120px 1fr; align-items: start; } .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; } * { 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 { 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 { 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; } .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 { 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.large { max-height: none; height: 100%; min-height: 0; }
.viewer > * { max-width: 100%; } .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; } .hero-logo { width: min(340px, 70%); height: auto; margin: 0 0 14px -6px; }
.pill.accent { background: var(--accent-soft); color: var(--accent); } .pill.accent { background: var(--accent-soft); color: var(--accent); }
/* ------------------------------------------------------------ explorador de arquivos */ /* ------------------------------------------------------------ file explorer */
.main.fixed { overflow: hidden; } .main.fixed { overflow: hidden; }
.page.wide { max-width: none; padding: 14px 18px 0; } .page.wide { max-width: none; padding: 14px 18px 0; }
.page.fill { height: 100%; display: flex; flex-direction: column; } .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 .dev[aria-current="true"] { outline: 2px solid var(--accent); }
.cand .s { white-space: normal; } .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 { 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 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); } .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-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); } .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-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 { 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; } .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 { 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); } .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; } .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 { 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-head { display: grid; gap: 2px; }
.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); } .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)`: // Guará: UI. Communicates with the core via `rpc(method, params)`:
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token. // • Tauri app: invoke('rpc') • `guara ui` mode: POST /rpc/<method> with token.
import { createExplorer } from './explorer.js'; import { createExplorer } from './explorer.js';
import { setLang, LOC, currentLang } from './i18n.js'; import { setLang, LOC, currentLang } from './i18n.js';
@@ -10,7 +10,7 @@ let token = params.get('token');
try { try {
if (token) sessionStorage.setItem('guara-token', token); if (token) sessionStorage.setItem('guara-token', token);
else token = sessionStorage.getItem('guara-token'); else token = sessionStorage.getItem('guara-token');
} catch { /* armazenamento indisponível */ } } catch { /* storage unavailable */ }
async function rpc(method, p = {}) { async function rpc(method, p = {}) {
if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: 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 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 || '')}`); 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) { function setHTML(el, html) {
if (!el || el._h === html) return false; if (!el || el._h === html) return false;
el._h = html; el._h = html;
@@ -37,7 +37,7 @@ function setHTML(el, html) {
return true; return true;
} }
// ------------------------------------------------------------------ tema // ------------------------------------------------------------------ theme
function applyTheme(t) { function applyTheme(t) {
const root = document.documentElement; const root = document.documentElement;
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme; 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 () => { document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
const t = b.dataset.themePick; const t = b.dataset.themePick;
applyTheme(t); applyTheme(t);
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ } try { localStorage.setItem('guara-theme', t); } catch { /* no storage */ }
saveSettings({ theme: t }); saveSettings({ theme: t });
})); }));
// ------------------------------------------------------------------ configurações // ------------------------------------------------------------------ settings
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui) // (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 }; const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
async function saveSettings(patch) { async function saveSettings(patch) {
Object.assign(SETTINGS, 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 $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = (n) => { const human = (n) => {
@@ -153,14 +153,14 @@ const S = {
sessions: [], // {job, target, label} sessions: [], // {job, target, label}
device: null, // {path,name,...} device: null, // {path,name,...}
layout: null, layout: null,
job: null, // id da varredura atual job: null, // active scan job id
status: null, status: null,
filters: { category: null, minConfidence: null, query: '', sort: 'confidence' }, filters: { category: null, minConfidence: null, query: '', sort: 'confidence' },
items: [], items: [],
total: 0, total: 0,
selected: new Set(), selected: new Set(),
allFiltered: false, allFiltered: false,
current: null, // arquivo em foco (pré-visualização) current: null, // focused file (preview)
poll: null, poll: null,
recJob: 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>` }); } } catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
}; };
// ------------------------------------------------------------------ início // ------------------------------------------------------------------ home
function renderHome() { function renderHome() {
main.classList.remove('fixed'); main.classList.remove('fixed');
stopPoll(); stopPoll();
@@ -278,7 +278,7 @@ function renderHome() {
$('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e)))); $('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
} }
// ------------------------------------------------------------------ dispositivo // ------------------------------------------------------------------ device
async function openDevice(d) { async function openDevice(d) {
main.classList.remove('fixed'); main.classList.remove('fixed');
stopPoll(); stopPoll();
@@ -291,7 +291,7 @@ async function openDevice(d) {
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; } try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
S.layout = layout; S.layout = layout;
renderDevice(err); 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(() => {}); 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('')} ${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>` : ''; </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>` : ''; 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 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(''); 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> 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); $('#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() { function confirmRisky() {
return new Promise((resolve) => { return new Promise((resolve) => {
const h = S.health; const h = S.health;
@@ -532,8 +532,8 @@ function openSearch(job, terms) {
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })(); (async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
} }
// ------------------------------------------------------------------ varredura // ------------------------------------------------------------------ scan
let EX = null; // explorador de arquivos (grade, navegação, detalhes) let EX = null; // file explorer (grid, navigation, details)
function openJob(s) { function openJob(s) {
stopPoll(); stopPoll();
@@ -556,7 +556,7 @@ async function tick() {
S.status = st; S.status = st;
paintStatus(); paintStatus();
EX?.onStatus(st); 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) { if (st.status !== 'running' && !st.enriching) {
stopPoll(); stopPoll();
loadRecent(); loadRecent();
@@ -615,7 +615,7 @@ function paintStatus() {
async function chooseSource() { async function chooseSource() {
let cands = []; 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 st = S.status || {};
const size = st.layout?.size; const size = st.layout?.size;
const d = $('#dlg'); 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>` }); } } 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) { function watchAux(job, title, doneHtml) {
const d = $('#dlg'); const d = $('#dlg');
d.onclose = null; d.onclose = null;
@@ -740,7 +740,7 @@ function watchAux(job, title, doneHtml) {
async function openJobFromList(s) { openJob(s); } async function openJobFromList(s) { openJob(s); }
// ------------------------------------------------------------------ configurações (janela) // ------------------------------------------------------------------ settings modal
$('#settingsBtn')?.addEventListener('click', () => { $('#settingsBtn')?.addEventListener('click', () => {
const d = $('#dlg'); const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Configurações</h2> d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
@@ -764,9 +764,9 @@ $('#settingsBtn')?.addEventListener('click', () => {
d.showModal(); d.showModal();
}); });
// ------------------------------------------------------------------ atualização do app // ------------------------------------------------------------------ app updater
// O núcleo do app (Tauri) consulta o `latest.json` da release no Gitea, confere a assinatura do // The desktop shell (Tauri) queries `latest.json` from the Gitea release, verifies the
// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer. // package signature against the embedded public key, installs, and relaunches. No-op in browser mode (`guara ui`).
let UPD = null; let UPD = null;
async function checkUpdate(manual) { async function checkUpdate(manual) {
@@ -822,7 +822,7 @@ function updateProgress(p) {
} }
} }
// ------------------------------------------------------------------ fechar com varredura em andamento // ------------------------------------------------------------------ close while scan is running
function askClose() { function askClose() {
return new Promise((resolve) => { return new Promise((resolve) => {
const d = $('#dlg'); const d = $('#dlg');
@@ -848,14 +848,14 @@ function watchClose() {
try { try {
await rpc('scan_pause', { job: S.job }); 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)); } 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 await new Promise((r) => setTimeout(r, 600)); // allow auto-save to flush
} catch { /* sai mesmo assim */ } } catch { /* exit anyway */ }
} }
win.destroy(); win.destroy();
}); });
} }
// ------------------------------------------------------------------ início do app // ------------------------------------------------------------------ app startup
(async function init() { (async function init() {
try { try {
const v = await rpc('version'); const v = await rpc('version');
+24 -24
View File
@@ -1,7 +1,7 @@
// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta // Recovered file explorer: thumbnail grid (virtualized, handles
// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção // tens of thousands of items), navigation by type/date/folder/camera, file-manager
// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e // style selection (Ctrl/Shift/keyboard), inspector panel, and
// visualização rápida em tela cheia. // fullscreen quick look.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js'; import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js'; import { LOC } from './i18n.js';
@@ -66,7 +66,7 @@ export function createExplorer(deps) {
try { try {
E.view = localStorage.getItem('guara-view') || 'grid'; E.view = localStorage.getItem('guara-view') || 'grid';
E.tile = +localStorage.getItem('guara-tile') || 150; E.tile = +localStorage.getItem('guara-tile') || 150;
} catch { /* sem armazenamento */ } } catch { /* no storage */ }
host.innerHTML = `<div class="xp" data-view="${E.view}"> host.innerHTML = `<div class="xp" data-view="${E.view}">
<div class="xp-bar"> <div class="xp-bar">
@@ -91,7 +91,7 @@ export function createExplorer(deps) {
$('#xpSort').value = E.sort; $('#xpSort').value = E.sort;
$('#xpSize').value = E.tile; $('#xpSize').value = E.tile;
// ------------------------------------------------------------ dados // ------------------------------------------------------------ data
const toQuery = () => { const toQuery = () => {
const f = E.filters; const f = E.filters;
const q = {}; const q = {};
@@ -139,8 +139,8 @@ export function createExplorer(deps) {
const reloadAll = () => { reload(); loadFacets(); }; const reloadAll = () => { reload(); loadFacets(); };
// ------------------------------------------------------------ layout virtual // ------------------------------------------------------------ virtual layout
// Agrupa por dia; se houver poucos itens por dia, agrupa por mês. // Group by day; if there are too few items per day, group by month.
let groupBy = 'day'; let groupBy = 'day';
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7)); const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
const dayLabel = (k) => { 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' }); : 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' })); 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 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 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' })); 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(); flush();
if (grouped) { if (grouped) {
// contagem por grupo // count per group
for (let r = 0; r < rows.length; r++) { for (let r = 0; r < rows.length; r++) {
if (rows[r].t !== 'head') continue; if (rows[r].t !== 'head') continue;
let n = 0; let n = 0;
@@ -245,7 +245,7 @@ export function createExplorer(deps) {
sizer.appendChild(frag); sizer.appendChild(frag);
} }
// ------------------------------------------------------------ itens // ------------------------------------------------------------ items
const isSel = (id) => E.allFiltered || E.sel.has(id); const isSel = (id) => E.allFiltered || E.sel.has(id);
function headHtml(row) { function headHtml(row) {
@@ -288,7 +288,7 @@ export function createExplorer(deps) {
</div>`; </div>`;
} }
// imagens que falham viram um aviso (arquivo possivelmente danificado) // failed images become a warning (file possibly damaged)
scroll.addEventListener('error', (e) => { scroll.addEventListener('error', (e) => {
const img = e.target; const img = e.target;
if (img.tagName !== 'IMG' || !img.dataset.th) return; 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>`); : `<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); }, true);
// ------------------------------------------------------------ seleção // ------------------------------------------------------------ selection
const selCount = () => (E.allFiltered ? E.total : E.sel.size); const selCount = () => (E.allFiltered ? E.total : E.sel.size);
const idxOf = (id) => E.items.findIndex((x) => x.id === id); 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(); } else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
}); });
// ------------------------------------------------------------ barra de ações // ------------------------------------------------------------ action bar
function paintActions() { function paintActions() {
const n = selCount(); const n = selCount();
const html = n const html = n
@@ -407,7 +407,7 @@ export function createExplorer(deps) {
else if (a === 'recall') onRecover({ filter: toQuery() }); else if (a === 'recall') onRecover({ filter: toQuery() });
}); });
// ------------------------------------------------------------ navegação lateral // ------------------------------------------------------------ sidebar navigation
function paintNav() { function paintNav() {
const f = E.facets; const f = E.facets;
if (!f) return; 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('') : ''); + (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) : ''); }).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
const folders = f.folders.filter((d) => { 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('/'); 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; 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; return true;
@@ -528,7 +528,7 @@ export function createExplorer(deps) {
changed(); changed();
}); });
// ------------------------------------------------------------ barra superior // ------------------------------------------------------------ top bar
let qt; let qt;
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); }); $('#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(); }); $('#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'); $('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp'); $('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
// ------------------------------------------------------------ painel de detalhes // ------------------------------------------------------------ inspector panel
let inspT, stopPv = null, metaToken = 0; let inspT, stopPv = null, metaToken = 0;
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); }; 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="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>`; <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) }); 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) => { $('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return; if (ev.target.closest('button, a, .pv-pdfbar')) return;
openLightbox(idxOf(it.id)); 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(() => {})); $('#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(() => {}); }).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]) { function gpsHtml([lat, lon]) {
let map = ''; let map = '';
if (deps.getSettings?.().maps) { if (deps.getSettings?.().maps) {
@@ -625,7 +625,7 @@ export function createExplorer(deps) {
else if (a === 'close') root.classList.remove('show-insp'); else if (a === 'close') root.classList.remove('show-insp');
}); });
// ------------------------------------------------------------ tela cheia // ------------------------------------------------------------ fullscreen
let stopLb = null; let stopLb = null;
function openLightbox(i) { function openLightbox(i) {
const it = E.items[i]; const it = E.items[i];
@@ -649,14 +649,14 @@ export function createExplorer(deps) {
if (!d.open) d.showModal(); if (!d.open) d.showModal();
} }
// ------------------------------------------------------------ paint geral // ------------------------------------------------------------ main paint
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); } function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
paintView(); paintCrumb(); paintActions(); paintInspector(); paintView(); paintCrumb(); paintActions(); paintInspector();
reloadAll(); reloadAll();
return { return {
/** Chamado a cada atualização de status da varredura. */ /** Called on every scan status update. */
onStatus(st) { onStatus(st) {
E.status = st; E.status = st;
const enriching = !!st.enriching; const enriching = !!st.enriching;
@@ -672,7 +672,7 @@ export function createExplorer(deps) {
}, },
query: () => toQuery(), query: () => toQuery(),
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; }, 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(); }, relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
focus() { scroll.focus({ preventScroll: true }); }, 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 = { 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.', '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 realocados': 'Reallocated sectors',
'Setores pendentes de realocação': 'Pending 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', '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', ', que contém': ', which contains',
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.', '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', 'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices', 'Dispositivos': 'Devices',
'Dispositivos e sessões': 'Devices and sessions', 'Dispositivos e sessões': 'Devices and sessions',
@@ -64,7 +64,7 @@ export const EN = {
'· requer admin': '· needs admin', '· requer admin': '· needs admin',
'requer admin': 'needs admin', 'requer admin': 'needs admin',
'{n} · requer admin': '{n} · 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.', '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.', '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', 'Uma varredura, tudo junto': 'One scan, everything together',
@@ -74,7 +74,7 @@ export const EN = {
'Origem protegida': 'Protected source', '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.', '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.', 'Nenhum disco apareceu.': 'No disks showed up.',
// dispositivo // device
'Mapa do disco': 'Disk map', 'Mapa do disco': 'Disk map',
'Como procurar': 'How to search', 'Como procurar': 'How to search',
'Recomendado': 'Recommended', 'Recomendado': 'Recommended',
@@ -108,7 +108,7 @@ export const EN = {
'encontrada por varredura': 'found by scanning', 'encontrada por varredura': 'found by scanning',
'Desconhecido': 'Unknown', '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.', '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', '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 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.', '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', 'Varrer mesmo assim': 'Scan anyway',
'Cancelar': 'Cancel', 'Cancelar': 'Cancel',
'Este disco parece estar com defeito': 'This disk seems to be failing', 'Este disco parece estar com defeito': 'This disk seems to be failing',
// varredura // scan
'‹ Dispositivos': '‹ Devices', '‹ Dispositivos': '‹ Devices',
'Procurando…': 'Searching…', 'Procurando…': 'Searching…',
'Varredura concluída': 'Scan complete', 'Varredura concluída': 'Scan complete',
@@ -152,7 +152,7 @@ export const EN = {
'Lendo tabela de partições': 'Reading partition table', 'Lendo tabela de partições': 'Reading partition table',
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})', 'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
'Varredura profunda por assinatura': 'Deep signature scan', 'Varredura profunda por assinatura': 'Deep signature scan',
// explorador // explorer
'Navegação': 'Navigation', 'Navegação': 'Navigation',
'Filtros': 'Filters', 'Filtros': 'Filters',
'Todos os arquivos': 'All files', 'Todos os arquivos': 'All files',
@@ -246,7 +246,7 @@ export const EN = {
'Tem localização GPS': 'Has GPS location', 'Tem localização GPS': 'Has GPS location',
'Confiança {n}': 'Confidence {n}', 'Confiança {n}': 'Confidence {n}',
'Nada por aqui': 'Nothing here', 'Nada por aqui': 'Nothing here',
// pré-visualização // preview
'Imagem': 'Image', 'Imagem': 'Image',
'Vídeo': 'Video', 'Vídeo': 'Video',
'PDF': 'PDF', '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 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.', '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', 'pasta': 'folder',
// banco de dados // database
'Abrir o visualizador': 'Open the viewer', 'Abrir o visualizador': 'Open the viewer',
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages', '{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
'{n} linhas · {n} colunas': '{n} rows · {n} columns', '{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}', '{n} a {n} de {n}': '{n} to {n} of {n}',
'‹ Anterior': '‹ Previous', '‹ Anterior': '‹ Previous',
'Próximas ›': 'Next ›', 'Próximas ›': 'Next ›',
// recuperar // recover
'Recuperar para…': 'Recover to…', 'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover', 'Recuperar': 'Recover',
'Pasta de destino': 'Destination folder', '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.', '{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.', '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).', '{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', 'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.', 'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes', 'Calculando hashes da origem': 'Computing source hashes',
@@ -354,7 +354,7 @@ export const EN = {
'{n} ocorrência{n}': '{n} occurrence{n}', '{n} ocorrência{n}': '{n} occurrence{n}',
'Termos: {n}': 'Terms: {n}', 'Termos: {n}': 'Terms: {n}',
'{n}% · {n} até agora': '{n}% · {n} so far', '{n}% · {n} até agora': '{n}% · {n} so far',
// sessões e origem // sessions and source
'Abrir arquivo de imagem': 'Open disk image file', '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 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á.', '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', 'mesmo disco': 'same disk',
'tamanho igual, conteúdo diferente': 'same size, different content', 'tamanho igual, conteúdo diferente': 'same size, different content',
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)', 'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
// configurações e fechamento // settings and shutdown
'Idioma': 'Language', 'Idioma': 'Language',
'Notificações': 'Notifications', 'Notificações': 'Notifications',
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes', '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', 'Sair sem pausar': 'Quit without pausing',
'Continuar varrendo': 'Keep scanning', '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.', '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 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', 'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
'Autorização cancelada.': 'Authorization canceled.', 'Autorização cancelada.': 'Authorization canceled.',
'Origem desconectada': 'Source disconnected', 'Origem desconectada': 'Source disconnected',
'Este disco está em uso (montado em': 'This disk is in use (mounted at', '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.', '). 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', 'Volumes cifrados, RAID e vídeos': 'Encrypted volumes, RAID and videos',
'Senha do volume (BitLocker, LUKS, FileVault 2)': 'Volume password (BitLocker, LUKS, FileVault 2)', 'Senha do volume (BitLocker, LUKS, FileVault 2)': 'Volume password (BitLocker, LUKS, FileVault 2)',
'Chave de recuperação do BitLocker': 'BitLocker recovery key', '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)', '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', '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.', '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', 'Arquivo de destino': 'Destination file',
'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)', 'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)',
'Formato da imagem': 'Image format', 'Formato da imagem': 'Image format',
@@ -416,18 +416,18 @@ export const EN = {
'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)', 'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)',
'Imagem salva em': 'Image saved to', 'Imagem salva em': 'Image saved to',
'Hashes do conteúdo inteiro de': 'Hashes of the entire content of', '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', 'arquivo(s) salvos em': 'file(s) saved to',
'arquivos em {n}': 'files in {n}', 'arquivos em {n}': 'files in {n}',
'Espaço não particionado: {n}': 'Unpartitioned space: {n}', 'Espaço não particionado: {n}': 'Unpartitioned space: {n}',
'Partição {n}': 'Partition {n}', 'Partição {n}': 'Partition {n}',
'erro {n}': 'error {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', 'Autoavaliação SMART': 'SMART self-assessment',
'Vida útil usada': 'Life used', 'Vida útil usada': 'Life used',
'aprovado': 'passed', 'aprovado': 'passed',
'REPROVADO: o próprio disco prevê falha': 'FAILED: the disk itself predicts failure', 'REPROVADO: o próprio disco prevê falha': 'FAILED: the disk itself predicts failure',
// pré-visualização // preview
'Pré-visualização': 'Preview', 'Pré-visualização': 'Preview',
'Pré-visualização de {n}': 'Preview of {n}', 'Pré-visualização de {n}': 'Preview of {n}',
'vídeo': 'video', '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)', 'diretório do ZIP não encontrado (arquivo possivelmente incompleto)': 'ZIP directory not found (file possibly incomplete)',
'visualizador indisponível': 'viewer unavailable', 'visualizador indisponível': 'viewer unavailable',
'© colaboradores do OpenStreetMap': '© OpenStreetMap contributors', '© 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', 'selecionado': 'selected',
'selecionados': 'selected', 'selecionados': 'selected',
'selecionado (todos desta vista)': 'selected (all in this view)', '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}', 'Conteúdo do arquivo compactado · {n}': 'Archive contents · {n}',
'Banco de dados SQLite · {n}': 'SQLite database · {n}', 'Banco de dados SQLite · {n}': 'SQLite database · {n}',
'Dados brutos (hexadecimal) · {n}': 'Raw data (hexadecimal) · {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 JPEG': 'JPEG image',
'Imagem PNG': 'PNG image', 'Imagem PNG': 'PNG image',
'Imagem GIF': 'GIF image', 'Imagem GIF': 'GIF image',
@@ -482,7 +482,7 @@ export const EN = {
'Última modificação por': 'Last modified by', 'Última modificação por': 'Last modified by',
'Páginas': 'Pages', 'Páginas': 'Pages',
'Duração': 'Duration', '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)', 'SSD (sem rotação)': 'SSD (no rotation)',
'Cache de gravação': 'Write cache', 'Cache de gravação': 'Write cache',
'Segurança ATA': 'ATA security', 'Segurança ATA': 'ATA security',
@@ -490,13 +490,13 @@ export const EN = {
'membro de RAID': 'RAID member', 'membro de RAID': 'RAID member',
'sem rodapé/estrutura completa: pode estar truncado': 'no footer/complete structure: may be truncated', 'sem rodapé/estrutura completa: pode estar truncado': 'no footer/complete structure: may be truncated',
'vídeo sem índice (moov)': 'video without index (moov)', '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.', '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 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.', '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.', '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', 'job {n} não existe': 'job {n} does not exist',
'a varredura não está pausada': 'the scan is not paused', '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.', '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 // UI language. Source strings are in Portuguese; in English mode a translator
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e // replaces displayed strings (text nodes and attributes) using a dictionary, and
// continua observando a página para traduzir o que for desenhado depois. // keeps observing the page to translate dynamically rendered elements.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files"). // Variable placeholders use {n} in the dictionary (e.g. "{n} files").
import { EN } from './i18n-en.js'; import { EN } from './i18n-en.js';
@@ -16,7 +16,7 @@ export function resolveLang(pref) {
} }
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty']; 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 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}'))); 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, '\\$&')); const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v }; 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); .sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map(); const cache = new Map();
@@ -43,7 +43,7 @@ export function tr(text) {
const m = core.match(p.re); const m = core.match(p.re);
if (m) { if (m) {
let i = 0; 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] ?? '')); out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break; break;
} }
@@ -55,7 +55,7 @@ export function tr(text) {
return lead + out + trail; 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; let observer = null;
function translateNode(n) { function translateNode(n) {
+12 -12
View File
@@ -1,8 +1,8 @@
import { LOC } from './i18n.js'; import { LOC } from './i18n.js';
// Pré-visualização de arquivos recuperados. // Preview of recovered files.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é // Content is streamed from the core (with Range support) via `url`; nothing is
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos // written to disk. Supported types: image, video, audio, PDF (pdf.js), text,
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal. // Office/OpenDocument documents (extracted text), ZIP listing, and hex view.
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']); const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']); 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()); return new Uint8Array(await r.arrayBuffer());
} }
// ------------------------------------------------------------------ ZIP mínimo // ------------------------------------------------------------------ minimal ZIP reader
async function zipDir(url, size) { async function zipDir(url, size) {
const tailLen = Math.min(size, 66000); const tailLen = Math.min(size, 66000);
const tail = await fetchRange(url, size - tailLen, size - 1); 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'); const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
// ------------------------------------------------------------------ renderizadores // ------------------------------------------------------------------ renderers
function note(host, html, cls = '') { function note(host, html, cls = '') {
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`); 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); 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>`; 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 { } else {
// pptx / odp: texto por slide // pptx / odp: per-slide text
let slides; let slides;
if (ext === 'pptx') { 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); 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.`); 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) { function tsHint(v) {
if (!Number.isInteger(v)) return null; if (!Number.isInteger(v)) return null;
let ms = null, kind = ''; let ms = null, kind = '';
@@ -361,7 +361,7 @@ async function database(host, item, state, large, db, onOpen) {
} }
// ------------------------------------------------------------------ API // ------------------------------------------------------------------ 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 }) { function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
const ico = kind === 'video' ? '🎞' : '🎵'; const ico = kind === 'video' ? '🎞' : '🎵';
const canInline = inlineMedia && large; 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.classList.remove('zoomable', 'actual');
host.innerHTML = '<div class="pv-loading"><i></i></div>'; host.innerHTML = '<div class="pv-loading"><i></i></div>';
const kind = kindOf(item); const kind = kindOf(item);
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop // Video and audio: never instantiate <video>/<audio> in the small panel, nor in the large view
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre. // on Linux desktop (WebKitGTK without media plugins), as the webview process would crash.
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) { if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }); mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
return () => { state.dead = true; }; 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] [package]
name = "guara-cli" 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 version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+73 -73
View File
@@ -20,34 +20,34 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum SignCmd { 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 { 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)] #[arg(long)]
key: Option<String>, key: Option<String>,
}, },
/// Assina um arquivo e grava <arquivo>.assinatura.json /// Sign a file and write <file>.assinatura.json
File { File {
path: String, path: String,
#[arg(long)] #[arg(long)]
key: Option<String>, key: Option<String>,
/// Nome de quem assina (padrão: GUARA_EXAMINER) /// Signer name (default: GUARA_EXAMINER)
#[arg(long)] #[arg(long)]
signer: Option<String>, signer: Option<String>,
}, },
/// Confere a assinatura de um arquivo /// Verify a file signature
Verify { Verify {
path: String, path: String,
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json) /// Signature file (default: <file>.assinatura.json)
#[arg(long)] #[arg(long)]
sig: Option<String>, sig: Option<String>,
/// Impressão digital esperada da chave, publicada pelo perito /// Expected key fingerprint published by the examiner
#[arg(long)] #[arg(long)]
trust: Option<String>, trust: Option<String>,
#[arg(long)] #[arg(long)]
json: bool, 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 { Recovery {
dir: String, dir: String,
#[arg(long)] #[arg(long)]
@@ -59,103 +59,103 @@ enum SignCmd {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Lista discos e pendrives detectados /// List detected disks and USB drives
Devices { Devices {
#[arg(long)] #[arg(long)]
json: bool, 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 { Inspect {
/// Dispositivo (ex.: /dev/sdb, \\.\PhysicalDrive1) ou arquivo de imagem /// Device (e.g. /dev/sdb, \\.\PhysicalDrive1) or image file
path: String, path: String,
#[arg(long)] #[arg(long)]
json: bool, json: bool,
#[command(flatten)] #[command(flatten)]
asm: AsmArgs, asm: AsmArgs,
}, },
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos /// Disk health (SMART): status, temperature, usage, and attribute table
Health { 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, path: String,
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
/// Procura arquivos apagados/perdidos (metadados + assinatura) /// Scan for deleted/lost files (metadata + file carving)
Scan(ScanArgs), 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 { Resume {
session: String, session: String,
#[command(flatten)] #[command(flatten)]
out: OutArgs, out: OutArgs,
}, },
/// Recupera arquivos de uma sessão salva /// Recover files from a saved session
Recover { Recover {
session: String, session: String,
/// Pasta de destino (em OUTRO disco) /// Destination directory (on ANOTHER disk)
dest: String, dest: String,
#[command(flatten)] #[command(flatten)]
filter: FilterArgs, 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 { Image {
path: String, path: String,
dest: 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")] #[arg(long, default_value = "raw")]
format: String, format: String,
/// E01: número do caso /// E01: case number
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
case: String, case: String,
/// E01: examinador /// E01: examiner
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
examiner: String, examiner: String,
/// E01: descrição da evidência /// E01: evidence description
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
description: String, description: String,
/// E01: número da evidência /// E01: evidence number
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
evidence: String, evidence: String,
/// E01: observações /// E01: notes
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
notes: String, 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)] #[arg(long, default_value_t = 1500)]
segment_mib: u64, segment_mib: u64,
/// E01: compressão 0 (nenhuma) a 9 /// E01: compression level 0 (none) to 9
#[arg(long, default_value_t = 1)] #[arg(long, default_value_t = 1)]
compression: u32, 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 }, 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)] #[command(subcommand)]
Sign(SignCmd), 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 { 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)] #[arg(long)]
log: Option<String>, 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)] #[arg(long)]
verify: bool, verify: bool,
/// Mostra só as últimas N entradas /// Show only the last N entries
#[arg(long)] #[arg(long)]
tail: Option<usize>, tail: Option<usize>,
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
/// Exporta os achados de uma sessão para CSV (abre no Excel) /// Export session findings to CSV (Excel-compatible)
Export { Export {
session: String, session: String,
/// Arquivo CSV de saída /// Output CSV file
csv: String, csv: String,
#[command(flatten)] #[command(flatten)]
filter: FilterArgs, 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 { Report {
session: String, session: String,
/// Arquivo de saída (.pdf ou .html) /// Output file (.pdf or .html)
out: String, out: String,
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
case: String, case: String,
@@ -165,20 +165,20 @@ enum Cmd {
org: String, org: String,
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
notes: String, 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)] #[arg(long)]
hash_source: bool, 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 { Search {
path: String, path: String,
/// Termos a procurar /// Search terms
#[arg(required = true)] #[arg(required = true)]
terms: Vec<String>, terms: Vec<String>,
/// Diferencia maiúsculas de minúsculas /// Case-sensitive search
#[arg(long)] #[arg(long)]
case_sensitive: bool, case_sensitive: bool,
/// Não procura em UTF-16 /// Skip UTF-16 search
#[arg(long)] #[arg(long)]
no_utf16: bool, no_utf16: bool,
#[arg(long, default_value_t = 200)] #[arg(long, default_value_t = 200)]
@@ -186,11 +186,11 @@ enum Cmd {
#[arg(long)] #[arg(long)]
json: bool, 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 }, Hash { path: String },
/// Lista as sessões salvas automaticamente /// List autosaved sessions
Sessions, Sessions,
/// Abre a interface gráfica no navegador (modo local) /// Open the web UI in the browser (local mode)
Ui { Ui {
#[arg(long, default_value_t = 0)] #[arg(long, default_value_t = 0)]
port: u16, port: u16,
@@ -201,72 +201,72 @@ enum Cmd {
#[derive(clap::Args, Clone)] #[derive(clap::Args, Clone)]
struct FilterArgs { struct FilterArgs {
/// Categorias: image, document, video, audio, archive, database, other /// Categories: image, document, video, audio, archive, database, other
#[arg(long, value_delimiter = ',')] #[arg(long, value_delimiter = ',')]
category: Vec<String>, category: Vec<String>,
/// Confiança mínima: low, medium, high /// Minimum confidence: low, medium, high
#[arg(long, default_value = "low")] #[arg(long, default_value = "low")]
min_confidence: String, min_confidence: String,
/// Só nomes/caminhos que contenham este texto /// Only match names/paths containing this substring
#[arg(long)] #[arg(long)]
name: Option<String>, 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")] #[arg(long, default_value = "folders")]
organize: String, organize: String,
} }
#[derive(clap::Args, Clone)] #[derive(clap::Args, Clone)]
struct OutArgs { struct OutArgs {
/// Salva a sessão (necessária para recover/resume) /// Save session file (required for recover/resume)
#[arg(long)] #[arg(long)]
session: Option<String>, session: Option<String>,
/// Lista os arquivos encontrados /// List found files
#[arg(long)] #[arg(long)]
list: bool, list: bool,
#[arg(long)] #[arg(long)]
json: bool, json: bool,
} }
/// Montagem do disco: volumes cifrados, RAID e LVM. /// Disk assembly: encrypted volumes, RAID, and LVM.
#[derive(clap::Args, Clone, Default)] #[derive(clap::Args, Clone, Default)]
struct AsmArgs { 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")] #[arg(long = "password")]
passwords: Vec<String>, passwords: Vec<String>,
/// Pergunta a senha no terminal, sem mostrar o que é digitado /// Prompt for the password in the terminal without echoing
#[arg(long)] #[arg(long)]
ask_password: bool, 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")] #[arg(long = "recovery-key")]
recovery_keys: Vec<String>, 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")] #[arg(long = "key-file")]
key_files: Vec<String>, 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")] #[arg(long = "volume-key")]
volume_keys: Vec<String>, 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")] #[arg(long = "member")]
members: Vec<String>, 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)] #[arg(long)]
raid: bool, raid: bool,
/// Fixa o nível do RAID: 0, 5, 6, 1 /// Override RAID level: 0, 5, 6, 1
#[arg(long)] #[arg(long)]
raid_level: Option<String>, raid_level: Option<String>,
/// Fixa o tamanho da faixa do RAID em KiB /// Override RAID chunk size in KiB
#[arg(long)] #[arg(long)]
raid_chunk_kb: Option<u64>, 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)] #[arg(long)]
raid_layout: Option<String>, 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 = ',')] #[arg(long, value_delimiter = ',')]
raid_order: Vec<usize>, 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)] #[arg(long)]
remember_keys: bool, 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")] #[arg(long = "reference")]
references: Vec<String>, references: Vec<String>,
} }
@@ -343,15 +343,15 @@ impl AsmArgs {
#[derive(clap::Args)] #[derive(clap::Args)]
struct ScanArgs { struct ScanArgs {
/// Dispositivo ou imagem /// Device or image path
path: String, path: String,
/// Varredura rápida: só metadados dos sistemas de arquivos /// Quick scan: filesystem metadata only
#[arg(long)] #[arg(long)]
quick: bool, quick: bool,
/// Também varre áreas ainda alocadas (mais lento) /// Also carve currently allocated space (slower)
#[arg(long)] #[arg(long)]
include_allocated: bool, 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)] #[arg(long)]
recover: Option<String>, recover: Option<String>,
#[command(flatten)] #[command(flatten)]
@@ -684,7 +684,7 @@ fn query_from(f: &FilterArgs) -> Result<Query, String> {
"high" | "alta" => Confidence::High, "high" | "alta" => Confidence::High,
o => return Err(format!("confiança inválida: {o}")), 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 { Ok(Query {
category: if cats.len() == 1 { Some(cats[0]) } else { None }, category: if cats.len() == 1 { Some(cats[0]) } else { None },
min_confidence: Some(min), min_confidence: Some(min),
@@ -756,7 +756,7 @@ fn recover_job(
filter: &FilterArgs, filter: &FilterArgs,
) -> Result<(), String> { ) -> Result<(), String> {
let mut q = query_from(filter)?; let mut q = query_from(filter)?;
// Várias categorias: seleciona por ids. // Multiple categories: select by IDs.
let cats = parse_cats(&filter.category)?; let cats = parse_cats(&filter.category)?;
let mut ids: Option<Vec<u64>> = None; let mut ids: Option<Vec<u64>> = None;
if cats.len() > 1 { if cats.len() > 1 {
@@ -815,7 +815,7 @@ fn recover_job(
Ok(()) 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> { fn verify_image(path: &str) -> Result<Option<bool>, String> {
if path.to_ascii_lowercase().ends_with(".aff4") { if path.to_ascii_lowercase().ends_with(".aff4") {
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(path)).map_err(s)?; 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> { fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) -> Result<(), String> {
let log = match log { let log = match log {
Some(p) => guara_core::audit::Log::at(p), 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(()) Ok(())
} }
/// `guara sign`: chaves, assinatura e conferência. /// `guara sign`: key generation, signing, and verification.
fn sign_cmd(c: SignCmd) -> Result<(), String> { fn sign_cmd(c: SignCmd) -> Result<(), String> {
use guara_core::sign; use guara_core::sign;
use std::path::{Path, PathBuf}; 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 guara_core::service::Engine;
use serde_json::json; use serde_json::json;
@@ -43,7 +43,7 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
]); ]);
for req in server.incoming_requests() { for req in server.incoming_requests() {
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone()); 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[..])); std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
} }
Ok(()) Ok(())
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "guara-core" 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 version.workspace = true
edition.workspace = true edition.workspace = true
license.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 //! Reads linear and mapped images (including sparse ranges), with chunks compressed via
//! snappy, deflate, LZ4 ou sem compressão. Gravação de uma imagem linear com blocos snappy. //! snappy, deflate, LZ4, or stored uncompressed. Writes linear images with snappy chunks.
//! Conjuntos em vários volumes (spanned/striped) não são suportados. //! Multi-volume sets (spanned/striped) are not supported.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::hashing::MultiHasher; use crate::hashing::MultiHasher;
@@ -15,7 +15,7 @@ use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
pub fn is_aff4(head: &[u8]) -> bool { 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.len() > 50
&& head.starts_with(b"PK\x03\x04") && head.starts_with(b"PK\x03\x04")
&& (find(&head[30..head.len().min(80)], b"container.description") && (find(&head[30..head.len().min(80)], b"container.description")
@@ -80,7 +80,7 @@ impl Turtle<'_> {
} }
tok.to_string() 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> { fn term(&mut self) -> Option<String> {
self.ws(); self.ws();
let c = *self.b.get(self.i)?; let c = *self.b.get(self.i)?;
@@ -123,7 +123,7 @@ impl Turtle<'_> {
{ {
self.i += 1; self.i += 1;
} }
// ponto final de sentença colado ao termo // trailing statement period attached to the term
let mut e = self.i; let mut e = self.i;
if e > s && self.b[e - 1] == b'.' { if e > s && self.b[e - 1] == b'.' {
e -= 1; e -= 1;
@@ -165,7 +165,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
t.prefixes.insert(name, iri); t.prefixes.insert(name, iri);
continue; continue;
} }
// sujeito // subject
let Some(subj) = let Some(subj) =
(if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) { (if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) {
t.i += 1; t.i += 1;
@@ -200,7 +200,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
Some(b';') => { Some(b';') => {
t.i += 1; t.i += 1;
t.ws(); t.ws();
// ";" final antes de "." // trailing ";" before "."
if t.b.get(t.i) == Some(&b'.') { if t.b.get(t.i) == Some(&b'.') {
t.i += 1; t.i += 1;
break; break;
@@ -217,7 +217,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
out out
} }
// ------------------------------------------------------------------ leitor // ------------------------------------------------------------------ reader
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Method { enum Method {
@@ -265,7 +265,7 @@ pub struct Aff4Source {
targets: Vec<Target>, targets: Vec<Target>,
map: Vec<MapEntry>, map: Vec<MapEntry>,
size: u64, size: u64,
/// Metadados legíveis: caso, examinador, notas, hashes. /// Human-readable metadata: case, examiner, notes, hashes.
pub info: HashMap<String, String>, pub info: HashMap<String, String>,
cache: Mutex<Option<(usize, u64, Vec<u8>)>>, cache: Mutex<Option<(usize, u64, Vec<u8>)>>,
index_cache: Mutex<HashMap<(usize, u64), std::sync::Arc<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(); 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 for r in res
.values() .values()
.filter(|r| r.get(&format!("{NS}hash")).is_some()) .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>> { pub fn verify_stream_md5(&self) -> Result<Option<bool>> {
let Some(want) = self.info.get("md5").cloned() else { let Some(want) = self.info.get("md5").cloned() else {
return Ok(None); return Ok(None);
@@ -604,7 +604,7 @@ impl Source for Aff4Source {
let pos = offset + done as u64; let pos = offset + done as u64;
let i = self.map.partition_point(|e| e.start + e.len <= pos); 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 { 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 next = self.map.get(i).map(|e| e.start).unwrap_or(self.size);
let n = ((next - pos) as usize).min(want - done); let n = ((next - pos) as usize).min(want - done);
buf[done..done + n].fill(0); buf[done..done + n].fill(0);
@@ -626,7 +626,7 @@ impl Source for Aff4Source {
} }
} }
// ------------------------------------------------------------------ gravação // ------------------------------------------------------------------ writer
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Aff4Options { pub struct Aff4Options {
@@ -703,7 +703,7 @@ fn esc(s: &str) -> String {
.replace('\n', "\\n") .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( pub fn write_aff4(
src: &dyn Source, src: &dyn Source,
dest: &Path, dest: &Path,
@@ -776,11 +776,11 @@ pub fn write_aff4(
} }
} }
hasher.update(b); 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 let comp = enc_snap
.compress_vec(b) .compress_vec(b)
.map_err(|e| Error::msg(e.to_string()))?; .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 { let data: &[u8] = if want as u64 == chunk && comp.len() >= want {
b b
} else { } 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 //! Assembles a virtual disk from user-supplied inputs: extra members (RAID/LVM) and
//! senhas ou chaves para volumes cifrados. É a única porta de entrada para varrer e recuperar //! passwords or keys for encrypted volumes. Single entry point for scanning and recovering
//! discos que não são "simples". //! non-trivial disk layouts.
use crate::crypt::{self, Secret, UnlockError}; use crate::crypt::{self, Secret, UnlockError};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -21,17 +21,17 @@ pub enum SecretInput {
Recovery { Recovery {
value: String, value: String,
}, },
/// Caminho de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS). /// Path to a key file (BitLocker .BEK, LUKS keyfile).
Keyfile { Keyfile {
path: String, path: String,
}, },
/// Chave do volume em hexadecimal. /// Hex-encoded volume master key.
Key { Key {
hex: String, 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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SavedKey { pub struct SavedKey {
pub start: u64, pub start: u64,
@@ -39,29 +39,29 @@ pub struct SavedKey {
pub key_hex: String, 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)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RaidSpec { pub struct RaidSpec {
pub level: Option<crate::raid::Level>, pub level: Option<crate::raid::Level>,
/// Tamanho da faixa em KiB. /// Chunk (stripe) size in KiB.
pub chunk_kb: Option<u64>, pub chunk_kb: Option<u64>,
pub layout: Option<crate::raid::Layout>, 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>>, pub order: Option<Vec<usize>>,
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Assembly { 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)] #[serde(default)]
pub raid: Option<RaidSpec>, 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)] #[serde(default)]
pub members: Vec<String>, 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)] #[serde(default)]
pub secrets: Vec<SecretInput>, 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)] #[serde(default)]
pub remember_keys: bool, pub remember_keys: bool,
#[serde(default)] #[serde(default)]
@@ -96,13 +96,13 @@ pub struct Assembled {
pub source: Arc<dyn Source>, pub source: Arc<dyn Source>,
pub unlocked: Vec<UnlockInfo>, pub unlocked: Vec<UnlockInfo>,
pub locked: Vec<LockedInfo>, 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>, 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>, 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>, 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 skip_primary: bool,
pub notes: Vec<String>, 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> { pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?; let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?;
assemble_source(base, asm) assemble_source(base, asm)
@@ -153,7 +153,7 @@ pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
const MIB: u64 = 1 << 20; 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) { fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: String) {
let len = src.size(); let len = src.size();
if let Some(r) = regions 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 { struct Window {
src: Arc<dyn Source>, 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>, 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 done_containers: std::collections::BTreeSet<String> = Default::default();
let mut tried_pool = false; 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 { for round in 0..4 {
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone())); let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false); 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; 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() { if round == 0 || !tried_pool || !out.extra_parts.is_empty() {
let mut pool: Vec<Window> = vec![]; let mut pool: Vec<Window> = vec![];
pool.push(Window { 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 current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false); let layout = partition::analyze(current.as_ref(), false);
let mut all_parts: Vec<PartInfo> = layout.partitions.clone(); 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() { if !regions.is_empty() {
out.source = Arc::new(Overlay::new(base, regions)); 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 { for p in &mut out.extra_parts {
if let Some((fs, len)) = partition::detect_fs(out.source.as_ref(), p.start) { if let Some((fs, len)) = partition::detect_fs(out.source.as_ref(), p.start) {
p.fs = fs; p.fs = fs;
@@ -454,7 +454,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
Ok(out) 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( fn add_virtual(
regions: &mut Vec<Region>, regions: &mut Vec<Region>,
next: &mut u64, 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( fn pick_raid(
disks: &[Arc<dyn Source>], disks: &[Arc<dyn Source>],
spec: &RaidSpec, spec: &RaidSpec,
+23 -23
View File
@@ -1,13 +1,13 @@
//! Trilha de auditoria: cada ação do examinador (abrir uma origem, varrer, recuperar, criar //! Audit trail: each examiner action (opening a source, scanning, recovering, imaging,
//! imagem, exportar) vira uma linha JSON com quem, quando e o quê. //! 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 //! Entries are hash-chained: each entry's `hash` is the SHA-256 of the previous `hash`
//! seguido do corpo da própria linha. Alterar ou apagar uma linha do meio quebra a cadeia e //! followed by the entry body itself. Modifying or deleting an intermediate line breaks the chain
//! `guara audit --verify` aponta onde. Cortar o fim do arquivo não é detectável sozinho; para //! and `guara audit --verify` pinpoints where. Tail truncation cannot be detected from the file
//! isso, guarde o hash da última entrada (a "cabeça") fora da máquina, por exemplo no relatório. //! 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` //! Stored in the config directory (`auditoria.jsonl`) or at `GUARA_AUDIT_LOG`.
//! apontar. Segredos (senhas, chaves) nunca entram no registro. //! Secrets (passwords, keys) are never written to the log.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -32,11 +32,11 @@ struct Body<'a> {
prev: &'a str, prev: &'a str,
} }
/// Uma linha da trilha. /// A single audit log entry.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry { pub struct Entry {
pub seq: u64, pub seq: u64,
/// UTC, ISO 8601. /// UTC timestamp in ISO 8601 format.
pub time: String, pub time: String,
pub user: String, pub user: String,
pub host: String, pub host: String,
@@ -49,14 +49,14 @@ pub struct Entry {
pub hash: String, pub hash: String,
} }
/// Resultado de conferir a cadeia. /// Result of verifying the hash chain.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Verification { pub struct Verification {
pub entries: usize, pub entries: usize,
pub ok: bool, 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)>, 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, pub head: String,
} }
@@ -72,7 +72,7 @@ fn sha_hex(prev: &str, body: &str) -> String {
h.finalize().iter().map(|b| format!("{b:02x}")).collect() 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 { pub fn utc_iso(secs: i64) -> String {
let days = secs.div_euclid(86400); let days = secs.div_euclid(86400);
let rem = secs.rem_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()) 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 { fn current_user() -> String {
env("SUDO_USER") env("SUDO_USER")
.or_else(|| env("USER")) .or_else(|| env("USER"))
@@ -127,7 +127,7 @@ fn host_name() -> String {
.unwrap_or_else(|| "?".into()) .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>> { fn last_line(path: &Path) -> std::io::Result<Option<String>> {
let mut f = match std::fs::File::open(path) { let mut f = match std::fs::File::open(path) {
Ok(f) => f, Ok(f) => f,
@@ -158,7 +158,7 @@ impl Log {
Self { path: path.into() } 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 { pub fn default_path() -> PathBuf {
match env("GUARA_AUDIT_LOG") { match env("GUARA_AUDIT_LOG") {
Some(p) => PathBuf::from(p), Some(p) => PathBuf::from(p),
@@ -174,7 +174,7 @@ impl Log {
&self.path &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> { pub fn append(&self, action: &str, detail: Value) -> Result<Entry> {
let (seq, prev) = match last_line(&self.path)? { let (seq, prev) = match last_line(&self.path)? {
None => (1, GENESIS.to_string()), 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>> { pub fn entries(&self) -> Result<Vec<Entry>> {
let text = match std::fs::read_to_string(&self.path) { let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t, Ok(t) => t,
@@ -244,7 +244,7 @@ impl Log {
.collect() .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> { pub fn verify(&self) -> Result<Verification> {
let text = match std::fs::read_to_string(&self.path) { let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t, 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 é /// Records an action in the default audit trail. Write failures do not abort the operation
/// avisada no stderr: uma ação sem registro precisa ser notada. /// but are logged to stderr so unrecorded actions do not go unnoticed.
pub fn record(action: &str, detail: Value) { pub fn record(action: &str, detail: Value) {
if let Err(e) = Log::open_default().append(action, detail) { if let Err(e) = Log::open_default().append(action, detail) {
eprintln!("aviso: não foi possível gravar a trilha de auditoria ({action}): {e}"); 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 { pub fn path_detail(path: &str) -> Value {
json!({ "path": path }) 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::signatures::{category_for_ext, detect, might_start, Hit};
use crate::source::Source; use crate::source::Source;
@@ -14,9 +14,9 @@ pub struct CarveCfg<'a> {
pub categories: &'a [Category], pub categories: &'a [Category],
} }
/// Varre [start, end) a partir de `*next`. Chama `on_hit(offset, hit)` e retorna /// Scans `[start, end)` starting from `*next`. Calls `on_hit(offset, hit)` and returns
/// `true` se terminou o intervalo (`false` se cancelado). /// `true` if the range completed (`false` if cancelled).
/// `advance(done_bytes)` informa o progresso dentro do intervalo. /// `advance(done_bytes)` reports progress within the range.
pub fn carve_range( pub fn carve_range(
src: &dyn Source, src: &dyn Source,
start: u64, start: u64,
@@ -28,7 +28,7 @@ pub fn carve_range(
) -> CarveEnd { ) -> CarveEnd {
let end = end.min(src.size()); let end = end.min(src.size());
let mut pos = (*next).max(start); let mut pos = (*next).max(start);
// Alinha ao setor. // Align to sector boundary.
pos = pos.div_ceil(SECTOR) * SECTOR; pos = pos.div_ceil(SECTOR) * SECTOR;
let mut buf = vec![0u8; CHUNK + LOOKAHEAD]; let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
let mut skip_until = 0u64; let mut skip_until = 0u64;
@@ -45,13 +45,13 @@ pub fn carve_range(
n n
} }
Err(_) => { 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; fails += 1;
if fails >= 4 { if fails >= 4 {
*next = pos - (fails as u64 - 1) * CHUNK as u64; *next = pos - (fails as u64 - 1) * CHUNK as u64;
return CarveEnd::IoLost; 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; pos += CHUNK as u64;
ev.advance(CHUNK as u64); ev.advance(CHUNK as u64);
*next = pos; *next = pos;
@@ -100,30 +100,30 @@ pub fn carve_range(
CarveEnd::Done CarveEnd::Done
} }
/// Como o carving de um intervalo terminou. /// Outcome of carving a byte range.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarveEnd { pub enum CarveEnd {
Done, Done,
Cancelled, Cancelled,
/// Várias leituras seguidas falharam: o dispositivo provavelmente foi desconectado. /// Multiple consecutive reads failed: the device was likely disconnected.
IoLost, IoLost,
} }
/// Receptor de eventos do carving. /// Receiver for file-carving events.
pub trait CarveEvents { pub trait CarveEvents {
fn advance(&mut self, bytes: u64); 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 chunk(&mut self, _abs: u64, _data: &[u8]) {}
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction; 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 { pub enum HitAction {
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão). /// Skip past the recognized file content (default behavior).
Skip, 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), SkipLen(u64),
/// Segue setor a setor (acerto descartado). /// Continue sector by sector (hit discarded).
Continue, Continue,
} }
+20 -20
View File
@@ -1,6 +1,6 @@
//! BitLocker (Windows 7 em diante, inclusive BitLocker To Go): AES-CBC com ou sem difusor //! BitLocker (Windows 7+, including BitLocker To Go): AES-CBC with or without Elephant
//! Elephant, e AES-XTS. Protetores: senha, chave de recuperação, chave de inicialização (.BEK), //! diffuser, and AES-XTS. Protectors: password, recovery password, startup key (.BEK),
//! chave limpa (proteção suspensa) e chave do volume já conhecida. TPM não é suportado. //! clear key (suspended protection), and raw volume master key. TPM is not supported.
use super::*; use super::*;
use crate::source::{read_vec, SubSource}; 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, 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> { fn offsets_pos(vbr: &[u8]) -> Option<usize> {
if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" { if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" {
return Some(176); 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) { if vbr.len() >= 464 && (vbr[424..440] == GUID_NORMAL || vbr[424..440] == GUID_EOW) {
return Some(440); return Some(440);
} }
@@ -54,7 +54,7 @@ fn words(buf: &[u8]) -> Vec<u32> {
.collect() .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]) { fn diffuser_a_decrypt(d: &mut [u32]) {
let n = d.len(); let n = d.len();
for _ in 0..5 { 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) { pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off: u64) {
let mut iv = [0u8; 16]; let mut iv = [0u8; 16];
iv[..8].copy_from_slice(&byte_off.to_le_bytes()); 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() { for (i, w) in d.iter().enumerate() {
buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes()); 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]; let mut k0 = [0u8; 16];
k0[..8].copy_from_slice(&byte_off.to_le_bytes()); k0[..8].copy_from_slice(&byte_off.to_le_bytes());
let mut k1 = k0; let mut k1 = k0;
@@ -106,7 +106,7 @@ pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off:
// ------------------------------------------------------------------ CCM // ------------------------------------------------------------------ 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>> { fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
if nonce.len() != 12 || blob.len() < 16 { if nonce.len() != 12 || blob.len() < 16 {
return None; 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); let s = ctr(bi as u32 + 1);
pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b)); 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]; let mut b0 = [0u8; 16];
b0[0] = 0x3A; b0[0] = 0x3A;
b0[1..13].copy_from_slice(nonce); 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) (x == tag).then_some(pt)
} }
// --------------------------------------------------------------- chaves // --------------------------------------------------------------- keys
fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] { fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] {
let mut last = [0u8; 32]; let mut last = [0u8; 32];
@@ -169,7 +169,7 @@ fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] {
stretch(&init, salt) 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]> { fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect(); let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 48 { if digits.len() != 48 {
@@ -188,7 +188,7 @@ fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
Some(stretch(&init, salt)) Some(stretch(&init, salt))
} }
// ----------------------------------------------------------- metadados // ----------------------------------------------------------- metadata
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Entry { struct Entry {
@@ -250,7 +250,7 @@ fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
} }
let version = le16(&h, 10); let version = le16(&h, 10);
if version != 2 { 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 encrypted_size = le64(&h, 16);
let hdr_sectors = le32(&h, 28) as u64; 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>> { fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
if file.len() < 48 { if file.len() < 48 {
return None; return None;
@@ -313,7 +313,7 @@ fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> { fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = vec![]; 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 { if v.protection == 0 {
for n in &v.nested { for n in &v.nested {
if n.vtype == 1 && n.data.len() >= 36 { 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; return None;
} }
let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?; 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()) (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 bps = le16(&vbr, 11).max(512) as u64;
let pos = offsets_pos(&vbr).unwrap_or(176); 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 fve = None;
let mut meta_offsets = vec![]; let mut meta_offsets = vec![];
for i in 0..3 { for i in 0..3 {
@@ -481,7 +481,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let len = src.size(); let len = src.size();
let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps); let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps);
let mut patches = vec![]; 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 { for off in meta_offsets {
patches.push(Patch { patches.push(Patch {
at: off, 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 { 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 { patches.push(Patch {
at: fve.volume_header_offset, at: fve.volume_header_offset,
len: fve.volume_header_size, 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). //! FileVault 2 on Core Storage (encrypted HFS+ volumes from macOS 10.7–10.12 and Fusion Drives).
//! Senha ou chave do volume; chave de recuperação institucional não é suportada. //! Supports user password or volume key; institutional recovery keys are not supported.
use super::*; use super::*;
use crate::source::{read_vec, SubSource}; 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 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]> { fn unwrap_key(kek: &[u8], wrapped: &[u8]) -> Option<[u8; 16]> {
let aes = AesAny::new(kek)?; let aes = AesAny::new(kek)?;
if wrapped.len() != 24 { if wrapped.len() != 24 {
@@ -73,7 +73,7 @@ fn b64(s: &str) -> Option<Vec<u8>> {
Some(out) 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> { fn plist_value(xml: &str, key: &str) -> Option<String> {
let k = format!("<key>{key}</key>"); let k = format!("<key>{key}</key>");
let mut from = 0; 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) { if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) {
break; 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); md_cipher.decrypt_sector(&mut b, i, 0);
match u16::from_le_bytes([b[10], b[11]]) { match u16::from_le_bytes([b[10], b[11]]) {
0x0019 => { 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 super::*;
use crate::source::{read_vec, SubSource}; 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) { fn diffuse(block: &mut [u8], h: Hash) {
let ds = h.size(); let ds = h.size();
let mut i = 0u32; 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> { pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
let mut buf = vec![0u8; block]; let mut buf = vec![0u8; block];
for i in 0..stripes.saturating_sub(1) { 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() 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> { pub fn parse_cipher(spec: &str, key: &[u8]) -> std::result::Result<SectorCipher, UnlockError> {
let (alg, rest) = spec let (alg, rest) = spec
.split_once('-') .split_once('-')
@@ -279,7 +279,7 @@ fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> {
v.get(k)?.as_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> { fn jnum(v: &serde_json::Value, k: &str) -> Option<u64> {
let x = v.get(k)?; let x = v.get(k)?;
x.as_u64().or_else(|| x.as_str()?.parse().ok()) 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( pub fn unlock_window(
base: Arc<dyn Source>, base: Arc<dyn Source>,
start: u64, 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 bitlocker;
pub mod filevault; pub mod filevault;
@@ -14,7 +14,7 @@ use aes::{Aes128, Aes192, Aes256};
use std::io; use std::io;
use std::sync::Arc; use std::sync::Arc;
/// AES com tamanho de chave escolhido em tempo de execução. /// AES cipher with runtime key size selection.
#[derive(Clone)] #[derive(Clone)]
pub enum AesAny { pub enum AesAny {
A128(Aes128), 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)] #[derive(Clone)]
pub enum Iv { 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, Plain64,
/// Número do setor em 32 bits little-endian. /// Sector number as 32-bit little-endian.
Plain32, Plain32,
Plain64Be, Plain64Be,
/// ESSIV: AES(SHA-256(chave), setor). /// ESSIV: AES(SHA-256(key), sector).
Essiv(AesAny), 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), Unit(u64),
/// BitLocker (Windows 10 1511+): AES(chave, offset em bytes). /// BitLocker (Windows 10 1511+): AES(key, byte offset).
Eboiv(AesAny), 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), Bitlocker(AesAny),
} }
@@ -94,7 +94,7 @@ impl Iv {
} }
} }
/// Cifra aplicada setor a setor. /// Sector-level disk cipher.
#[derive(Clone)] #[derive(Clone)]
pub enum SectorCipher { pub enum SectorCipher {
Xts { Xts {
@@ -106,7 +106,7 @@ pub enum SectorCipher {
key: AesAny, key: AesAny,
iv: Iv, 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 { Elephant {
key: AesAny, key: AesAny,
tweak: AesAny, tweak: AesAny,
@@ -126,8 +126,8 @@ fn gf_double(t: &mut [u8; 16]) {
} }
impl SectorCipher { impl SectorCipher {
/// Decifra um setor no lugar. `sector_512` conta setores de 512 bytes; `byte_off` é a posição /// Decrypts a sector in place. `sector_512` is the 512-byte sector index; `byte_off` is the
/// em bytes relativa ao início dos dados cifrados. /// byte offset relative to the start of the encrypted payload.
pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) { pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) {
match self { match self {
SectorCipher::Xts { k1, k2, iv } => { 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 { pub struct CryptSource {
inner: Arc<dyn Source>, inner: Arc<dyn Source>,
cipher: SectorCipher, cipher: SectorCipher,
data_offset: u64, data_offset: u64,
len: u64, len: u64,
sector: u64, sector: u64,
/// Setores antes deste limite estão em claro (BitLocker parcialmente cifrado) ou /// Sectors before this threshold are either unencrypted (partially encrypted BitLocker) or
/// são cobertos por uma cópia (os primeiros setores do BitLocker). /// redirected to a backup copy (BitLocker relocated boot sectors).
patches: Vec<Patch>, 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, 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, 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)] #[derive(Clone)]
pub struct Patch { pub struct Patch {
/// Início no volume em claro. /// Start offset in the logical plaintext volume.
pub at: u64, pub at: u64,
pub len: u64, pub len: u64,
/// Onde estão os dados, na origem, já em claro. /// Source offset where the data resides.
pub from: u64, 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>, 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, pub zero: bool,
} }
@@ -242,7 +242,7 @@ impl CryptSource {
} }
fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> { 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 { for p in &self.patches {
if rel >= p.at && rel < p.at + p.len { if rel >= p.at && rel < p.at + p.len {
if p.zero { 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)] #[derive(Clone)]
pub enum Secret { pub enum Secret {
Password(String), 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), 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>), KeyFile(Vec<u8>),
/// Chave do volume já conhecida (FVEK/master key). /// Known volume master key (FVEK / LUKS master key).
VolumeKey(Vec<u8>), VolumeKey(Vec<u8>),
} }
@@ -317,7 +317,7 @@ impl std::fmt::Debug for Secret {
#[derive(Debug)] #[derive(Debug)]
pub enum UnlockError { pub enum UnlockError {
/// Nenhuma das senhas/chaves abriu o volume. /// None of the supplied passwords or keys unlocked the volume.
WrongSecret, WrongSecret,
Unsupported(String), Unsupported(String),
Corrupt(String), Corrupt(String),
@@ -339,11 +339,11 @@ impl From<UnlockError> for crate::Error {
} }
} }
/// Volume aberto. /// Unlocked encrypted volume.
pub struct Unlocked { pub struct Unlocked {
pub source: Arc<dyn Source>, pub source: Arc<dyn Source>,
pub description: String, 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>>, 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, //! For JPEG: validates contiguous bytes with the entropy decoder; if the stream breaks,
//! procura entre os clusters "com cara de JPEG" próximos a continuação cujo DC de luminância //! searches nearby JPEG-like entropy clusters for a continuation whose luminance DC
//! emenda sem salto, até três fragmentos. É o que dá para fazer sem tabela de alocação, e só //! splices smoothly across up to three fragments. Best-effort without an allocation table,
//! vale quando o sistema de arquivos não deixou metadados para o arquivo. //! used when filesystem metadata is no longer available.
use crate::jpegscan::{self, Header, State, Stop}; use crate::jpegscan::{self, Header, State, Stop};
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
use crate::types::Extent; use crate::types::Extent;
const MAX_FILE: usize = 48 << 20; 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_AFTER: u64 = 512 << 20;
const WINDOW_BEFORE: u64 = 64 << 20; const WINDOW_BEFORE: u64 = 64 << 20;
const PROBE: usize = 8192; const PROBE: usize = 8192;
pub const CLUSTER: u64 = 4096; pub const CLUSTER: u64 = 4096;
const MAX_PROBES: usize = 20_000; const MAX_PROBES: usize = 20_000;
/// Marca os clusters (de 4 KiB, alinhados ao início da origem) que parecem dados entropy-coded /// Bitmap of 4 KiB source-aligned clusters that look like JPEG entropy-coded data
/// de JPEG: sem marcadores no meio e com bytes bem distribuídos. /// (no embedded unescaped markers and high byte entropy).
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub struct ClusterIndex { pub struct ClusterIndex {
bits: Vec<u64>, bits: Vec<u64>,
@@ -43,7 +43,7 @@ impl ClusterIndex {
.map(|w| w >> (c % 64) & 1 == 1) .map(|w| w >> (c % 64) & 1 == 1)
.unwrap_or(false) .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]) { pub fn scan_chunk(&mut self, abs: u64, buf: &[u8]) {
let first = abs.div_ceil(CLUSTER) * CLUSTER; let first = abs.div_ceil(CLUSTER) * CLUSTER;
let mut o = first; let mut o = first;
@@ -90,7 +90,7 @@ pub fn jpeg_like(c: &[u8]) -> bool {
pub struct Pool<'a> { pub struct Pool<'a> {
pub index: &'a ClusterIndex, pub index: &'a ClusterIndex,
/// Extents já atribuídos a outros arquivos. /// Extents already claimed by other recovered files.
pub used: &'a [Extent], pub used: &'a [Extent],
} }
@@ -105,17 +105,17 @@ impl Pool<'_> {
#[derive(Debug)] #[derive(Debug)]
pub enum Analysis { pub enum Analysis {
/// Contíguo e consistente; `len` inclui o EOI. /// Contiguous and valid; `len` includes the EOI marker.
Intact { len: u64 }, Intact { len: u64 },
/// Remontado a partir de fragmentos. /// Reassembled from multiple non-contiguous fragments.
Rebuilt { extents: Vec<Extent>, size: u64 }, Rebuilt { extents: Vec<Extent>, size: u64 },
/// Só o início é confiável. /// Only the leading prefix is reliable.
Broken { good_len: u64 }, Broken { good_len: u64 },
/// Formato que não sabemos validar (progressivo etc.). /// JPEG variant we do not entropy-validate (progressive, arithmetic, etc.).
Unsupported, 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> { fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
let end = (pos + 64).min(data.len().saturating_sub(1)); let end = (pos + 64).min(data.len().saturating_sub(1));
(pos.saturating_sub(2)..end) (pos.saturating_sub(2)..end)
@@ -123,7 +123,7 @@ fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
.map(|i| i + 2) .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( pub fn analyze_jpeg(
src: &dyn Source, src: &dyn Source,
off: u64, off: u64,
@@ -168,8 +168,8 @@ pub fn analyze_jpeg(
let mut pieces: Vec<(u64, usize)> = vec![]; let mut pieces: Vec<(u64, usize)> = vec![];
let mut joined = buf; let mut joined = buf;
let mut cur_base = off; // início absoluto do fragmento atual let mut cur_base = off; // absolute start offset of the current fragment
let mut frag_start = 0usize; // posição em `joined` onde o fragmento atual começa let mut frag_start = 0usize; // byte offset in `joined` where the current fragment starts
let mut snaps = d.snaps; let mut snaps = d.snaps;
let mut suspect = stop_pos; 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.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
ranked.truncate(120); 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; let mut longest: Option<(usize, u64, f64)> = None;
for (sc, q) in ranked { for (sc, q) in ranked {
let avail = (src.size() - q) as usize; let avail = (src.size() - q) as usize;
@@ -269,7 +269,7 @@ pub fn analyze_jpeg(
return finish(pieces); return finish(pieces);
} }
} }
// Quebrou de novo: onde? // Broke again: find the new break point.
let mut sp = match d2.stop { let mut sp = match d2.stop {
Stop::Marker(_, p) | Stop::Error(p) => p, Stop::Marker(_, p) | Stop::Error(p) => p,
_ => joined.len(), _ => joined.len(),
@@ -314,8 +314,8 @@ fn finish(pieces: Vec<(u64, usize)>) -> Analysis {
Analysis::Rebuilt { extents, size } Analysis::Rebuilt { extents, size }
} }
/// Pontuação (menor é melhor) de `q` como continuação de `prefix`: diferença média de pixels /// Score (lower is better) for `q` as a continuation of `prefix`: mean pixel difference
/// nas bordas entre os MCUs novos e seus vizinhos. Natural: poucos níveis de cinza; lixo: dezenas. /// along MCU borders between new blocks and their neighbors (smooth in natural images, high on noise).
fn probe_candidate( fn probe_candidate(
src: &dyn Source, src: &dyn Source,
h: &Header, h: &Header,
@@ -385,7 +385,7 @@ fn probe_candidate(
if n < 40 { if n < 40 {
return None; 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 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 used = d.state.pos.saturating_sub(prefix.len()) as f64;
let ratio = if after > 4.0 && used > 0.0 { 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}; use serde::{Deserialize, Serialize};
@@ -7,18 +7,18 @@ pub struct DeviceInfo {
pub path: String, pub path: String,
pub name: String, pub name: String,
pub size: u64, pub size: u64,
/// "HD", "SSD", "NVMe", "USB", "Disco" ou "Imagem". /// Device kind label (e.g. "HD", "SSD", "NVMe", "USB").
pub kind: String, pub kind: String,
pub removable: bool, pub removable: bool,
pub model: Option<String>, pub model: Option<String>,
pub mounted_at: Vec<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, 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>, pub trim: Option<bool>,
} }
/// (dispositivo, ponto de montagem) /// `(device_path, mount_point)`
pub fn mount_table() -> Vec<(String, String)> { pub fn mount_table() -> Vec<(String, String)> {
#[cfg(target_os = "linux")] #[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 //! Reads multi-segment sets (`.E01`, `.E02`, ...) with deflate, bzip2, uncompressed,
//! deflate, bzip2, sem compressão ou com preenchimento por padrão. Escrita de //! or pattern-filled chunks. Writes EnCase 6 compatible `.E01` images.
//! E01 no formato EnCase 6. A origem nunca é alterada. //! The source is never modified.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::hashing::MultiHasher; 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_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]; 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 { pub fn is_ewf_header(head: &[u8]) -> bool {
head.len() >= 8 && (head[..8] == SIG_V1 || head[..8] == SIG_V2) head.len() >= 8 && (head[..8] == SIG_V1 || head[..8] == SIG_V2)
} }
@@ -85,12 +85,12 @@ enum Codec {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
enum Kind { 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 { Stored {
checksum: bool, checksum: bool,
}, },
Packed(Codec), 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]), Pattern([u8; 8]),
} }
@@ -102,16 +102,16 @@ struct Chunk {
kind: Kind, kind: Kind,
} }
/// Metadados lidos do conjunto de segmentos. /// Metadata extracted from the EWF segment set.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct EwfInfo { pub struct EwfInfo {
/// 1 para E01, 2 para Ex01. /// Format version: 1 for E01, 2 for Ex01.
pub version: u8, pub version: u8,
pub segments: usize, pub segments: usize,
pub chunk_size: u64, pub chunk_size: u64,
pub bytes_per_sector: u32, pub bytes_per_sector: u32,
pub sectors: u64, 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 fields: BTreeMap<String, String>,
pub md5: Option<String>, pub md5: Option<String>,
pub sha1: Option<String>, pub sha1: Option<String>,
@@ -130,7 +130,7 @@ fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect() 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> { pub fn next_segment_ext(ext: &str) -> Option<String> {
let b = ext.as_bytes(); let b = ext.as_bytes();
if b.len() != 3 { 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() }) 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> { fn next_segment_path(p: &Path) -> Option<PathBuf> {
let ext = p.extension()?.to_str()?; let ext = p.extension()?.to_str()?;
let new = if ext.len() == 4 && ext[..2].eq_ignore_ascii_case("ex") { let new = if ext.len() == 4 && ext[..2].eq_ignore_ascii_case("ex") {
@@ -375,7 +375,7 @@ impl EwfSource {
if flen < 96 { if flen < 96 {
continue; 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 pos = flen - 64;
let mut guard = 0; let mut guard = 0;
loop { loop {
@@ -387,7 +387,7 @@ impl EwfSource {
let dsize = le64(&d, 16); let dsize = le64(&d, 16);
let pad = le32(&d, 28) as u64; let pad = le32(&d, 28) as u64;
let dstart = pos.saturating_sub(dsize); 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 _ = pad;
let read_data = |max: u64| -> io::Result<Vec<u8>> { let read_data = |max: u64| -> io::Result<Vec<u8>> {
let n = data_len.min(max) as usize; let n = data_len.min(max) as usize;
@@ -515,7 +515,7 @@ impl EwfSource {
Ok(out) 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>> { pub fn verify(&self) -> Result<Option<bool>> {
let Some(stored) = self.info.md5.clone() else { let Some(stored) = self.info.md5.clone() else {
return Ok(None); return Ok(None);
@@ -559,7 +559,7 @@ impl Source for EwfSource {
let data = &cache.as_ref().unwrap().1; let data = &cache.as_ref().unwrap().1;
let within = (pos % self.chunk_size) as usize; let within = (pos % self.chunk_size) as usize;
if within >= data.len() { 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); let n = (self.chunk_size as usize - within).min(want - done);
buf[done..done + n].fill(0); buf[done..done + n].fill(0);
done += n; 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>) { 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)) { let text = if raw.len() >= 2 && (raw[0] == 0xFF && raw[1] == 0xFE || raw.get(1) == Some(&0)) {
utf16_text(raw) 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)] #[derive(Debug, Clone)]
pub struct E01Options { pub struct E01Options {
pub chunk_sectors: u32, pub chunk_sectors: u32,
pub bytes_per_sector: 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, 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 segment_size: u64,
pub case_number: String, pub case_number: String,
pub description: String, pub description: String,
@@ -735,7 +735,7 @@ impl SegWriter {
self.pos += b.len() as u64; self.pos += b.len() as u64;
Ok(()) 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<()> { fn section(&mut self, ty: &str, data: &[u8], last: bool) -> io::Result<()> {
let size = 76 + data.len() as u64; let size = 76 + data.len() as u64;
let next = if last { self.pos } else { self.pos + size }; 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> { fn volume_data(o: &E01Options, chunks: u32, sectors: u64) -> Vec<u8> {
let mut v = vec![0u8; 1052]; 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[4..8].copy_from_slice(&chunks.to_le_bytes());
v[8..12].copy_from_slice(&o.chunk_sectors.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[12..16].copy_from_slice(&o.bytes_per_sector.to_le_bytes());
v[16..24].copy_from_slice(&sectors.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[52] = if o.compression == 0 { 0 } else { 1 };
v[56..60].copy_from_slice(&o.chunk_sectors.to_le_bytes()); v[56..60].copy_from_slice(&o.chunk_sectors.to_le_bytes());
let c = adler32(&v[..1048]); let c = adler32(&v[..1048]);
@@ -780,7 +780,7 @@ fn utc_now() -> String {
.unwrap_or(0); .unwrap_or(0);
let days = secs.div_euclid(86400); let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400); let rem = secs.rem_euclid(86400);
// Conversão civil (Howard Hinnant). // Civil calendar conversion (Howard Hinnant).
let z = days + 719468; let z = days + 719468;
let era = z.div_euclid(146097); let era = z.div_euclid(146097);
let doe = z.rem_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`. /// Creates `base.E01` (and `.E02`... when split across segments) from `src`.
/// Devolve o relatório com os hashes do conteúdo lido. /// Returns the imaging report with hashes of the imaged content.
pub fn write_e01( pub fn write_e01(
src: &dyn Source, src: &dyn Source,
base: &Path, base: &Path,
@@ -838,7 +838,7 @@ pub fn write_e01(
base.with_extension(ext) 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| { let full = |pos: u64, entries: u64| {
opts.segment_size > 0 opts.segment_size > 0
&& pos + chunk_size + 2 * (entries * 4 + 110) + 1024 >= opts.segment_size && pos + chunk_size + 2 * (entries * 4 + 110) + 1024 >= opts.segment_size
@@ -875,14 +875,14 @@ pub fn write_e01(
false, false,
)?; )?;
} }
// Grupos "sectors" + "table" + "table2" até estourar o limite do segmento. // Emit "sectors" + "table" + "table2" groups until the segment size limit is reached.
loop { loop {
let remaining = chunks_total - chunk_idx; let remaining = chunks_total - chunk_idx;
if remaining == 0 { if remaining == 0 {
break; break;
} }
let sec_start = w.pos; 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])?; w.put(&[0u8; 76])?;
let base_off = w.pos; let base_off = w.pos;
let mut entries: Vec<u32> = vec![]; let mut entries: Vec<u32> = vec![];
@@ -894,7 +894,7 @@ pub fn write_e01(
} }
let off = chunk_idx * chunk_size; let off = chunk_idx * chunk_size;
let want = (total - off).min(chunk_size) as usize; 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]; let b = &mut buf[..want];
if crate::source::read_exact_at(src, off, b).is_err() { if crate::source::read_exact_at(src, off, b).is_err() {
let mut o = 0; let mut o = 0;
@@ -930,21 +930,21 @@ pub fn write_e01(
chunk_idx += 1; chunk_idx += 1;
group_chunks += 1; group_chunks += 1;
progress(chunk_idx * chunk_size.min(total), total); 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) { if rel > 0x7000_0000 || full(w.pos, entries.len() as u64) {
break; break;
} }
} }
// Preenche a seção "sectors". // Backfill the "sectors" section descriptor.
let sec_end = w.pos; let sec_end = w.pos;
let table_size = 76 + 24 + entries.len() as u64 * 4 + 4; let table_size = 76 + 24 + entries.len() as u64 * 4 + 4;
let sectors_desc = section_desc("sectors", sec_end, sec_end - sec_start); 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}; use std::io::{Seek, SeekFrom};
w.f.seek(SeekFrom::Start(sec_start))?; w.f.seek(SeekFrom::Start(sec_start))?;
w.f.write_all(&sectors_desc)?; w.f.write_all(&sectors_desc)?;
w.f.seek(SeekFrom::Start(sec_end))?; w.f.seek(SeekFrom::Start(sec_end))?;
// table e table2 idênticas. // `table` and `table2` are identical.
for ty in ["table", "table2"] { for ty in ["table", "table2"] {
let start = w.pos; let start = w.pos;
w.put(&section_desc(ty, start + table_size, table_size))?; w.put(&section_desc(ty, start + table_size, table_size))?;
@@ -971,7 +971,7 @@ pub fn write_e01(
} }
} }
if chunk_idx >= chunks_total { 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 h = std::mem::take(&mut hasher).finalize();
let md5: Vec<u8> = (0..16) let md5: Vec<u8> = (0..16)
.map(|i| u8::from_str_radix(&h.md5[i * 2..i * 2 + 2], 16).unwrap()) .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 //! File-manager style browsing: filtering, sorting, facets
//! (tipos, datas, pastas, câmeras) e cache de miniaturas. //! (types, dates, folders, cameras), and thumbnail caching.
use crate::types::*; use crate::types::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -21,14 +21,14 @@ pub struct Query {
pub offset: usize, pub offset: usize,
#[serde(default = "default_limit")] #[serde(default = "default_limit")]
pub limit: usize, pub limit: usize,
/// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz". /// Folder path prefix, "@carved" (carved findings), or "@raiz" (root).
#[serde(default)] #[serde(default)]
pub folder: Option<String>, pub folder: Option<String>,
#[serde(default)] #[serde(default)]
pub year: Option<i64>, pub year: Option<i64>,
#[serde(default)] #[serde(default)]
pub month: Option<u32>, pub month: Option<u32>,
/// Só arquivos sem data conhecida. /// Match only files without a known date.
#[serde(default)] #[serde(default)]
pub undated: Option<bool>, pub undated: Option<bool>,
#[serde(default)] #[serde(default)]
@@ -39,12 +39,12 @@ pub struct Query {
pub min_size: Option<u64>, pub min_size: Option<u64>,
#[serde(default)] #[serde(default)]
pub max_size: Option<u64>, pub max_size: Option<u64>,
/// Megapixels mínimos. /// Minimum resolution in megapixels.
#[serde(default)] #[serde(default)]
pub min_mp: Option<f64>, pub min_mp: Option<f64>,
#[serde(default)] #[serde(default)]
pub has_gps: Option<bool>, 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)] #[serde(default)]
pub dedupe: Option<bool>, pub dedupe: Option<bool>,
} }
@@ -67,7 +67,7 @@ impl Query {
.filter(|x| !x.is_empty()) .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> { pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> {
let needle = self.needle(); let needle = self.needle();
let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect(); 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 { pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new(); let mut cats: HashMap<&'static str, usize> = HashMap::new();
let mut years: BTreeMap<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::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); 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 { pub struct Gate {
n: Mutex<usize>, n: Mutex<usize>,
cv: Condvar, 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> { pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
use crate::recover::read_found_at; use crate::recover::read_found_at;
if f.size == 0 { 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 //! Opens a recovered file in the OS default application (video player, PDF
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia: //! reader, etc.). Used when the embedded WebView cannot play media directly:
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e //! WebKitGTK bundled in the AppImage lacks GStreamer media plugins and
//! o processo da página morre ao criar um `<video>`. //! crashes the web process when instantiating a `<video>` element.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::fs::sanitize_name; use crate::fs::sanitize_name;
use crate::types::Found; use crate::types::Found;
use std::path::{Path, PathBuf}; 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 { fn temp_dir() -> PathBuf {
let uid = crate::owner::invoking_user() let uid = crate::owner::invoking_user()
.map(|u| u.uid.to_string()) .map(|u| u.uid.to_string())
.unwrap_or_else(|| { .unwrap_or_else(|| {
#[cfg(unix)] #[cfg(unix)]
{ {
// SAFETY: getuid não tem pré-condições. // SAFETY: `getuid` has no preconditions.
unsafe { libc::getuid() }.to_string() unsafe { libc::getuid() }.to_string()
} }
#[cfg(not(unix))] #[cfg(not(unix))]
@@ -26,7 +26,7 @@ fn temp_dir() -> PathBuf {
std::env::temp_dir().join(format!("guara-abrir-{uid}")) 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) { fn cleanup(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else { let Ok(rd) = std::fs::read_dir(dir) else {
return; 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> { pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
const MAX: u64 = 8 * 1024 * 1024 * 1024; const MAX: u64 = 8 * 1024 * 1024 * 1024;
if f.size > MAX { if f.size > MAX {
@@ -62,7 +62,7 @@ pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf>
Ok(path) 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<()> { pub fn open_path(path: &Path) -> std::io::Result<()> {
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
#[cfg(target_os = "linux")] #[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 //! Additional file formats recognized by internal structure (headers, chunks, and embedded sizes)
//! carving devolver o arquivo inteiro e não só o começo. Cada detector devolve o tamanho exato //! so file carving recovers complete files rather than prefixes. Each detector returns the exact
//! quando a estrutura permite e uma confiança proporcional ao que foi conferido. //! byte length when determinable and a confidence level proportional to structural validation.
use crate::signatures::Hit; use crate::signatures::Hit;
use crate::source::{read_vec, Source}; 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 { pub fn might_start(b0: u8) -> bool {
matches!( matches!(
b0, 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> { pub fn detect(src: &dyn Source, off: u64, h: &[u8], max: u64) -> Option<Hit> {
if h.starts_with(&[0x1F, 0x8B, 0x08]) { if h.starts_with(&[0x1F, 0x8B, 0x08]) {
return gzip(src, off, max); return gzip(src, off, max);
@@ -153,9 +153,9 @@ pub fn detect(src: &dyn Source, off: u64, h: &[u8], max: u64) -> Option<Hit> {
None 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( fn inflate_len(
src: &dyn Source, src: &dyn Source,
start: u64, start: u64,
@@ -311,7 +311,7 @@ fn zstd(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if btype == 3 { if btype == 3 {
return None; 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 }; let body = if btype == 1 { 1 } else { bsize };
pos += 3 + body; pos += 3 + body;
if pos - off > max { 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) hit(p as u64, "zst", Confidence::Medium)
} }
// ------------------------------------------------------------------ mídia // ------------------------------------------------------------------ media
fn vint(b: &[u8]) -> Option<(u64, usize, bool)> { fn vint(b: &[u8]) -> Option<(u64, usize, bool)> {
let first = *b.first()?; 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); 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 pos = off + (p + sl) as u64;
let mut last_good = pos; let mut last_good = pos;
for _ in 0..2_000_000 { 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) 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)> { fn vint_id(b: &[u8]) -> Option<(u64, usize, bool)> {
let first = *b.first()?; let first = *b.first()?;
let len = first.leading_zeros() as usize + 1; 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> { 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()?; let probe = read_vec(src, off, 188 * 8).ok()?;
if probe.len() < 188 * 8 || !(0..8).all(|i| probe[i * 188] == 0x47) { if probe.len() < 188 * 8 || !(0..8).all(|i| probe[i * 188] == 0x47) {
return None; return None;
@@ -492,7 +492,7 @@ fn flv(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if t.len() < 15 { if t.len() < 15 {
break; break;
} }
// PreviousTagSize(4) + tipo(1) + tamanho(3) // PreviousTagSize(4) + tag_type(1) + data_size(3)
let ty = t[4]; let ty = t[4];
if !matches!(ty, 8 | 9 | 18) { if !matches!(ty, 8 | 9 | 18) {
break; break;
@@ -559,7 +559,7 @@ fn jp2(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
}; };
if ty == b"jp2c" { if ty == b"jp2c" {
if size == 0 { 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) + 8 + (4 << 20).min(max), "jp2", Confidence::Low);
} }
return hit(pos - off + size, "jp2", Confidence::High); 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) hit(pos - off, "mid", Confidence::High)
} }
// -------------------------------------------------------- arquivos compactados // -------------------------------------------------------- archives
fn rar(src: &dyn Source, off: u64, max: u64) -> Option<Hit> { fn rar(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let h = read_vec(src, off, 8).ok()?; 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(()) .then_some(())
.and_then(|_| hit(len, "swf", Confidence::High)); .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)?; let (consumed, _, out) = inflate_len(src, off + 8, max, false, true)?;
hit( hit(
8 + consumed, 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> { fn elf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let h = read_vec(src, off, 64).ok()?; 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; 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 { if phnum > 0 && phnum < 256 && phentsize >= 32 {
let ph = read_vec(src, off + phoff, (phnum * phentsize) as usize).ok()?; let ph = read_vec(src, off + phoff, (phnum * phentsize) as usize).ok()?;
for i in 0..phnum as usize { 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); 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 first_off = u32le(&d, 12);
let probe = read_vec(src, off + first_off, 8).ok()?; 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) { 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 { if h.len() < 4096 {
return None; 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; let mut x = 0u32;
for i in 0..127 { for i in 0..127 {
x ^= u32le(&h, i * 4) as u32; 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 //! APFS (unencrypted volumes): parses the container, object map, and file system tree of each
//! volume; arquivos apagados vêm de duas fontes: as árvores dos instantâneos (snapshots) e os nós //! volume; deleted files are recovered from two sources: snapshot trees and older copy-on-write
//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são //! B-tree nodes still resident on disk. Encrypted volumes (FileVault) are detected and left for
//! detectados e deixados para a varredura por assinatura. //! signature carving unless unlocked.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan}; use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
@@ -32,13 +32,13 @@ const T_CIB: u32 = 7;
const T_CHECKPOINT_MAP: u32 = 0x0C; const T_CHECKPOINT_MAP: u32 = 0x0C;
const SUBTYPE_FSTREE: u32 = 0x0E; 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_SNAP_METADATA: u8 = 1;
const R_INODE: u8 = 3; const R_INODE: u8 = 3;
const R_FILE_EXTENT: u8 = 8; const R_FILE_EXTENT: u8 = 8;
const R_DIR_REC: u8 = 9; 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 { pub fn fletcher64(data: &[u8]) -> u64 {
let (mut s1, mut s2) = (0u64, 0u64); let (mut s1, mut s2) = (0u64, 0u64);
for w in data.chunks_exact(4) { for w in data.chunks_exact(4) {
@@ -60,7 +60,7 @@ struct Apfs<'a> {
struct BNode { struct BNode {
flags: u16, flags: u16,
level: u16, level: u16,
/// (chave, valor) por entrada /// `(key, value)` slice pair for each node entry
entries: Vec<(Vec<u8>, Vec<u8>)>, entries: Vec<(Vec<u8>, Vec<u8>)>,
oid: u64, oid: u64,
xid: 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( fn walk_physical(
&self, &self,
root: u64, 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)>> { fn omap(&self, omap_paddr: u64) -> HashMap<u64, Vec<(u64, u64)>> {
let mut m: HashMap<u64, Vec<(u64, u64)>> = HashMap::new(); let mut m: HashMap<u64, Vec<(u64, u64)>> = HashMap::new();
let Some(ob) = self.checked_block(omap_paddr) else { let Some(ob) = self.checked_block(omap_paddr) else {
@@ -174,7 +174,7 @@ impl<'a> Apfs<'a> {
m 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> { fn ephemeral_paddr(&self, nx: &[u8], oid: u64) -> Option<u64> {
let desc_blocks = le32(nx, 104) as u64; let desc_blocks = le32(nx, 104) as u64;
let desc_base = le64(nx, 112); let desc_base = le64(nx, 112);
@@ -203,8 +203,8 @@ impl<'a> Apfs<'a> {
best.map(|(_, p)| p) best.map(|(_, p)| p)
} }
/// Mapa de espaço livre do contêiner: percorre o spaceman, os chunk-info blocks e os bitmaps /// Container free-space map: walks the spaceman, chunk-info blocks (CIBs), and allocation bitmaps
/// (bit 1 = bloco em uso). /// (bit 1 = allocated block).
fn free_map(&self, nx: &[u8]) -> Option<Vec<Extent>> { fn free_map(&self, nx: &[u8]) -> Option<Vec<Extent>> {
let sm_oid = le64(nx, 152); let sm_oid = le64(nx, 152);
if sm_oid == 0 { if sm_oid == 0 {
@@ -214,7 +214,7 @@ impl<'a> Apfs<'a> {
if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN { if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN {
return None; 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 { if le32(&sm, 68) != 0 {
return None; return None;
} }
@@ -289,7 +289,7 @@ fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u
.map(|(_, p)| *p) .map(|(_, p)| *p)
} }
// ---------------------------------------------------------------- registros // ---------------------------------------------------------------- records
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct Inode { struct Inode {
@@ -308,7 +308,7 @@ struct Inode {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct FileRec { struct FileRec {
inode: Inode, inode: Inode,
names: BTreeMap<u64, String>, // pai -> nome names: BTreeMap<u64, String>, // parent_id -> name
} }
type Files = HashMap<u64, FileRec>; 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 { let mut ino = Inode {
parent: le64(v, 0), parent: le64(v, 0),
private_id: le64(v, 8), private_id: le64(v, 8),
ctime: ns(le64(v, 16)), // criação ctime: ns(le64(v, 16)), // creation timestamp
mtime: ns(le64(v, 24)), mtime: ns(le64(v, 24)),
atime: ns(le64(v, 40)), atime: ns(le64(v, 40)),
mode: le16(v, 80), 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); let flags = le64(v, 48);
ino.compressed = le32(v, 68) & 0x20 != 0; ino.compressed = le32(v, 68) & 0x20 != 0;
let _ = flags; let _ = flags;
// campos estendidos // xfields (extended fields)
let xf = &v[92..]; let xf = &v[92..];
if xf.len() >= 4 { if xf.len() >= 4 {
let n = le16(xf, 0) as usize; 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<'_> { 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( fn walk_fstree(
&self, &self,
root_oid: u64, root_oid: u64,
@@ -484,7 +484,7 @@ fn open_container<'a>(src: &'a dyn Source, base: u64) -> Option<(Apfs<'a>, Vec<u
bs, bs,
blocks, 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_blocks = le32(&b0, 104) as u64;
let desc_base = le64(&b0, 112); let desc_base = le64(&b0, 112);
let mut best = b0.clone(); 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) { 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 omap = a.omap(v.omap_paddr);
let current_xid = u64::MAX; let current_xid = u64::MAX;
// Árvore atual. // Current live filesystem tree.
let mut live: Files = HashMap::new(); let mut live: Files = HashMap::new();
let mut live_ext: Extents = HashMap::new(); let mut live_ext: Extents = HashMap::new();
let mut live_nodes: HashSet<u64> = HashSet::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); live_nodes.insert(node);
add_record(&mut live, &mut live_ext, k, val); add_record(&mut live, &mut live_ext, k, val);
}); });
// Instantâneos. // Volume snapshots.
let mut stale: Files = HashMap::new(); let mut stale: Files = HashMap::new();
let mut stale_ext: Extents = HashMap::new(); let mut stale_ext: Extents = HashMap::new();
if v.snap_tree_oid != 0 { 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 { } else {
for (_, val) in &n.entries { 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); let c = le64(val, 0);
stack.push(lookup(&omap, c, current_xid).unwrap_or(c)); 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 { if allow_stale_scan {
let total = a.blocks; let total = a.blocks;
for p in 0..total { 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(); let mut ids: Vec<&u64> = stale.keys().collect();
ids.sort(); ids.sort();
let mut seen_first: HashSet<u64> = HashSet::new(); 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; continue;
}; };
if ex.values().any(|e| e.2 != 0) { 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() { let (name, parent) = match rec.names.iter().next() {
Some((p, n)) => (n.clone(), *p), Some((p, n)) => (n.clone(), *p),
None => match &ino.name { 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; 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>> { pub fn free_extents(src: &dyn Source, base: u64) -> Option<Vec<Extent>> {
let (a, nx) = open_container(src, base)?; let (a, nx) = open_container(src, base)?;
a.free_map(&nx) 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)> { pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some((a, nx)) = open_container(src, base) else { let Some((a, nx)) = open_container(src, base) else {
return vec![]; 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 //! Btrfs (single device; single, DUP, and RAID 1 profiles): reads live trees for paths and
//! espaço livre, e arquivos apagados achados em nós antigos das árvores. Como o Btrfs nunca //! free-space mapping, and recovers deleted files from historical copy-on-write B-tree nodes.
//! sobrescreve metadados no lugar (copy-on-write), as versões anteriores dos nós continuam no //! Because Btrfs never overwrites metadata in place, prior leaf generations remain on disk
//! disco até o espaço ser reaproveitado: varremos os blocos de metadata em busca delas. //! until reallocated: we scan metadata chunks for those older leaves.
//! Extensões comprimidas (zlib, zstd, lzo) são descompactadas na recuperação. //! Compressed extents (zlib, zstd, LZO) are decompressed on recovery.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan}; use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
@@ -12,7 +12,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
const MAGIC: &[u8; 8] = b"_BHRfS_M"; const MAGIC: &[u8; 8] = b"_BHRfS_M";
const SB_OFF: u64 = 0x10000; const SB_OFF: u64 = 0x10000;
// chaves // key item types
const INODE_ITEM: u8 = 1; const INODE_ITEM: u8 = 1;
const INODE_REF: u8 = 12; const INODE_REF: u8 = 12;
const EXTENT_DATA: u8 = 108; const EXTENT_DATA: u8 = 108;
@@ -81,7 +81,7 @@ struct Chunk {
logical: u64, logical: u64,
len: u64, len: u64,
ty: u64, ty: u64,
/// (offset físico do primeiro stripe) por cópia /// Physical byte offsets of each stripe replica.
phys: Vec<u64>, phys: Vec<u64>,
} }
@@ -153,7 +153,7 @@ impl<'a> Fs<'a> {
fs.chunks.push(ch); fs.chunks.push(ch);
p += 17 + used; p += 17 + used;
} }
// chunk tree completa // full chunk tree
let mut more = vec![]; let mut more = vec![];
let cr = fs.chunk_root; let cr = fs.chunk_root;
fs.walk(cr, &mut |k, d| { 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])) { fn walk(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8])) {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
self.walk_inner(root, f, &mut seen, &mut |_| {}, 0); self.walk_inner(root, f, &mut seen, &mut |_| {}, 0);
@@ -314,7 +314,7 @@ struct InodeData {
atime: i64, atime: i64,
has_inode: bool, has_inode: bool,
refs: HashMap<u64, String>, refs: HashMap<u64, String>,
/// offset no arquivo -> (geração do nó, item) /// `file_offset -> (node_generation, extent_item)`
extents: BTreeMap<u64, (u64, Vec<u8>)>, extents: BTreeMap<u64, (u64, Vec<u8>)>,
} }
@@ -377,8 +377,8 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res; return res;
} }
// 1) Árvores atuais: raízes das subvolumes e extents alocados. // 1) Live trees: subvolume roots and allocated extents.
let mut roots: Vec<(u64, u64)> = vec![]; // (id da árvore, bytenr) let mut roots: Vec<(u64, u64)> = vec![]; // (tree_id, root_bytenr)
let mut extent_root = 0u64; let mut extent_root = 0u64;
let rr = fs.root; let rr = fs.root;
fs.walk(rr, &mut |k, d| { 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![]; let mut allocated: Vec<Extent> = vec![];
if extent_root != 0 { if extent_root != 0 {
let nodesize = fs.nodesize; 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 allocated = merge_extents(allocated);
let mut free: Vec<Extent> = vec![]; let mut free: Vec<Extent> = vec![];
for c in fs.chunks.iter().filter(|c| c.ty & 0x1 != 0) { 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 mut cur = c.logical;
let i = allocated.partition_point(|e| e.end() <= c.logical); let i = allocated.partition_point(|e| e.end() <= c.logical);
for e in &allocated[i..] { 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)); 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 mut stale: Files = HashMap::new();
let ns = fs.nodesize as usize; let ns = fs.nodesize as usize;
let step = fs.sectorsize 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; let mut i = 0usize;
while i + ns <= buf.len() && (off + i as u64) < c.len { while i + ns <= buf.len() && (off + i as u64) < c.len {
let logical = c.logical + off + i as u64; 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 if buf[i + 32..i + 48] == fs.fsid
&& le64(&buf[i..], 48) == logical && le64(&buf[i..], 48) == logical
&& buf[i + 100] == 0 && 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 path_of = |owner: u64, mut ino: u64| -> String {
let mut parts: Vec<String> = vec![]; let mut parts: Vec<String> = vec![];
for _ in 0..64 { for _ in 0..64 {
@@ -584,7 +584,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
res 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)> { fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc, bool, bool)> {
let mut parts: Vec<Part> = vec![]; let mut parts: Vec<Part> = vec![];
let mut pos = 0u64; 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| { let mut hole = |parts: &mut Vec<Part>, len: u64| {
if len > 0 { if len > 0 {
parts.push(Part::Run { offset: 0, len }); parts.push(Part::Run { offset: 0, len });
// marcador: offset 0 + sparse tratado abaixo // placeholder: offset 0 + sparse hole handled below
} }
}; };
let _ = &mut hole; let _ = &mut hole;
@@ -697,7 +697,7 @@ fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc,
.collect(); .collect();
return Some((DataLoc::Extents { extents: ex }, any_alloc, all_ok)); 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![]; let mut out: Vec<Part> = vec![];
for (i, p) in parts.into_iter().enumerate() { for (i, p) in parts.into_iter().enumerate() {
if sparse_marks.contains(&i) { 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] /// Btrfs LZO extent format: total length (`u32`) + `[seg_len (u32)][LZO1X payload]` segments
/// de até um setor de 4 KiB descomprimido cada, sem cruzar fronteiras de setor. /// of up to one 4 KiB uncompressed sector each, padded at sector boundaries.
pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> { pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
if raw.len() < 4 { if raw.len() < 4 {
return None; 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 out = Vec::with_capacity(ram);
let mut p = 4usize; let mut p = 4usize;
while p + 4 <= raw.len().min(total) && out.len() < ram { 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); let in_sector = 4096 - (p % 4096);
if in_sector < 4 { if in_sector < 4 {
p += in_sector; p += in_sector;
@@ -758,7 +758,7 @@ pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
Some(out) 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>> { pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max_out); let mut out: Vec<u8> = Vec::with_capacity(max_out);
let mut ip = 0usize; let mut ip = 0usize;
@@ -778,7 +778,7 @@ pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
*ip += 1; *ip += 1;
Some(v) 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; let mut state;
if byte(0)? > 17 { if byte(0)? > 17 {
let t = 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(()) 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)> { pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some(fs) = Fs::open(src, base) else { let Some(fs) = Fs::open(src, base) else {
return vec![]; return vec![];
@@ -900,18 +900,18 @@ pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
out 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 struct TreeLocations {
pub nodesize: u64, 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 root_tree: u64,
pub fs_tree: u64, pub fs_tree: u64,
pub extent_tree: u64, pub extent_tree: u64,
/// Endereços lógicos correspondentes. /// Corresponding logical `bytenr` addresses.
pub root_tree_logical: u64, pub root_tree_logical: u64,
pub fs_tree_logical: u64, pub fs_tree_logical: u64,
pub extent_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)>, 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::{ use super::{
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan, 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![], 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_chain = vol.chain(vol.root);
let root_data = vol.read(&root_chain, 64 << 20); let root_data = vol.read(&root_chain, 64 << 20);
let mut bm: Option<(u32, u64)> = None; 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()); .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 live = vec![Extent::new(base, heap_off - base)];
let mut run_start: Option<u32> = None; let mut run_start: Option<u32> = None;
for c in 2..clusters + 2 { for c in 2..clusters + 2 {
@@ -163,7 +163,7 @@ fn walk(
break; break;
} }
let ty = e[0]; 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 { if ty != 0x85 && ty != 0x05 {
i += 1; i += 1;
continue; continue;
@@ -214,7 +214,7 @@ fn walk(
} }
let need = size.div_ceil(vol.cbytes) as u32; let need = size.div_ceil(vol.cbytes) as u32;
let cl: Vec<u32> = if no_chain || deleted { 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![] }; let chain = if !no_chain { vol.chain(first) } else { vec![] };
if chain.len() as u32 >= need.max(1) { if chain.len() as u32 >= need.max(1) {
chain 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; //! 1. deleted inodes (`dtime != 0`) whose extent tree or block pointers still have remnants;
//! 2. cópias antigas do inode guardadas no diário (jbd2), de antes da exclusão; //! 2. pre-deletion inode copies preserved in the jbd2 journal;
//! 3. entradas de diretório "escondidas" na folga do `rec_len` (dão o nome). //! 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 super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::signatures; use crate::signatures;
@@ -103,7 +103,7 @@ struct Ext<'a> {
journal_inum: u32, 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)>; type ExtentList = Vec<(u64, u64, u64, bool)>;
impl<'a> Ext<'a> { impl<'a> Ext<'a> {
@@ -201,7 +201,7 @@ impl<'a> Ext<'a> {
Inode::parse(&self.inode_bytes(ino)?) 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( fn walk_extent_node(
&self, &self,
node: &[u8], node: &[u8],
@@ -248,7 +248,7 @@ impl<'a> Ext<'a> {
true 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 { fn block_map(&self, blk: &[u8; 60], max_blocks: u64) -> ExtentList {
let mut v: Vec<u64> = vec![]; let mut v: Vec<u64> = vec![];
let ptr = |i: usize| le32(blk, i * 4) as u64; let ptr = |i: usize| le32(blk, i * 4) as u64;
@@ -308,7 +308,7 @@ impl<'a> Ext<'a> {
out 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> { fn file_extents(&self, ino: &Inode, size: u64) -> Vec<Extent> {
let need = size.div_ceil(self.bs); let need = size.div_ceil(self.bs);
let mut list: ExtentList = vec![]; let mut list: ExtentList = vec![];
@@ -351,7 +351,7 @@ impl<'a> Ext<'a> {
out 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>) { fn free_map(&self, ctx: &Ctx) -> (Vec<Extent>, Vec<bool>) {
let mut free: Vec<Extent> = vec![]; let mut free: Vec<Extent> = vec![];
let mut allocated_all: Vec<bool> = vec![]; let mut allocated_all: Vec<bool> = vec![];
@@ -418,7 +418,7 @@ struct DirEnt {
live: bool, 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>) { fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
let mut off = 0usize; let mut off = 0usize;
while off + 8 <= b.len() { while off + 8 <= b.len() {
@@ -437,11 +437,11 @@ fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
live: true, 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 mut s = off + if ino != 0 || nlen > 0 { real } else { 8 };
let end = off + rec; let end = off + rec;
if ino == 0 { if ino == 0 {
s = end; // entrada nula: sem folga confiável s = end; // zeroed entry: no reliable slack to scan
} }
while s + 8 <= end { while s + 8 <= end {
let dino = le32(b, s) as u64; 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 { struct Names {
/// inode -> (pasta, nome) de entradas vivas. /// `inode -> (parent_dir, name)` from live directory entries.
live: HashMap<u64, (String, String)>, 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)>, dead: HashMap<u64, (String, String)>,
} }
@@ -531,9 +531,9 @@ fn walk_dirs(ext: &Ext, ctx: &Ctx) -> Names {
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>>> { fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
let mut out: HashMap<u64, Vec<Vec<u8>>> = HashMap::new(); let mut out: HashMap<u64, Vec<Vec<u8>>> = HashMap::new();
if ext.journal_inum == 0 { if ext.journal_inum == 0 {
@@ -543,7 +543,7 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
return out; return out;
}; };
let jext = ext.file_extents(&jino, jino.size.min(1 << 30)); 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![]; let mut jblocks: Vec<u64> = vec![];
for e in &jext { for e in &jext {
if e.sparse { 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 } 8 + if is64 { 4 } else { 0 }
}; };
let have_csum = csum_v2 || csum_v3; 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 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() { for (g, gr) in ext.groups.iter().enumerate() {
tables.push(( tables.push((
gr.inode_table, gr.inode_table,
@@ -588,9 +588,9 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
} }
let n = jblocks.len(); let n = jblocks.len();
let first = (be32(&jsb, 20) as usize).clamp(1, n); 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; 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 { while idx < n {
if idx % 4096 == 4095 && (ctx.cancelled)() { if idx % 4096 == 4095 && (ctx.cancelled)() {
break; break;
@@ -664,7 +664,7 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
out out
} }
// -------------------------------------------------------------- varredura // -------------------------------------------------------------- scan
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan { pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let mut res = VolumeScan::default(); 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 emitted: HashSet<u64> = HashSet::new();
let mut seen_extents: HashSet<(u64, 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; let total = ext.groups.len() as u64;
for (g, gr) in ext.groups.iter().enumerate() { for (g, gr) in ext.groups.iter().enumerate() {
if g % 16 == 15 && (ctx.cancelled)() { 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() { if !deleted || !ino.is_reg() {
continue; 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; let mut cand: Option<(Inode, &str)> = None;
if let Some(snaps) = journal.get(&ino_no) { if let Some(snaps) = journal.get(&ino_no) {
for raw in snaps.iter().rev() { 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 { for (ino_no, snaps) in &journal {
if emitted.contains(ino_no) || names.live.contains_key(ino_no) || *ino_no < 11 { if emitted.contains(ino_no) || names.live.contains_key(ino_no) || *ino_no < 11 {
continue; continue;
} }
let cur = ext.read_inode(*ino_no); 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 if cur
.as_ref() .as_ref()
.map(|c| c.dtime == 0 && c.links > 0 && c.mode != 0) .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 { if ino.flags & EXT4_EXTENTS_FL != 0 {
let mut list: ExtentList = vec![]; let mut list: ExtentList = vec![];
if !ext.walk_extent_node(&ino.block, 5, &mut list, true) { 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); list = remnant_extents(ext, &ino.block);
} }
if list.is_empty() { if list.is_empty() {
@@ -834,7 +834,7 @@ fn emit_inode(
if !seen.insert((first.0, first.1)) { if !seen.insert((first.0, first.1)) {
return false; 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 reused = 0u64;
let mut total_blocks = 0u64; let mut total_blocks = 0u64;
for e in extents.iter().filter(|e| !e.sparse) { for e in extents.iter().filter(|e| !e.sparse) {
@@ -859,7 +859,7 @@ fn emit_inode(
)); ));
} }
if estimated { 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 if let Some(h) = extents
.iter() .iter()
.find(|e| !e.sparse) .find(|e| !e.sparse)
@@ -932,7 +932,7 @@ fn layout_from_list(ext: &Ext, list: &ExtentList, size: u64) -> Vec<Extent> {
out 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 { fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList {
let mut out: ExtentList = vec![]; let mut out: ExtentList = vec![];
if le16(block, 0) != EXT_MAGIC || le16(block, 6) != 0 { if le16(block, 0) != EXT_MAGIC || le16(block, 6) != 0 {
@@ -958,8 +958,8 @@ fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList {
out out
} }
/// Quão coerente é a estrutura do volume: fração das entradas de diretório cujo inode confere /// Measures filesystem structural coherence: fraction of directory entries whose target inode
/// com o tipo declarado. Serve para escolher parâmetros de RAID sem metadados. /// matches the declared file type. Used to score candidate metadata-less RAID parameters.
pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> { pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> {
let ext = Ext::open(src, base)?; let ext = Ext::open(src, base)?;
let mut queue = vec![2u64]; let mut queue = vec![2u64];
@@ -988,8 +988,8 @@ pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> {
o += ext.bs; o += ext.bs;
} }
} }
// A entrada "." de um diretório aponta para ele mesmo: só confere se inode e bloco // A directory's "." entry points to itself: only matches if both the inode table and
// vieram do lugar certo. // directory data block were mapped from the right RAID stripe.
dots_total += 1; dots_total += 1;
if ents.iter().any(|e| e.live && e.name == "." && e.ino == d) { if ents.iter().any(|e| e.live && e.name == "." && e.ino == d) {
dots_ok += 1; dots_ok += 1;
+9 -9
View File
@@ -1,5 +1,5 @@
//! FAT12/16/32: entradas de diretório apagadas (inclusive nomes longos) e //! FAT12/16/32: deleted directory entries (including VFAT Long File Names) and
//! reconstrução dos clusters pela regra "próximos clusters livres". //! cluster chain reconstruction using contiguous free clusters.
use super::{make_found, merge_extents, sanitize_name, utf16le, Ctx, NewFound, VolumeScan}; use super::{make_found, merge_extents, sanitize_name, utf16le, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source}; 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> { fn chain(&self, start: u32) -> Vec<u32> {
let mut out = vec![]; let mut out = vec![];
let mut c = start; 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()); .push("FAT: setor de boot ilegível; usando apenas assinatura.".into());
return res; 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 live = vec![Extent::new(base, fat.data_off - base)];
let mut w = Walk { let mut w = Walk {
fat: &fat, fat: &fat,
@@ -257,7 +257,7 @@ impl Walk<'_, '_, '_, '_> {
continue; continue;
} }
if deleted || in_deleted { 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 cl = self.contiguous_free(start, 16);
let data = self let data = self
.fat .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> { pub fn trim_extents(mut ex: Vec<Extent>, size: u64) -> Vec<Extent> {
let mut left = size; let mut left = size;
let mut out = vec![]; let mut out = vec![];
@@ -416,15 +416,15 @@ fn long_name(parts: &[(u8, [u16; 13])], short: &[u8; 11]) -> Option<String> {
for &b in short { for &b in short {
sum = (sum >> 1).wrapping_add((sum & 1) << 7).wrapping_add(b); sum = (sum >> 1).wrapping_add((sum & 1) << 7).wrapping_add(b);
} }
// Entradas LFN de um arquivo apagado preservam o checksum do nome curto original, // Deleted LFN entries preserve the checksum of the original 8.3 short name,
// porém o primeiro byte do nome curto virou 0xE5; tentamos as duas variantes. // whereas the short entry's first byte became 0xE5; check both variants.
let ok = parts.iter().all(|p| p.0 == parts[0].0); let ok = parts.iter().all(|p| p.0 == parts[0].0);
if !ok { if !ok {
return None; return None;
} }
let target = parts[0].0; let target = parts[0].0;
if sum != target { 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; let mut hit = false;
for c in 0u8..=255 { for c in 0u8..=255 {
let mut s2 = *short; 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 //! Reads the catalog B-tree for names and paths, the allocation bitmap for free-space
//! espaço livre e procura arquivos apagados em três lugares: sobras dentro dos nós folha do //! mapping, and searches for deleted files in three places: slack space inside catalog leaf
//! catálogo, cópias antigas de nós no diário (journal) e registros de arquivo "soltos". //! nodes, older leaf node copies in the journal, and orphaned file records.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan}; use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
@@ -49,7 +49,7 @@ fn parse_fork(b: &[u8]) -> Fork {
f 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> { pub fn locate(src: &dyn Source, base: u64) -> Option<u64> {
let h = read_vec(src, base + 1024, 512).ok()?; let h = read_vec(src, base + 1024, 512).ok()?;
if h.len() < 512 { if h.len() < 512 {
@@ -106,7 +106,7 @@ impl<'a> Vol<'a> {
self.base + b * self.bs 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> { 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 ex: Vec<(u32, u32)> = f.extents.clone();
let mut have: u64 = ex.iter().map(|e| e.1 as u64).sum(); let mut have: u64 = ex.iter().map(|e| e.1 as u64).sum();
@@ -124,7 +124,7 @@ impl<'a> Vol<'a> {
.collect() .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)> { fn overflow_extents(&self, file_id: u32, fork_type: u8, mut start: u32) -> Vec<(u32, u32)> {
let mut out = vec![]; let mut out = vec![];
let node_size = match self.tree_node_size(&self.extents_file) { let node_size = match self.tree_node_size(&self.extents_file) {
@@ -140,7 +140,7 @@ impl<'a> Vol<'a> {
continue; continue;
} }
for r in records(&node) { 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 { if r.len() < 12 + 64 || be16(r, 0) != 10 {
continue; continue;
} }
@@ -170,7 +170,7 @@ impl<'a> Vol<'a> {
self.read_fork_bytes(f, n as u64 * ns as u64, ns) 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>> { fn read_fork_bytes(&self, f: &Fork, off: u64, len: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(len); let mut out = Vec::with_capacity(len);
let mut pos = 0u64; 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]> { fn records(node: &[u8]) -> Vec<&[u8]> {
let n = be16(node, 10) as usize; let n = be16(node, 10) as usize;
let mut offs = vec![]; let mut offs = vec![];
@@ -233,7 +233,7 @@ fn date(d: u32) -> Option<i64> {
(d != 0).then(|| d as i64 - HFS_EPOCH) (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( fn decode_record(
r: &[u8], r: &[u8],
max_id: u32, max_id: u32,
@@ -265,7 +265,7 @@ fn decode_record(
} }
} }
3 if rec.len() >= 10 => { 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; let pn = be16(rec, 8) as usize;
if pn <= 255 { if pn <= 255 {
threads.entry(parent).or_insert(( 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( fn scan_slack(
node: &[u8], node: &[u8],
max_id: u32, max_id: u32,
@@ -346,7 +346,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res; 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) { if let Some(bm) = vol.read_fork_all(&vol.alloc, (vol.total / 8 + 1) as usize) {
let mut free = vec![]; let mut free = vec![];
let mut run: Option<u64> = None; 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); 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 { if vol.journal_info != 0 {
scan_journal( scan_journal(
&vol, &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 path_of = |parent: u32| -> String {
let mut parts = vec![]; let mut parts = vec![];
let mut cur = parent; 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)) { if !seen.insert((f.id, ex[0].offset)) {
continue; 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); 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 reused = extents.iter().filter(|e| !covered_by(&free_set, e)).count();
let (conf, note) = if reused == 0 { 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( fn scan_journal(
vol: &Vol, vol: &Vol,
ns: usize, ns: usize,
@@ -556,7 +556,7 @@ fn scan_journal(
let mut i = 0; let mut i = 0;
while i + ns <= buf.len().min(n + ns) && i < n { while i + ns <= buf.len().min(n + ns) && i < n {
let nd = &buf[i..i + ns]; 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 { 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); let table = ns - 2 * (be16(nd, 10) as usize + 1);
if (be16(nd, ns - 2) as usize) == 14 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, //! Each reader walks volume metadata structures looking for deleted files,
//! devolve os extents ainda alocados ("live") para o carving pular e, quando //! returns currently allocated ("live") extents so file carving can skip them,
//! possível, os trechos livres. //! and, when available, returns the exact free-space map.
pub mod apfs; pub mod apfs;
pub mod btrfs; pub mod btrfs;
@@ -18,12 +18,12 @@ use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Found, Origin}; use crate::types::{Confidence, DataLoc, Extent, Found, Origin};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
/// Contexto compartilhado por todos os leitores. /// Context shared across all filesystem metadata readers.
pub struct Ctx<'a> { pub struct Ctx<'a> {
pub src: &'a dyn Source, pub src: &'a dyn Source,
pub next_id: &'a AtomicU64, pub next_id: &'a AtomicU64,
pub cancelled: &'a (dyn Fn() -> bool + Sync), 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 progress: &'a (dyn Fn(u64, u64) + Sync),
pub emit: &'a mut dyn FnMut(Found), 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)] #[derive(Default)]
pub struct VolumeScan { 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>, 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 free: Option<Vec<Extent>>,
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -57,7 +57,7 @@ pub struct NewFound<'a> {
pub note: Option<&'a str>, 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 { pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
let ext = n let ext = n
.name .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> { pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
v.retain(|e| e.len > 0); v.retain(|e| e.len > 0);
v.sort_by_key(|e| e.offset); v.sort_by_key(|e| e.offset);
@@ -135,7 +135,7 @@ pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
out 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 { pub fn overlaps(sorted: &[Extent], off: u64, len: u64) -> bool {
let i = sorted.partition_point(|e| e.end() <= off); let i = sorted.partition_point(|e| e.end() <= off);
sorted.get(i).map(|e| e.offset < off + len).unwrap_or(false) 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) 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 { pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y }; let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400; 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 //! NTFS: walks the MFT for deleted file records, reconstructs folder paths,
//! reconstrói os data runs. Coleta também os extents vivos. //! and decodes data runs. Also collects live allocated extents.
use super::{ use super::{
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan, 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; use std::collections::HashMap;
struct Run { struct Run {
/// Cluster lógico inicial no arquivo (VCN). /// Starting Virtual Cluster Number (VCN) within the file.
vcn: u64, vcn: u64,
len: u64, len: u64,
/// None = esparso. /// `None` = sparse run.
lcn: Option<u64>, lcn: Option<u64>,
} }
@@ -61,7 +61,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
return res; 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; let mut mft_runs: Option<(Vec<Run>, u64)> = None;
for lcn in [mft_lcn, mirr_lcn] { for lcn in [mft_lcn, mirr_lcn] {
if let Some(mut r) = read_vec(ctx.src, base + lcn * cl, rec_size as usize) 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 dirs: HashMap<u64, (u64, String)> = HashMap::new();
let mut deleted: Vec<(u64, Rec)> = vec![]; 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 chunk_recs = (1 << 20) / rec_size.max(1);
let mut idx = 0u64; let mut idx = 0u64;
let mut buf: Vec<u8>; let mut buf: Vec<u8>;
@@ -161,7 +161,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
unsupported, unsupported,
} => { } => {
if unsupported { if unsupported {
continue; // comprimido/cifrado (EFS): fora do escopo da v1 continue; // compressed/encrypted (EFS) attributes are skipped
} }
let mut ex = vec![]; let mut ex = vec![];
for r in &runs { 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()) 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> { fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
if r.len() < 48 || &r[0..4] != b"FILE" { if r.len() < 48 || &r[0..4] != b"FILE" {
return None; return None;
@@ -308,7 +308,7 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
break; break;
} }
if r[end - 2..end] != usn { 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 - 2] = r[usa_off + i * 2];
r[end - 1] = r[usa_off + i * 2 + 1]; 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 flags = le16(r, 22);
let base_ref = le64(r, 32) & 0xFFFF_FFFF_FFFF; let base_ref = le64(r, 32) & 0xFFFF_FFFF_FFFF;
if base_ref != 0 { 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 { let mut rec = Rec {
in_use: flags & 1 != 0, 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 //! XFS (v4 and v5): free-space map from each allocation group's free-block B+tree and deleted
//! arquivos apagados. Ao liberar um inode, o XFS zera o modo e o tamanho mas não apaga a lista de //! file recovery. When freeing an inode, XFS zeroes mode and size if not fully wiped, leaving
//! extents que ficou dentro do inode; e a entrada de diretório liberada perde só os 4 primeiros //! inline extent records in the inode fork; freed directory entries only overwrite the first
//! bytes (o número do inode), preservando o nome. Juntamos as duas sobras. //! 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 super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::signatures; use crate::signatures;
@@ -88,7 +88,7 @@ impl<'a> Xfs<'a> {
self.base + agno * self.agblocks * self.bs 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> { fn fsb_off(&self, fsb: u64) -> Option<u64> {
let (agno, agbno) = (fsb >> self.agblklog, fsb & ((1 << self.agblklog) - 1)); 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) (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)> { fn extents_of(&self, ino: &[u8], max_blocks: u64) -> Vec<(u64, u64, u64)> {
let fmt = ino[5]; let fmt = ino[5];
let start = self.core_size(ino); let start = self.core_size(ino);
@@ -135,7 +135,7 @@ impl<'a> Xfs<'a> {
} }
} }
3 => { 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 level = be16(lit, 0) as usize;
let nrecs = be16(lit, 2) as usize; let nrecs = be16(lit, 2) as usize;
let maxrecs = (lit.len() - 4) / 16; 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) { fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
let hi = u64::from_be_bytes(r[0..8].try_into().unwrap()); let hi = u64::from_be_bytes(r[0..8].try_into().unwrap());
let lo = u64::from_be_bytes(r[8..16].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) (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( fn walk_short_btree(
x: &Xfs, x: &Xfs,
agno: u64, agno: u64,
@@ -201,7 +201,7 @@ fn walk_short_btree(
f: &mut dyn FnMut(&[u8]), f: &mut dyn FnMut(&[u8]),
) { ) {
let mut cur = root; let mut cur = root;
// desce pela esquerda até a folha // descend leftmost branch down to the leaf level
for lvl in (1..levels).rev() { 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 { let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else {
return; return;
@@ -241,10 +241,10 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res; 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: Vec<Extent> = vec![];
let mut free_set: Vec<(u64, u64)> = vec![]; // (fsblock inicial, quantidade) para checar blocos 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, máscara livre) let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, free_bitmask)
for agno in 0..x.agcount { for agno in 0..x.agcount {
if (ctx.cancelled)() { if (ctx.cancelled)() {
return res; 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) .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); 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 max_blocks = x.dblocks;
let mut seen_first: HashSet<u64> = HashSet::new(); let mut seen_first: HashSet<u64> = HashSet::new();
for (agno, startino, mask) in &chunks { 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 { for i in 0..64u64 {
if mask >> i & 1 == 0 { if mask >> i & 1 == 0 {
continue; // em uso continue; // allocated inode
} }
let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i); let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i);
let Some(raw) = x.read_ino(ino) else { continue }; 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 { if mode != 0 && be32(&raw, 16) != 0 {
continue; 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 { if raw[5] != 2 {
continue; continue;
} }
let start = x.core_size(&raw); let start = x.core_size(&raw);
let lit = &raw[start..]; 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 ext: Vec<(u64, u64, u64)> = vec![];
let mut next_off = 0u64; let mut next_off = 0u64;
for k in 0..lit.len() / 16 { for k in 0..lit.len() / 16 {
@@ -346,7 +346,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
} else { } else {
"blocos do arquivo já reaproveitados".to_string() "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; let mut est = true;
if let Some(h) = read_vec(ctx.src, extents[0].offset, 128) if let Some(h) = read_vec(ctx.src, extents[0].offset, 128)
.ok() .ok()
@@ -394,7 +394,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
res 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)>) { fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64, String)>) {
let mut paths: HashMap<u64, String> = HashMap::new(); let mut paths: HashMap<u64, String> = HashMap::new();
let mut stale: HashMap<u64, (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); let start = x.core_size(&raw);
match raw[5] { match raw[5] {
1 => { 1 => {
// pasta curta guardada no inode // shortform directory stored inline inside the inode
let lit = &raw[start..]; let lit = &raw[start..];
let count = lit[0] as usize; let count = lit[0] as usize;
let i8 = lit[1] != 0; 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 ex = x.extents_of(&raw, 1 << 20);
let _ = size; let _ = size;
for (off, fsb, cnt) in ex { 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 { if off * x.bs >= 32u64 << 30 {
continue; continue;
} }
@@ -479,7 +479,7 @@ fn parse_dir_block(
_ => return, _ => return,
}; };
let is_block = magic == b"XD2B" || magic == b"XDB3"; 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 end = if is_block {
let cnt = be32(b, b.len() - 8) as usize; let cnt = be32(b, b.len() - 8) as usize;
b.len().saturating_sub(8 + cnt * 8) b.len().saturating_sub(8 + cnt * 8)
@@ -493,7 +493,7 @@ fn parse_dir_block(
if len < 8 || p + len > end { if len < 8 || p + len > end {
break; 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); scan_free_region(x, &b[p..p + len], dino, stale);
p += len; p += len;
continue; 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)>) { 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 try_at = |p: usize, low: u64, stale: &mut HashMap<u64, (u64, String)>| -> Option<usize> {
let nl = *r.get(p + 8)? as usize; let nl = *r.get(p + 8)? as usize;
let name = r.get(p + 9..p + 9 + nl)?; 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 { if r.len() >= 16 {
let low = be32(r, 4) as u64; let low = be32(r, 4) as u64;
if let Some(sz) = try_at(0, low, stale) { 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; let mut p = sz;
while p + 16 <= r.len() { while p + 16 <= r.len() {
let ino = be64(r, p); 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 md5::Md5;
use serde::{Deserialize, Serialize}; 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 //! Pre-scan disk health assessment: reads SMART data (via `smartctl`, when installed) and
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma //! kernel I/O error counters. A failing drive should be imaged first, as every additional
//! imagem primeiro: cada leitura extra pode piorar o estado dele. //! read pass can accelerate media degradation.
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
@@ -8,43 +8,43 @@ use std::process::{Command, Stdio};
#[derive(Debug, Clone, Serialize, Default)] #[derive(Debug, Clone, Serialize, Default)]
pub struct Health { 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, pub available: bool,
/// "ok", "warning", "failing" ou "unknown". /// Overall status: `"ok"`, `"warning"`, `"failing"`, or `"unknown"`.
pub status: String, pub status: String,
pub summary: String, pub summary: String,
pub details: Vec<(String, String)>, pub details: Vec<(String, String)>,
pub advice: Vec<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)>, 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 health_pct: Option<u8>,
pub temperature_c: Option<i64>, pub temperature_c: Option<i64>,
pub power_on_hours: Option<i64>, pub power_on_hours: Option<i64>,
pub power_cycles: 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_reads: Option<u64>,
pub host_writes: Option<u64>, pub host_writes: Option<u64>,
/// Tabela completa de atributos SMART. /// Full table of SMART attributes.
pub attributes: Vec<Attr>, 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, 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>, pub partition: Option<String>,
} }
/// Uma linha da tabela de atributos SMART. /// A single row in the SMART attribute table.
#[derive(Debug, Clone, Serialize, Default)] #[derive(Debug, Clone, Serialize, Default)]
pub struct Attr { 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 id: String,
pub name: 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 value: Option<i64>,
pub worst: Option<i64>, pub worst: Option<i64>,
pub thresh: Option<i64>, pub thresh: Option<i64>,
pub raw: String, pub raw: String,
/// "ok", "warning" ou "failing". /// Attribute status: `"ok"`, `"warning"`, or `"failing"`.
pub status: String, 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."; 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 { pub fn check(path: &str) -> Health {
#[cfg(unix)] #[cfg(unix)]
let is_dev = { let is_dev = {
@@ -88,8 +88,8 @@ pub fn check(path: &str) -> Health {
h h
} }
/// O SMART é do disco, não da partição: `/dev/sda1` vira `/dev/sda`, /// SMART belongs to the physical disk rather than a partition: `/dev/sda1` -> `/dev/sda`,
/// `/dev/nvme0n1p2` vira `/dev/nvme0n1` e `/dev/disk2s1` vira `/dev/disk2`. /// `/dev/nvme0n1p2` -> `/dev/nvme0n1`, and `/dev/disk2s1` -> `/dev/disk2`.
pub fn parent_disk(path: &str) -> String { pub fn parent_disk(path: &str) -> String {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@@ -117,7 +117,7 @@ pub fn parent_disk(path: &str) -> String {
} }
fn smartctl_name(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 if let Some(n) = path
.strip_prefix(r"\\.\PhysicalDrive") .strip_prefix(r"\\.\PhysicalDrive")
.and_then(|n| n.parse::<u8>().ok()) .and_then(|n| n.parse::<u8>().ok())
@@ -139,9 +139,9 @@ fn run_smartctl(path: &str) -> Option<Value> {
.stderr(Stdio::null()) .stderr(Stdio::null())
.output() .output()
.ok()?; .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()?; 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") if v.pointer("/smartctl/messages")
.and_then(|m| m.as_array()) .and_then(|m| m.as_array())
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error")) .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() .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 { pub fn parse_smartctl(v: &Value) -> Health {
let mut h = Health { let mut h = Health {
available: true, available: true,
status: "ok".into(), status: "ok".into(),
..Default::default() ..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| { let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
bad = bad.max(sev); bad = bad.max(sev);
h.details.push((k.to_string(), val)); h.details.push((k.to_string(), val));
@@ -204,7 +204,7 @@ pub fn parse_smartctl(v: &Value) -> Health {
h.details h.details
.push(("Autoavaliação SMART".into(), "aprovado".into())); .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 let Some(n) = attr(v, 5) {
if n > 0 { if n > 0 {
note( note(
@@ -320,7 +320,7 @@ fn n(v: &Value, p: &str) -> Option<i64> {
v.pointer(p).and_then(|x| x.as_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) { fn fill_info(v: &Value, h: &mut Health) {
let mut put = |k: &str, val: Option<String>| { let mut put = |k: &str, val: Option<String>| {
if let Some(val) = val { 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_on_hours = n(v, "/power_on_time/hours");
h.power_cycles = n(v, "/power_cycle_count"); h.power_cycles = n(v, "/power_cycle_count");
if let Some(l) = v.get("nvme_smart_health_information_log") { 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_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.host_writes = l["data_units_written"].as_u64().map(|u| u * 512_000);
h.health_pct = l["percentage_used"] 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; 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_reads = attr(v, 242).map(|x| x as u64 * lba);
h.host_writes = attr(v, 241).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) // (231 SSD Life Left, 233 Media Wearout, 177 Wear Leveling, 202 Percent Lifetime Remain)
h.health_pct = [231u64, 233, 177, 202, 169] h.health_pct = [231u64, 233, 177, 202, 169]
.iter() .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 { fn decimal_size(b: u64) -> String {
let units = ["B", "kB", "MB", "GB", "TB", "PB"]; let units = ["B", "kB", "MB", "GB", "TB", "PB"];
let mut x = b as f64; let mut x = b as f64;
@@ -437,7 +437,7 @@ fn attr_field(v: &Value, id: u64, f: &str) -> Option<i64> {
.as_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]; const RAW_WARN: [u64; 5] = [5, 196, 197, 198, 187];
fn fill_attributes(v: &Value, h: &mut Health) { 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") { 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] = [ let rows: [(u8, &str, &str); 15] = [
(1, "Critical Warning", "critical_warning"), (1, "Critical Warning", "critical_warning"),
(2, "Composite Temperature", "temperature"), (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) { fn kernel_errors(path: &str, h: &mut Health) {
#[cfg(target_os = "linux")] #[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 crate::service::Engine;
use tiny_http::{Header, Method, Request, Response, StatusCode}; 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() 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 { pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
req.headers() req.headers()
.iter() .iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str())) .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`. /// Serves `/file/<job>/<id>` (with Range support) and `/thumb/<job>/<id>?s=NNN`.
/// Devolve a requisição quando ela não é uma rota de mídia. /// Returns `Err(req)` when the request is not a media route.
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> { pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
let full = req.url().to_string(); let full = req.url().to_string();
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), "")); 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 //! Forensic disk imaging: fault-tolerant reading across bad sectors with an error
//! erros e hash. Serve para trabalhar sempre sobre a cópia. //! map and integrity hashes so recovery always runs against a copy.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::hashing::MultiHasher; use crate::hashing::MultiHasher;
@@ -16,7 +16,7 @@ pub struct ImageReport {
pub md5: String, pub md5: String,
#[serde(default)] #[serde(default)]
pub sha1: String, 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 bad_ranges: Vec<(u64, u64)>,
pub cancelled: bool, pub cancelled: bool,
} }
@@ -29,7 +29,7 @@ const SECTOR: usize = 512;
pub enum ImageFormat { pub enum ImageFormat {
#[default] #[default]
Raw, Raw,
/// Expert Witness (EnCase 6): `.E01`, dividido em segmentos. /// Expert Witness Format (EnCase 6): segmented `.E01`.
E01 { E01 {
#[serde(default)] #[serde(default)]
case_number: String, case_number: String,
@@ -41,14 +41,14 @@ pub enum ImageFormat {
examiner: String, examiner: String,
#[serde(default)] #[serde(default)]
notes: String, 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")] #[serde(default = "default_segment_mib")]
segment_mib: u64, segment_mib: u64,
/// 0 = sem compressão, 1 a 9 = deflate. /// 0 = uncompressed, 1..=9 = deflate compression level.
#[serde(default = "default_compression")] #[serde(default = "default_compression")]
compression: u32, compression: u32,
}, },
/// AFF4 (padrão v1.0, blocos snappy). /// AFF4 (v1.0 standard, snappy-compressed chunks).
Aff4 { Aff4 {
#[serde(default)] #[serde(default)]
case_number: String, case_number: String,
@@ -119,7 +119,7 @@ pub fn create_image_as(
compression: *compression, compression: *compression,
..Default::default() ..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()) { let base = match dest.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""), Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""),
_ => dest.to_path_buf(), _ => dest.to_path_buf(),
@@ -160,7 +160,7 @@ pub fn create_image(
match read_full(src, pos, &mut buf[..want]) { match read_full(src, pos, &mut buf[..want]) {
Ok(()) => {} Ok(()) => {}
Err(_) => { Err(_) => {
// Refaz setor a setor para isolar os trechos ruins. // Retry sector by sector to isolate the exact bad sectors.
let mut o = 0; let mut o = 0;
while o < want { while o < want {
let n = SECTOR.min(want - o); 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 //! Validates whether a JPEG stream is contiguous and assists in reassembling fragmented
//! fragmentados: o fluxo é decodificado MCU a MCU, guardando o DC de luminância (uma miniatura //! JPEGs: decodes MCU by MCU, tracking luminance DC values (a 1/8th thumbnail) and
//! 1/8 da imagem) e pontos de retomada. Depois de uma quebra os bytes viram lixo, e a //! decoder checkpoints. After a fragmentation break, foreign clusters cause either
//! descontinuidade do DC (ou um erro de código) denuncia onde. //! a Huffman/marker error or a sharp luminance discontinuity.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Huff { struct Huff {
// decodificação canônica // canonical Huffman decoding tables
mincode: [i32; 17], mincode: [i32; 17],
maxcode: [i32; 18], maxcode: [i32; 18],
valptr: [i32; 17], valptr: [i32; 17],
@@ -54,9 +54,9 @@ pub struct Header {
pub restart: u32, pub restart: u32,
pub mcus_x: u32, pub mcus_x: u32,
pub mcus_y: 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, pub scan_start: usize,
/// Tabelas de quantização (ordem zigzag). /// Quantization tables (in zigzag order).
pub qt: [[u16; 64]; 4], pub qt: [[u16; 64]; 4],
dc: [Option<Huff>; 4], dc: [Option<Huff>; 4],
ac: [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> { pub fn parse_header(d: &[u8]) -> Option<Header> {
if d.len() < 4 || d[0] != 0xFF || d[1] != 0xD8 { if d.len() < 4 || d[0] != 0xFF || d[1] != 0xD8 {
return None; return None;
@@ -119,7 +119,7 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
.collect(); .collect();
sof = Some((w, h, comps)); 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 => { 0xC4 => {
let mut q = 0; let mut q = 0;
while q + 17 <= seg.len() { while q + 17 <= seg.len() {
@@ -167,7 +167,7 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
let (w, h, comps) = sof?; let (w, h, comps) = sof?;
let ns = *seg.first()? as usize; let ns = *seg.first()? as usize;
if ns != comps.len() || seg.len() < 1 + ns * 2 + 3 { 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![]; let mut cs = vec![];
for i in 0..ns { for i in 0..ns {
@@ -212,20 +212,20 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum Stop { pub enum Stop {
/// Todos os MCUs foram decodificados. /// All MCUs in the frame were decoded.
Done, Done,
/// Marcador inesperado (inclusive EOI) na posição dada. /// Unexpected marker (including early EOI) at the given byte offset.
Marker(u8, usize), Marker(u8, usize),
/// Código inválido ou coeficiente fora do limite. /// Invalid Huffman code or DCT coefficient index out of bounds.
Error(usize), Error(usize),
/// Faltam bytes. /// Truncated bitstream (ran out of input bytes).
Truncated, 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)] #[derive(Clone, Debug)]
pub struct State { pub struct State {
/// Próximo byte a buscar no fluxo. /// Next byte offset to read from the entropy stream.
pub pos: usize, pub pos: usize,
bitbuf: u32, bitbuf: u32,
bitcnt: u32, bitcnt: u32,
@@ -236,7 +236,7 @@ pub struct State {
} }
impl 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 { pub fn luma_pred(&self) -> i32 {
self.pred[0] self.pred[0]
} }
@@ -254,9 +254,9 @@ impl State {
} }
pub struct Decoded { pub struct Decoded {
/// DC de luminância (absoluto) por MCU decodificado. /// Absolute luminance DC coefficient for each decoded MCU.
pub dc: Vec<i32>, 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 snaps: Vec<State>,
pub stop: Stop, pub stop: Stop,
pub state: State, 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). /// Decodes up to `max_mcus` MCUs starting from `state` over `data` (raw byte-stuffed stream).
/// `snap_every` > 0 guarda estados a cada tantos bytes consumidos. /// 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 { 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) 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( pub fn decode_hook(
h: &Header, h: &Header,
data: &[u8], data: &[u8],
@@ -369,9 +369,9 @@ pub fn decode_hook(
let mut stop = Stop::Done; let mut stop = Stop::Done;
let mut decoded = 0u32; let mut decoded = 0u32;
'mcu: while b.st.mcu < total && decoded < max_mcus { '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 { 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.bitbuf = 0;
b.st.bitcnt = 0; b.st.bitcnt = 0;
let p = b.st.pos; 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. /// Detects the first suspicious MCU where a sliding window of `win` MCUs exhibits an abnormally
/// Devolve o índice do MCU onde a janela começa. /// high mean DC jump. Returns the starting MCU index of that window.
pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> { pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
let mx = h.mcus_x as usize; let mx = h.mcus_x as usize;
let d: Vec<f64> = (0..dc.len()) 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 { if d.len() < win * 2 {
return None; 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 hist = vec![0u32; 2048];
let mut seen = 0u32; let mut seen = 0u32;
let mut sum: f64 = d[..win].iter().sum(); 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); 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 { if i < win * 3 || d[i] < 300.0 {
hist[(d[i] as usize).min(2047)] += 1; hist[(d[i] as usize).min(2047)] += 1;
seen += 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] { pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
let mut f = [[0f32; 8]; 8]; let mut f = [[0f32; 8]; 8];
for z in 0..64 { 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; f[n / 8][n % 8] = (coef[z] * qt[z] as i32) as f32;
} }
let t = cos_table(); let t = cos_table();
// linhas // rows
let mut tmp = [[0f32; 8]; 8]; let mut tmp = [[0f32; 8]; 8];
for v in 0..8 { for v in 0..8 {
for x 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 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> { pub fn mcu_luma(h: &Header, blocks: &[[i32; 64]]) -> Vec<u8> {
let c = &h.comps[0]; let c = &h.comps[0];
let (bw, bh) = (c.h as usize, c.v as usize); 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 out
} }
/// Plano de luminância do que já foi decodificado. /// Luma pixel plane of the decoded image prefix.
pub struct Plane { pub struct Plane {
pub w: usize, pub w: usize,
pub h: 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( pub fn decode_plane(
h: &Header, h: &Header,
data: &[u8], data: &[u8],
+3 -3
View File
@@ -5,10 +5,10 @@
clippy::large_enum_variant, clippy::large_enum_variant,
clippy::needless_range_loop 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 //! Core principle: the source is opened **strictly read-only**; nothing in this
//! crate escreve no dispositivo que está sendo recuperado. //! crate ever writes to the device being recovered.
pub mod aff4; pub mod aff4;
pub mod assemble; 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 //! LVM2: parses Physical Volume (PV) labels and ASCII metadata rings to reassemble
//! lógicos (lineares, em faixas, espelhados e RAID 0/1/5/6/10 do LVM). //! Logical Volumes (linear, striped, mirrored, and LVM RAID 0/1/5/6/10).
use crate::raid::{Layout, Level, Member, RaidParams, RaidSource}; use crate::raid::{Layout, Level, Member, RaidParams, RaidSource};
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
@@ -11,7 +11,7 @@ const MDA_MAGIC: &[u8; 16] = b" LVM2 x[5A%r0N*>";
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PvInfo { pub struct PvInfo {
/// UUID de 32 caracteres, sem hífens. /// 32-character LVM UUID normalized without hyphens.
pub uuid: String, pub uuid: String,
pub size: u64, pub size: u64,
pub seqno: u64, pub seqno: u64,
@@ -19,7 +19,7 @@ pub struct PvInfo {
pub text: String, 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> { pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
let head = read_vec(src, 0, 2048).ok()?; let head = read_vec(src, 0, 2048).ok()?;
if head.len() < 2048 { if head.len() < 2048 {
@@ -34,7 +34,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
let pv = lab.get(off..)?; let pv = lab.get(off..)?;
let uuid = String::from_utf8_lossy(pv.get(..32)?).to_string(); let uuid = String::from_utf8_lossy(pv.get(..32)?).to_string();
let size = u64::from_le_bytes(pv.get(32..40)?.try_into().ok()?); 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 mut o = 40;
let rd = |o: usize| -> Option<(u64, u64)> { let rd = |o: usize| -> Option<(u64, u64)> {
Some(( Some((
@@ -78,7 +78,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
text = t; text = t;
} }
} else { } else {
// O texto dá a volta no anel. // Metadata text wraps around the circular ring buffer.
let first = mda_size - loc_off; let first = mda_size - loc_off;
if let (Ok(a), Ok(b)) = ( if let (Ok(a), Ok(b)) = (
read_vec(src, mda_off + loc_off, first as usize), read_vec(src, mda_off + loc_off, first as usize),
@@ -113,7 +113,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
best best
} }
// ------------------------------------------------------------------ texto // ------------------------------------------------------------------ text parser
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Val { pub enum Val {
@@ -125,7 +125,7 @@ pub enum Val {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Section { pub struct Section {
pub values: BTreeMap<String, Val>, pub values: BTreeMap<String, Val>,
/// Subseções na ordem do arquivo. /// Child sections in declaration order.
pub sections: Vec<(String, Section)>, pub sections: Vec<(String, Section)>,
} }
@@ -277,7 +277,7 @@ pub fn parse(text: &str) -> Option<Section> {
.section(true) .section(true)
} }
// ------------------------------------------------------------- montagem // ------------------------------------------------------------- assembly
fn dashed(u: &str) -> String { fn dashed(u: &str) -> String {
if u.len() != 32 { if u.len() != 32 {
@@ -297,7 +297,7 @@ fn dashed(u: &str) -> String {
#[derive(Clone)] #[derive(Clone)]
enum Map { enum Map {
/// Lê de `src` a partir de `base` (linear). /// Linear segment: reads from `src` starting at `base`.
Linear { Linear {
src: Option<Arc<dyn Source>>, src: Option<Arc<dyn Source>>,
base: u64, base: u64,
@@ -413,7 +413,7 @@ fn mk_stripes(list: &[Val]) -> Vec<(String, u64)> {
struct Ctx<'a> { struct Ctx<'a> {
ext: u64, 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, lvs: &'a Section,
cache: BTreeMap<String, Option<Arc<dyn Source>>>, cache: BTreeMap<String, Option<Arc<dyn Source>>>,
warnings: Vec<String>, warnings: Vec<String>,
@@ -487,7 +487,7 @@ impl Ctx<'_> {
} }
}) })
.collect(); .collect();
// Pares (rimage, rmeta) ou lista só de imagens. // Pairs of `(rimage, rmeta)` or a list of image LVs.
let data_lvs: Vec<&String> = let data_lvs: Vec<&String> =
imgs.iter().filter(|n| n.contains("_rimage_")).collect(); imgs.iter().filter(|n| n.contains("_rimage_")).collect();
let mut members: Vec<Option<Member>> = vec![]; 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> { 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(); let mut by_vg: BTreeMap<String, Vec<(Arc<dyn Source>, PvInfo)>> = BTreeMap::new();
for (s, p) in pvs { 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![]; let mut lvs = vec![];
for (name, def) in &lvsec.sections { 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 let hidden = def
.list("status") .list("status")
.map(|l| !l.iter().any(|v| matches!(v, Val::Str(s) if s == "VISIBLE"))) .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), //! Metadata extraction for recovered files: EXIF (camera, timestamps, GPS),
//! informações de PDF, propriedades de documentos Office/OpenDocument e //! PDF info dictionary, Office/OpenDocument properties, and MP4/MOV
//! dados de vídeo MP4/MOV. Tudo é leitura posicional, sem gravar em disco. //! container headers. Performed entirely via positional reads without writing to disk.
use crate::recover::read_found_at; use crate::recover::read_found_at;
use crate::source::Source; use crate::source::Source;
@@ -12,16 +12,16 @@ use std::io::Read;
pub struct Meta { pub struct Meta {
pub kind: String, pub kind: String,
pub fields: Vec<(String, String)>, pub fields: Vec<(String, String)>,
/// (latitude, longitude) em graus decimais. /// `(latitude, longitude)` in decimal degrees.
pub gps: Option<(f64, f64)>, 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)] #[serde(default)]
pub taken: Option<i64>, pub taken: Option<i64>,
#[serde(default)] #[serde(default)]
pub camera: Option<String>, pub camera: Option<String>,
#[serde(default)] #[serde(default)]
pub px: Option<(u32, u32)>, pub px: Option<(u32, u32)>,
/// Orientação EXIF (1..8). /// EXIF orientation tag (`1..=8`).
#[serde(default)] #[serde(default)]
pub orientation: u16, pub orientation: u16,
} }
@@ -142,7 +142,7 @@ fn jpeg(fr: &Fr, m: &mut Meta) {
jpeg_bytes(&d, m); 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) { pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
let mut p = 2usize; let mut p = 2usize;
while p + 4 <= d.len() && d[p] == 0xFF { 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>> { pub fn exif_thumbnail(d: &[u8]) -> Option<Vec<u8>> {
let mut p = 2usize; let mut p = 2usize;
while p + 4 <= d.len() && d[p] == 0xFF { while p + 4 <= d.len() && d[p] == 0xFF {
@@ -222,7 +222,7 @@ struct Entry {
tag: u16, tag: u16,
typ: u16, typ: u16,
count: u32, 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<'_> { 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> { fn exif_epoch(s: &str) -> Option<i64> {
let b = s.as_bytes(); let b = s.as_bytes();
if b.len() < 19 || b[4] != b':' || b[7] != b':' { 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) 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 { fn exif_date(s: &str) -> String {
let b = s.as_bytes(); let b = s.as_bytes();
if b.len() >= 19 && b[4] == b':' && b[7] == b':' { 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(); let k = key.as_bytes();
for at in find_all(d, k) { for at in find_all(d, k) {
let mut p = at + k.len(); 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) { if d.get(p).map(|c| c.is_ascii_alphanumeric()).unwrap_or(false) {
continue; continue;
} }
@@ -544,7 +544,7 @@ fn pdf_string(d: &[u8], key: &str) -> Option<String> {
b'\\' if i + 1 < d.len() => { b'\\' if i + 1 < d.len() => {
i += 1; i += 1;
if d[i].is_ascii_digit() && d[i] < b'8' { 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 v = 0u32;
let mut n = 0; let mut n = 0;
while n < 3 && i < d.len() && d[i] >= b'0' && d[i] < b'8' { 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(); .collect();
return String::from_utf16_lossy(&u); 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() 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 { fn pdf_date(s: &str) -> String {
let t = s.trim_start_matches("D:"); let t = s.trim_start_matches("D:");
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect(); 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> { fn xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}"); let open = format!("<{tag}");
let mut from = 0; let mut from = 0;
@@ -733,7 +733,7 @@ fn unescape(s: &str) -> String {
.replace("&amp;", "&") .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 { fn iso_date(s: &str) -> String {
let b = s.as_bytes(); let b = s.as_bytes();
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' { 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 { pub fn iso_unix(t: i64) -> String {
let days = t.div_euclid(86400); let days = t.div_euclid(86400);
let rem = t.rem_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) { pub fn ymd(t: i64) -> (i64, u32, u32) {
let (y, m, d) = civil_from_days(t.div_euclid(86400)); let (y, m, d) = civil_from_days(t.div_euclid(86400));
(y, m as u32, d as u32) (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) { fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719468; let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097; 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, //! Reconstructs MP4/MOV video files missing their `moov` atom (interrupted camera/drone recordings
//! arquivo cortado). Os quadros ficam no `mdat` como unidades NAL com prefixo de tamanho; o //! or truncated files). Video frames sit in `mdat` as length-prefixed NAL units; we rebuild the
//! índice (`moov`) é refeito a partir delas: tamanhos de amostra, quadros-chave, tempo e, para //! `moov` index from them: sample sizes, keyframes, timing, and for H.264, presentation order
//! H.264, a ordem de apresentação (POC) quando há quadros B. O áudio não é reconstruído. //! (POC) when B-frames are present. Audio tracks are not reconstructed.
use crate::source::{read_vec, Source}; 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> { fn unescape(nal: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(nal.len()); let mut out = Vec::with_capacity(nal.len());
let mut z = 0; let mut z = 0;
@@ -86,7 +86,7 @@ pub struct Sps264 {
pub poc_type: u32, pub poc_type: u32,
pub log2_max_poc_lsb: u32, pub log2_max_poc_lsb: u32,
pub frame_mbs_only: bool, 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)>, 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.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)?; 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 {
if b.bit()? == 1 && b.u(8)? == 255 { if b.bit()? == 1 && b.u(8)? == 255 {
b.skip(32); b.skip(32);
@@ -202,7 +202,7 @@ pub fn parse_sps264(nal: &[u8]) -> Option<Sps264> {
Some(s) 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 { struct SliceInfo {
frame_num: u32, frame_num: u32,
first_mb0: bool, 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 { fn pps_pic_order_present(nal: &[u8]) -> bool {
let rbsp = unescape(&nal[1..]); let rbsp = unescape(&nal[1..]);
let mut b = Bits::new(&rbsp); 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; *v = b.u(8)? as u8;
let _ = i; 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_profile = vec![];
let mut sub_level = vec![]; let mut sub_level = vec![];
for _ in 0..max_sub { for _ in 0..max_sub {
@@ -320,9 +320,9 @@ pub fn parse_sps_hevc(nal: &[u8]) -> Option<SpsHevc> {
Some(s) 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)] #[derive(Clone, Debug)]
pub struct MediaConfig { pub struct MediaConfig {
pub codec: Codec, pub codec: Codec,
@@ -331,9 +331,9 @@ pub struct MediaConfig {
pub vps: Vec<Vec<u8>>, 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> { 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 size = src.size();
let head = read_vec(src, off, max.min(4 << 20) as usize).ok()?; let head = read_vec(src, off, max.min(4 << 20) as usize).ok()?;
let mut hay: Vec<Vec<u8>> = vec![head]; let mut hay: Vec<Vec<u8>> = vec![head];
@@ -416,7 +416,7 @@ pub enum Codec {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Sample { 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, off: u64,
size: u32, size: u32,
sync: bool, sync: bool,
@@ -425,9 +425,9 @@ struct Sample {
} }
pub struct Rebuilt { 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, 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 payload: (u64, u64),
pub moov: Vec<u8>, pub moov: Vec<u8>,
pub frames: usize, pub frames: usize,
@@ -439,7 +439,7 @@ pub struct Rebuilt {
} }
impl 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> { pub fn mdat_header(&self) -> Vec<u8> {
let size = 16 + (self.payload.1 - self.payload.0); let size = 16 + (self.payload.1 - self.payload.0);
let mut v = Vec::with_capacity(16); 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 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)> { fn mdat_at(src: &dyn Source, at: u64) -> Option<(u64, u64)> {
let h = read_vec(src, at, 16).ok()?; let h = read_vec(src, at, 16).ok()?;
if h.len() < 8 || &h[4..8] != b"mdat" { 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> { pub fn rebuild(src: &dyn Source, off: u64, avail: u64, refs: &[MediaConfig]) -> Option<Rebuilt> {
if let Some(r) = rebuild_with(src, off, avail, None) { if let Some(r) = rebuild_with(src, off, avail, None) {
return Some(r); return Some(r);
@@ -486,7 +486,7 @@ fn rebuild_with(
avail: u64, avail: u64,
preset: Option<&MediaConfig>, preset: Option<&MediaConfig>,
) -> Option<Rebuilt> { ) -> Option<Rebuilt> {
// Átomos antes do mdat. // Parse leading atoms before `mdat`.
let mut p = 0u64; let mut p = 0u64;
let mut pre_end = None; let mut pre_end = None;
for _ in 0..16 { for _ in 0..16 {
@@ -520,7 +520,7 @@ fn rebuild_with(
(off + pre_len + declared).min(hard_end) (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 samples: Vec<Sample> = vec![];
let mut cache: Vec<u8> = vec![]; let mut cache: Vec<u8> = vec![];
let mut cache_off = data_start; let mut cache_off = data_start;
@@ -536,7 +536,7 @@ fn rebuild_with(
let s = (o - *cache_off) as usize; let s = (o - *cache_off) as usize;
cache.get(s..s + n).map(|x| x.to_vec()) 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 chain = |c: Codec| -> usize {
let Ok(head) = read_vec(src, data_start, 64 << 10) else { let Ok(head) = read_vec(src, data_start, 64 << 10) else {
return 0; return 0;
@@ -599,10 +599,10 @@ fn rebuild_with(
let mut idr_total = 0usize; let mut idr_total = 0usize;
let mut idr_bad = 0usize; let mut idr_bad = 0usize;
let mut cur: Option<Sample> = None; 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 nal_count = 0usize;
let mut max_len = 0u64; 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_msb: i64 = 0;
let mut prev_lsb: i64 = 0; let mut prev_lsb: i64 = 0;
@@ -617,7 +617,7 @@ fn rebuild_with(
}; };
let mut ok = ok; let mut ok = ok;
if ok && nal_count >= 3 && len > (max_len * 6).max(1 << 20) { 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; ok = false;
} }
if ok { if ok {
@@ -629,7 +629,7 @@ fn rebuild_with(
max_len = max_len.max(len); max_len = max_len.max(len);
} }
if !valid { 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( let found = scan_nal(
src, src,
codec, codec,
@@ -642,7 +642,7 @@ fn rebuild_with(
); );
match found { match found {
Some(q) if nal_count > 0 => { 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() { if let Some(mut s) = cur.take() {
s.size = (group_start.unwrap_or(pos) - s.off) as u32; s.size = (group_start.unwrap_or(pos) - s.off) as u32;
samples.push(s); samples.push(s);
@@ -734,9 +734,9 @@ fn rebuild_with(
let _ = ty; let _ = ty;
if is_vcl { if is_vcl {
if first_of_pic { if first_of_pic {
// fecha o quadro anterior // flush previous frame
if let Some(mut s) = cur.take() { 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); let end = group_start.unwrap_or(pos);
s.size = (end - s.off) as u32; s.size = (end - s.off) as u32;
samples.push(s); samples.push(s);
@@ -816,7 +816,7 @@ fn rebuild_with(
return None; return None;
} }
let (num, den) = match timing { 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), Some((units, scale)) => (scale, units),
None => (30, 1), None => (30, 1),
}; };
@@ -827,10 +827,10 @@ fn rebuild_with(
(num, den) (num, den)
}; };
let fps = num as f64 / den as f64; 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); 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; let mut ctts: Option<Vec<i64>> = None;
if samples.iter().all(|s| s.poc.is_some()) { if samples.iter().all(|s| s.poc.is_some()) {
let mut offs = vec![0i64; samples.len()]; let mut offs = vec![0i64; samples.len()];
@@ -936,7 +936,7 @@ fn build_moov(
let movie_ts = 1000u32; let movie_ts = 1000u32;
let movie_dur = media_dur * movie_ts as u64 / timescale as u64; let movie_dur = media_dur * movie_ts as u64 / timescale as u64;
// Entrada de amostra // Visual sample entry (`avc1` / `hvc1`)
let mut entry = vec![]; let mut entry = vec![];
entry.extend_from_slice(&[0; 6]); entry.extend_from_slice(&[0; 6]);
entry.extend_from_slice(&1u16.to_be_bytes()); entry.extend_from_slice(&1u16.to_be_bytes());
@@ -975,18 +975,18 @@ fn build_moov(
Codec::Hevc => { Codec::Hevc => {
let h = hevc.unwrap(); let h = hevc.unwrap();
let mut c = vec![1]; 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); c.truncate(13);
while c.len() < 13 { while c.len() < 13 {
c.push(0); c.push(0);
} }
c.extend_from_slice(&0xF000u16.to_be_bytes()); // segmentação espacial c.extend_from_slice(&0xF000u16.to_be_bytes()); // min_spatial_segmentation_idc
c.push(0xFC); // paralelismo c.push(0xFC); // parallelismType
c.push(0xFC | h.chroma as u8); c.push(0xFC | h.chroma as u8);
c.push(0xF8 | (h.bit_depth_luma - 8) as u8); c.push(0xF8 | (h.bit_depth_luma - 8) as u8);
c.push(0xF8 | (h.bit_depth_chroma - 8) as u8); c.push(0xF8 | (h.bit_depth_chroma - 8) as u8);
c.extend_from_slice(&0u16.to_be_bytes()); // avgFrameRate 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 arrays: [(u8, &[Vec<u8>]); 3] = [(32, vps), (33, sps), (34, pps)];
let present: Vec<_> = arrays.iter().filter(|(_, v)| !v.is_empty()).collect(); let present: Vec<_> = arrays.iter().filter(|(_, v)| !v.is_empty()).collect();
c.push(present.len() as u8); c.push(present.len() as u8);
@@ -1047,7 +1047,7 @@ fn build_moov(
.collect(); .collect();
let mut stbl_parts = vec![stsd, stts]; let mut stbl_parts = vec![stsd, stts];
if let Some(c) = ctts { if let Some(c) = ctts {
// entradas agrupadas // run-length encoded entries
let mut runs: Vec<(u32, u32)> = vec![]; let mut runs: Vec<(u32, u32)> = vec![];
for &v in c { for &v in c {
match runs.last_mut() { match runs.last_mut() {
@@ -1080,7 +1080,7 @@ fn build_moov(
let vmhd = full(b"vmhd", 0, 1, &[0; 8]); let vmhd = full(b"vmhd", 0, 1, &[0; 8]);
let minf = bx(b"minf", &[vmhd, dinf, stbl].concat()); let minf = bx(b"minf", &[vmhd, dinf, stbl].concat());
let mut mdhd_body = vec![]; 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(timescale));
mdhd_body.extend_from_slice(&u32b(media_dur as u32)); mdhd_body.extend_from_slice(&u32b(media_dur as u32));
mdhd_body.extend_from_slice(&0x55C4u16.to_be_bytes()); // "und" 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(&[0; 4]);
tkhd_body.extend_from_slice(&u32b(movie_dur as u32)); tkhd_body.extend_from_slice(&u32b(movie_dur as u32));
tkhd_body.extend_from_slice(&[0; 8]); tkhd_body.extend_from_slice(&[0; 8]);
tkhd_body.extend_from_slice(&[0; 4]); // camada, grupo tkhd_body.extend_from_slice(&[0; 4]); // layer, alternate_group
tkhd_body.extend_from_slice(&[0; 4]); // volume, reservado tkhd_body.extend_from_slice(&[0; 4]); // volume, reserved
for m in matrix { for m in matrix {
tkhd_body.extend_from_slice(&u32b(m)); tkhd_body.extend_from_slice(&u32b(m));
} }
@@ -1136,7 +1136,7 @@ fn build_moov(
bx(b"moov", &[mvhd, trak].concat()) 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( fn nal_plausible(
codec: Codec, codec: Codec,
head: &[u8], head: &[u8],
@@ -1167,7 +1167,7 @@ fn nal_plausible(
return false; return false;
} }
if let Some(sp) = sps { if let Some(sp) = sps {
// frame_num de IDR é sempre 0 // IDR slice `frame_num` is always 0
if t == 5 { if t == 5 {
if let Some(fnum) = b.u(sp.log2_max_frame_num) { if let Some(fnum) = b.u(sp.log2_max_frame_num) {
return fnum == 0; 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( fn chain_len(
buf: &[u8], buf: &[u8],
base: u64, base: u64,
@@ -1232,8 +1232,8 @@ fn chain_len(
n n
} }
/// Próxima posição em [from, limit) onde começa uma sequência de unidades NAL plausíveis. /// Finds the next offset in `[from, limit)` where a plausible NAL unit sequence begins.
/// Prefere o primeiro ponto com duas ou mais unidades seguidas; senão, uma unidade isolada. /// Prefers an offset with two or more back-to-back valid NALs; falls back to a single valid NAL.
fn scan_nal( fn scan_nal(
src: &dyn Source, src: &dyn Source,
codec: Codec, codec: Codec,
@@ -1247,7 +1247,7 @@ fn scan_nal(
if limit <= from + 8 { if limit <= from + 8 {
return None; 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 = let want =
((limit - from) as usize + (cap as usize).min(4 << 20)).min((data_end - from) as usize); ((limit - from) as usize + (cap as usize).min(4 << 20)).min((data_end - from) as usize);
let buf = read_vec(src, from, want).ok()?; 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 { if l2 >= 2 && l2 <= cap && q + 4 + l2 <= data_end {
let nb = buf[i + 4]; let nb = buf[i + 4];
let good_head = match codec { 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 => { Codec::Avc => {
avc_nal_ok(nb) avc_nal_ok(nb)
&& (matches!(nb & 0x1F, 6..=9) && (matches!(nb & 0x1F, 6..=9)
+7 -7
View File
@@ -1,11 +1,11 @@
//! Notificações do sistema (varredura concluída, disco desconectado...), usando os //! Native OS desktop notifications (scan finished, disk disconnected, etc.) using
//! comandos nativos de cada sistema, sem dependências extras. //! standard platform CLI tools with zero extra crate dependencies.
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
/// Avisos que o núcleo envia ao sistema operacional. O texto é montado no /// Notification events emitted by the core. Messages are formatted in the
/// idioma efetivo da interface (`settings::ui_lang`), porque notificações não /// effective UI language (`settings::ui_lang`) since OS notifications bypass
/// passam pelo dicionário do DOM. /// the DOM i18n translator.
pub enum Notice { pub enum Notice {
ScanDone { count: usize, target: String }, ScanDone { count: usize, target: String },
RecoverDone { count: usize, destination: 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) { pub fn send(notice: Notice) {
let (title, body) = notice.text(&crate::settings::ui_lang()); let (title, body) = notice.text(&crate::settings::ui_lang());
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -72,7 +72,7 @@ pub fn send(notice: Notice) {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn command(title: &str, body: &str) -> Option<Command> { 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 = if let Some(u) = crate::owner::invoking_user() {
let mut c = Command::new("runuser"); let mut c = Command::new("runuser");
c.args(["-u", &u.name, "--", "env"]); 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 //! When Guará runs elevated as root (`sudo`/`pkexec`), all user-facing outputs
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que //! (sessions, reports, recovered files) are chowned back to the invoking user
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário. //! rather than root, and default paths resolve inside that user's home directory.
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -13,10 +13,10 @@ pub struct User {
pub home: PathBuf, 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)] #[cfg(unix)]
pub fn invoking_user() -> Option<User> { pub fn invoking_user() -> Option<User> {
// SAFETY: geteuid não tem pré-condições. // SAFETY: `geteuid` has no preconditions.
if unsafe { libc::geteuid() } != 0 { if unsafe { libc::geteuid() } != 0 {
return None; return None;
} }
@@ -30,7 +30,7 @@ pub fn invoking_user() -> Option<User> {
lookup_user(&passwd, uid, name.as_deref()) 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> { pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
for l in passwd.lines() { for l in passwd.lines() {
let f: Vec<&str> = l.split(':').collect(); let f: Vec<&str> = l.split(':').collect();
@@ -57,7 +57,7 @@ pub fn invoking_user() -> Option<User> {
None 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 { pub fn user_home() -> PathBuf {
if let Some(u) = invoking_user() { if let Some(u) = invoking_user() {
return u.home; 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) { pub fn give_to_user(path: &Path) {
#[cfg(unix)] #[cfg(unix)]
if let Some(u) = invoking_user() { if let Some(u) = invoking_user() {
@@ -79,7 +79,7 @@ pub fn give_to_user(path: &Path) {
let _ = 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<()> { pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
let mut missing = vec![]; let mut missing = vec![];
let mut cur = Some(path); let mut cur = Some(path);
@@ -97,7 +97,7 @@ pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
Ok(()) 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<()> { pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
create_dir_all_owned(parent)?; create_dir_all_owned(parent)?;
@@ -126,7 +126,7 @@ mod tests {
(1001, 1002, "/home/maria") (1001, 1002, "/home/maria")
); );
assert!(lookup_user(PASSWD, Some(4242), None).is_none()); assert!(lookup_user(PASSWD, Some(4242), None).is_none());
// o uid manda sobre o nome // UID takes precedence over username
assert_eq!( assert_eq!(
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid, lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
1001 1001
+23 -23
View File
@@ -1,5 +1,5 @@
//! Tabelas de partição (MBR/GPT), detecção de sistemas de arquivos, //! Partition tables (MBR/GPT), filesystem and encrypted volume detection,
//! volumes cifrados e busca de partições perdidas. //! and lost-partition scanning across unallocated gaps.
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
use crate::types::{DiskLayout, FsKind, Gap, PartInfo}; use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
@@ -7,7 +7,7 @@ use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
const SECTOR: u64 = 512; const SECTOR: u64 = 512;
const MIN_GAP: u64 = 1024 * 1024; 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>)> { pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)> {
let b = read_vec(src, start, 4096).ok()?; let b = read_vec(src, start, 4096).ok()?;
if b.len() < 1100 { 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) { if let Some(r) = detect_fs_in(&b) {
return Some(r); 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 let Ok(h) = read_vec(src, start + 0x10000, 0x100) {
if h.len() >= 0x80 && &h[0x40..0x48] == b"_BHRfS_M" { if h.len() >= 0x80 && &h[0x40..0x48] == b"_BHRfS_M" {
return Some(( return Some((
@@ -33,7 +33,7 @@ pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)>
None 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>)> { pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
if b.len() < 1100 { if b.len() < 1100 {
return None; 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 root_secs = (root_entries * 32).div_ceil(bps);
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs); let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
let clusters = data / spc; let clusters = data / spc;
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos // BPB structure takes precedence: zero root-entry count + 32-bit FAT size = FAT32 even on
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta). // 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 is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
let kind = if is32 { let kind = if is32 {
FsKind::Fat32 FsKind::Fat32
@@ -146,7 +146,7 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
Some((kind, Some(total * bps))) 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> { fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
let off = if kind == FsKind::Fat32 { 71 } else { 43 }; let off = if kind == FsKind::Fat32 { 71 } else { 43 };
let s = String::from_utf8_lossy(b.get(off..off + 11)?) 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) (!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 { pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
let size = src.size(); let size = src.size();
let mut layout = DiskLayout { let mut layout = DiskLayout {
@@ -174,7 +174,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
layout.table = "gpt".into(); layout.table = "gpt".into();
parts = g; parts = g;
} else if has_mbr { } 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(); let looks_boot = detect_fs_in(&read_vec(src, 0, 4096).unwrap_or_default()).is_some();
if !looks_boot { if !looks_boot {
let mp = read_mbr(src, &sector0, size); 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 parts.is_empty() {
if let Some((fs, len)) = detect_fs(src, 0) { if let Some((fs, len)) = detect_fs(src, 0) {
parts.push(PartInfo { parts.push(PartInfo {
@@ -217,12 +217,12 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
parts.sort_by_key(|p| p.start); parts.sort_by_key(|p| p.start);
layout.gaps = compute_gaps(&parts, size); 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(); let mut lost = Vec::new();
if search_lost { if search_lost {
lost = find_lost(src, &layout.gaps, &parts); lost = find_lost(src, &layout.gaps, &parts);
if parts.is_empty() && layout.table == "none" { 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( lost = find_lost(
src, src,
&[Gap { &[Gap {
@@ -304,7 +304,7 @@ fn read_mbr(src: &dyn Source, s0: &[u8], size: u64) -> Vec<PartInfo> {
continue; continue;
} }
if ty == 0xEE { if ty == 0xEE {
continue; // MBR protetor de GPT continue; // GPT protective MBR
} }
if matches!(ty, 0x05 | 0x0F | 0x85) { if matches!(ty, 0x05 | 0x0F | 0x85) {
ext_base = Some(lba * SECTOR); 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>> { 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) let hdr = read_vec(src, SECTOR, 512)
.ok() .ok()
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART") .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()?; let mut tbl = read_vec(src, table_lba * SECTOR, n * esz).ok()?;
if tbl.len() < n * esz { 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); tbl.resize(n * esz, 0);
} }
let mut out = vec![]; 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 { 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 { match guid {
[0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11, 0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B] => { [0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11, 0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B] => {
"EFI System" "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> { fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo> {
const CHUNK: u64 = 4 * 1024 * 1024; const CHUNK: u64 = 4 * 1024 * 1024;
let mut found: Vec<PartInfo> = vec![]; 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 { if abs >= skip_until {
let s = &buf[off as usize..]; let s = &buf[off as usize..];
if s.len() >= 1100 { 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 " let quick = &s[3..11] == b"NTFS "
|| &s[3..11] == b"EXFAT " || &s[3..11] == b"EXFAT "
|| &s[3..11] == b"-FVE-FS-" || &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); || (s[1080] == 0x53 && s[1081] == 0xEF);
if quick { if quick {
if let Some((fs, len)) = detect_fs_in(s) { if let Some((fs, len)) = detect_fs_in(s) {
// ext: o superbloco está a 1024 bytes do início do volume, // ext2/3/4: the superblock sits 1024 bytes into the volume,
// por isso o candidato ext é o setor atual (início do volume). // so `off` (1024 bytes before the magic) is the candidate volume start.
let len = len.unwrap_or(0); let len = len.unwrap_or(0);
let ok = len >= 64 * 1024 let ok = len >= 64 * 1024
&& abs + len <= src.size() + 1024 * 1024 && abs + len <= src.size() + 1024 * 1024
&& !overlaps(known, abs, len); && !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) { if ok && (fs != FsKind::Fat12 || len >= 512 * 1024) {
let mut p = let mut p =
part(0, abs, len, Some("encontrada por varredura".into())); 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 //! Uses only built-in PDF Type 1 fonts (Helvetica, Helvetica-Bold, and Courier) with WinAnsi
//! WinAnsi, então o arquivo é autocontido e não depende de fontes instaladas. Caracteres fora //! encoding so output files are self-contained with no embedded font dependencies. Characters
//! do Latin-1/WinAnsi viram `?`. //! outside Latin-1/WinAnsi are replaced with `?`.
use flate2::write::ZlibEncoder; use flate2::write::ZlibEncoder;
use flate2::Compression; 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] = [ const HELV: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 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, 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, 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 { fn base_letter(c: char) -> char {
match c { match c {
'À'..='Å' => 'A', 'À'..='Å' => '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 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 { fn win_ansi(c: char) -> u8 {
let n = c as u32; let n = c as u32;
match c { match c {
@@ -120,8 +120,8 @@ fn escape(s: &str) -> String {
o o
} }
/// Quebra `s` em linhas de no máximo `width` pontos. Sem espaço onde quebrar (caminhos, /// Greedy word-wraps `s` into lines of at most `width` points. Long tokens without spaces
/// hashes), corta no meio da palavra. /// (paths, hashes) are broken mid-token.
pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> { pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> {
let mut lines = vec![]; let mut lines = vec![];
for para in s.split('\n') { for para in s.split('\n') {
@@ -270,7 +270,7 @@ impl Pdf {
self.y -= 3.0; 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) { pub fn note(&mut self, s: &str) {
let size = 9.0; let size = 9.0;
let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0); let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0);
@@ -292,7 +292,7 @@ impl Pdf {
self.y -= h + 6.0; 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)]) { pub fn kv(&mut self, rows: &[(&str, &str, Font)]) {
let lw = 140.0; let lw = 140.0;
let size = 9.5; let size = 9.5;
@@ -318,7 +318,7 @@ impl Pdf {
self.y -= 4.0; 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>]) { pub fn table(&mut self, cols: &[Col], rows: &[Vec<String>]) {
let size = 8.0; let size = 8.0;
let pad = 3.0; let pad = 3.0;
@@ -391,7 +391,7 @@ impl Pdf {
self.y -= 6.0; 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> { pub fn finish(mut self, date: &str) -> Vec<u8> {
if !self.cur.is_empty() || self.pages.is_empty() { if !self.cur.is_empty() || self.pages.is_empty() {
self.pages.push(std::mem::take(&mut self.cur)); self.pages.push(std::mem::take(&mut self.cur));
@@ -416,7 +416,7 @@ impl Pdf {
out.extend_from_slice(body); out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n"); 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 >>"); obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids: String = (0..total) let kids: String = (0..total)
.map(|i| format!("{} 0 R", 7 + 2 * i)) .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 //! Parses Linux `mdadm` superblocks (v0.90 and v1.x) for automatic assembly; for metadata-less
//! módulo `raid_detect` descobre ordem, tamanho de faixa e layout. Se faltarem discos, o //! arrays, `raid_detect` infers disk order, chunk size, and parity layout. Missing disks are
//! array é lido em modo degradado (paridade reconstrói o que faltar). //! reconstructed on the fly in degraded mode via XOR / GF(2^8) parity.
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
use std::io; 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Layout { pub enum Layout {
@@ -42,7 +42,7 @@ pub enum Layout {
RightAsymmetric, RightAsymmetric,
LeftSymmetric, LeftSymmetric,
RightSymmetric, RightSymmetric,
/// RAID 4 e variantes "parity-0"/"parity-n". /// RAID 4 and fixed-parity (`parity-first` / `parity-last`) variants.
ParityFirst, ParityFirst,
ParityLast, ParityLast,
} }
@@ -72,23 +72,23 @@ impl Layout {
#[derive(Clone)] #[derive(Clone)]
pub struct Member { pub struct Member {
pub src: Arc<dyn Source>, 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, pub data_offset: u64,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct RaidParams { pub struct RaidParams {
pub level: Level, pub level: Level,
/// Tamanho da faixa por disco, em bytes. /// Stripe chunk size per disk in bytes.
pub chunk: u64, pub chunk: u64,
pub layout: Layout, 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>>, 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 near: u32,
pub far: u32, pub far: u32,
pub far_offset: bool, 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, pub per_member: u64,
} }
@@ -113,7 +113,7 @@ pub struct RaidSource {
per_member: u64, 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 { fn gf_mul(mut a: u8, mut b: u8) -> u8 {
let mut r = 0u8; let mut r = 0u8;
@@ -231,7 +231,7 @@ impl RaidSource {
Ok(()) 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) { fn raid5_map(&self, row: u64, d: usize) -> (usize, usize) {
let n = self.p.members.len(); let n = self.p.members.len();
let r = (row % n as u64) as usize; let r = (row % n as u64) as usize;
@@ -273,7 +273,7 @@ impl RaidSource {
let disk = if self.p.layout == Layout::LeftSymmetric { let disk = if self.p.layout == Layout::LeftSymmetric {
(pd + 2 + d) % n (pd + 2 + d) % n
} else { } 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; let mut k = d;
for skip in sorted2(pd, qd) { for skip in sorted2(pd, qd) {
if k >= skip { 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<()> { fn read_in_chunk(&self, chunk_idx: u64, within: u64, buf: &mut [u8]) -> io::Result<()> {
let n = self.p.members.len(); let n = self.p.members.len();
let c = self.p.chunk; let c = self.p.chunk;
@@ -333,7 +333,7 @@ impl RaidSource {
if self.p.members[disk].is_some() { if self.p.members[disk].is_some() {
return self.read_member(disk, off, buf); 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); buf.fill(0);
let mut tmp = vec![0u8; buf.len()]; let mut tmp = vec![0u8; buf.len()];
for i in 0..n { 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)> { fn r10_locations(&self, chunk_idx: u64) -> Vec<(usize, u64)> {
let n = self.p.members.len() as u64; let n = self.p.members.len() as u64;
let c = self.p.chunk; let c = self.p.chunk;
@@ -416,7 +416,7 @@ impl RaidSource {
let dd = n - 2; let dd = n - 2;
let (pd, qd, _) = self.raid6_map(row, 0); let (pd, qd, _) = self.raid6_map(row, 0);
let len = buf.len(); 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 disks: Vec<usize> = (0..dd).map(|k| self.raid6_map(row, k).2).collect();
let missing_data: Vec<usize> = (0..dd) let missing_data: Vec<usize> = (0..dd)
.filter(|&k| self.p.members[disks[k]].is_none()) .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)] #[derive(Clone, Debug)]
pub struct MdSuper { pub struct MdSuper {
@@ -560,10 +560,10 @@ pub struct MdSuper {
pub layout: u32, pub layout: u32,
pub chunk: u64, pub chunk: u64,
pub raid_disks: u32, 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 role: Option<u32>,
pub data_offset: u64, pub data_offset: u64,
/// Bytes de dados por membro. /// Usable data size per member in bytes.
pub data_size: u64, pub data_size: u64,
pub events: u64, pub events: u64,
pub array_size: 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> { pub fn read_md_super(src: &dyn Source) -> Option<MdSuper> {
let size = src.size(); 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")]; let mut cands: Vec<(u64, &str)> = vec![(0, "1.1"), (4096, "1.2")];
if size > 16384 { if size > 16384 {
cands.push(((size & !4095) - 8192, "1.0")); cands.push(((size & !4095) - 8192, "1.0"));
@@ -644,7 +644,7 @@ pub fn read_md_super(src: &dyn Source) -> Option<MdSuper> {
array_size, 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 { if size > 131072 {
let off = (size & !65535) - 65536; let off = (size & !65535) - 65536;
if let Ok(b) = read_vec(src, off, 4096) { 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() 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 struct FoundArray {
pub uuid: String, pub uuid: String,
pub name: String, pub name: String,
@@ -697,7 +697,7 @@ pub struct FoundArray {
pub missing: usize, 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>> { 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![]; let mut groups: Vec<(String, Vec<(Arc<dyn Source>, MdSuper)>)> = vec![];
for (s, m) in members { 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![]; let mut out = vec![];
for (uuid, mut list) in groups { 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)); list.sort_by_key(|(_, m)| std::cmp::Reverse(m.events));
let top = list[0].1.clone(); let top = list[0].1.clone();
let n = top.raid_disks as usize; 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 //! Automatically detects level, disk order, chunk size, and parity layout of a metadata-less
//! metadados, testando as combinações e medindo a coerência do sistema de arquivos que sai. //! RAID array by scoring parity consistency and filesystem structural coherence across combinations.
use crate::partition; use crate::partition;
use crate::raid::{Layout, Level, Member, RaidParams, RaidSource}; use crate::raid::{Layout, Level, Member, RaidParams, RaidSource};
@@ -12,7 +12,7 @@ pub struct Candidate {
pub level: Level, pub level: Level,
pub chunk: u64, pub chunk: u64,
pub layout: Layout, 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 order: Vec<usize>,
pub score: f64, pub score: f64,
} }
@@ -96,7 +96,7 @@ fn ntfs_score(src: &dyn Source, start: u64) -> f64 {
if r.len() < 64 { if r.len() < 64 {
continue; 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) { if r.iter().all(|&x| x == 0) {
continue; 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 { fn volume_score(src: &dyn Source, start: u64, fs: FsKind) -> f64 {
match fs { match fs {
FsKind::Ntfs => ntfs_score(src, start), 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()) 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( pub fn detect(
members: &[Arc<dyn Source>], members: &[Arc<dyn Source>],
cancelled: &(dyn Fn() -> bool + Sync), cancelled: &(dyn Fn() -> bool + Sync),
@@ -149,7 +149,7 @@ pub fn detect(
if n < 2 { if n < 2 {
return vec![]; 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 let cached: Vec<Arc<dyn Source>> = members
.iter() .iter()
.map(|m| { .map(|m| {
@@ -163,7 +163,7 @@ pub fn detect(
} }
let mut out: Vec<Candidate> = vec![]; 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 probe = |s: &Arc<dyn Source>| read_vec(s.as_ref(), 0, 1 << 20).unwrap_or_default();
let first = probe(&cached[0]); let first = probe(&cached[0]);
if first.iter().any(|&x| x != 0) && cached.iter().skip(1).all(|c| probe(c) == first) { 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); 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 sample = 4usize << 20;
let bufs: Vec<Vec<u8>> = cached let bufs: Vec<Vec<u8>> = cached
.iter() .iter()
@@ -246,7 +246,7 @@ pub fn detect(
let Ok(r) = RaidSource::new(params) else { let Ok(r) = RaidSource::new(params) else {
continue; 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 { if any_first {
let mut b = [0u8; 512]; let mut b = [0u8; 512];
let _ = r.read_at(0, &mut b); 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 //! Re-identifies and reconnects a session's source disk when unplugged and reattached
//! outro nome (por exemplo `/dev/sda` virando `/dev/sdb`). //! under a different OS device node (e.g. `/dev/sda` becoming `/dev/sdb`).
use crate::device; use crate::device;
use crate::source::{fingerprint, open_path, Source}; use crate::source::{fingerprint, open_path, Source};
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
/// A origem é compatível com o que a sessão espera? /// Checks whether `src` matches the size and fingerprint expected by the session.
/// `size == 0` ou `fp == None` significam "não sei" e não reprovam. /// `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 { 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)) (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. /// Searches attached storage devices for the disk matching the session's fingerprint.
/// Com impressão digital, exige igualdade; sem ela, só aceita se houver um único disco do mesmo tamanho. /// Requires an exact fingerprint match when present; otherwise accepts a unique size match.
pub fn find_matching_source( pub fn find_matching_source(
size: u64, size: u64,
fp: Option<&str>, fp: Option<&str>,
+23 -23
View File
@@ -1,5 +1,5 @@
//! Gravação dos arquivos recuperados (sempre em outro destino) com SHA-256 //! Writes recovered files to a separate destination volume along with SHA-256
//! e manifesto da sessão. //! digests, recovery journal, and session manifest.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::fs::sanitize_name; use crate::fs::sanitize_name;
@@ -33,12 +33,12 @@ pub struct RecoverReport {
pub failed: Vec<(u64, String)>, pub failed: Vec<(u64, String)>,
pub total_bytes: u64, pub total_bytes: u64,
pub cancelled: bool, 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)] #[serde(default)]
pub skipped: usize, 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<()> { pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
crate::owner::create_dir_all_owned(dest)?; crate::owner::create_dir_all_owned(dest)?;
let dest = dest.canonicalize()?; 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 let Ok(meta) = std::fs::metadata(sp) {
if meta.is_file() { 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(()) 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RecoverLayout { 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] #[default]
Folders, Folders,
/// Uma pasta por tipo (Fotos, Documentos, Vídeos...). /// Group into one folder per category (Photos, Documents, Videos, etc.).
Type, 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, Date,
/// Tudo direto na pasta de destino. /// Place all files flat inside the destination folder.
Flat, Flat,
} }
@@ -130,7 +130,7 @@ pub fn recover_with(
recover_resumable(src, files, dest, layout, "", false, cancelled, progress) 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct JournalEntry { struct JournalEntry {
source: String, source: String,
@@ -156,9 +156,9 @@ fn sha256_of_file(p: &Path) -> Option<String> {
Some(h.finalize().sha256) Some(h.finalize().sha256)
} }
/// Como `recover_with`, mas mantém um diário e, com `resume`, pula o que já foi /// Like `recover_with`, but maintains an append-only journal and, when `resume` is set, skips
/// recuperado (e conferido por SHA-256) numa execução interrompida. /// files already written and SHA-256 verified during an interrupted run.
/// `source_key` identifica a origem para que o diário de outro disco não seja reaproveitado. /// `source_key` binds the journal to the source disk so another disk's journal is never reused.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn recover_resumable( pub fn recover_resumable(
src: &dyn Source, src: &dyn Source,
@@ -202,7 +202,7 @@ pub fn recover_resumable(
break; break;
} }
progress(i, files.len(), report.total_bytes); 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) { if let Some(e) = prior.get(&f.id) {
let p = dest.join(&e.file.path); let p = dest.join(&e.file.path);
if e.name == f.name && e.file.size == f.size && p.is_file() { 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()); report.recovered.push(e.file.clone());
continue; 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); let _ = std::fs::remove_file(&p);
} }
} }
@@ -321,7 +321,7 @@ fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
p 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> { pub fn free_space(path: &Path) -> Option<u64> {
let mut p = path; let mut p = path;
while !p.exists() { while !p.exists() {
@@ -331,7 +331,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
{ {
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?; 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() }; let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
return None; return None;
@@ -348,7 +348,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect(); .collect();
let mut free = 0u64; 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 { let ok = unsafe {
GetDiskFreeSpaceExW( GetDiskFreeSpaceExW(
wide.as_ptr(), 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> { pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
let mut out = std::fs::OpenOptions::new() let mut out = std::fs::OpenOptions::new()
.write(true) .write(true)
@@ -443,8 +443,8 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
Ok(hasher.finalize()) Ok(hasher.finalize())
} }
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset` /// Reads up to `buf.len()` bytes of a recovered file's logical content starting at `offset`
/// (usado para streaming/pré-visualização sem gravar em disco). /// (used for zero-copy streaming and preview without writing to disk).
pub fn read_found_at( pub fn read_found_at(
src: &dyn Source, src: &dyn Source,
f: &Found, 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 struct FoundSource<'a> {
pub src: &'a dyn Source, pub src: &'a dyn Source,
pub found: &'a Found, 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): //! Forensic export and chain-of-custody recovery reporting:
//! CSV dos achados e um relatório HTML autocontido, pronto para imprimir. //! CSV findings table and self-contained, print-ready HTML/PDF reports.
use crate::hashing::Hashes; use crate::hashing::Hashes;
use crate::meta::fmt_unix; use crate::meta::fmt_unix;
@@ -19,11 +19,11 @@ pub struct CaseInfo {
pub notes: String, 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 struct AuditNote {
pub entries: usize, pub entries: usize,
pub ok: bool, 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, pub head: String,
} }
@@ -42,7 +42,7 @@ pub struct ReportInput<'a> {
} }
fn cell(s: &str) -> String { 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('"', "\"\""); let mut t = s.replace('"', "\"\"");
if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { if t.starts_with(['=', '+', '-', '@', '\t', '\r']) {
t.insert(0, '\''); 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 { 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"); 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 { 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>"); o.push_str("</table>");
} }
// Resumo // Summary
let mut by_cat = std::collections::BTreeMap::new(); let mut by_cat = std::collections::BTreeMap::new();
let mut conf = [0usize; 3]; let mut conf = [0usize; 3];
let mut bytes = 0u64; let mut bytes = 0u64;
@@ -318,7 +318,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
o 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> { pub fn pdf(r: &ReportInput) -> Vec<u8> {
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY}; use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
+15 -15
View File
@@ -1,5 +1,5 @@
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos → //! Scan orchestrator: partition layout -> filesystem metadata readers ->
//! carving no espaço livre/desconhecido → fusão sem duplicatas. //! signature carving across free/unallocated space -> deduplicated merge.
use crate::carve::{carve_range, CarveCfg, CarveEnd, CarveEvents, HitAction}; use crate::carve::{carve_range, CarveCfg, CarveEnd, CarveEvents, HitAction};
use crate::fs::{self, merge_extents, overlaps, Ctx}; use crate::fs::{self, merge_extents, overlaps, Ctx};
@@ -18,12 +18,12 @@ pub struct Progress {
pub total: u64, 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 { pub trait Sink {
fn found(&mut self, f: Found); 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); 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) { fn replace(&mut self, f: Found) {
self.found(f); self.found(f);
} }
@@ -39,14 +39,14 @@ pub struct RangeState {
pub next: u64, pub next: u64,
} }
/// Estado serializável para pausar/retomar. /// Serializable scan state for pause/resume checkpoints.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Checkpoint { pub struct Checkpoint {
pub fs_done: bool, pub fs_done: bool,
pub ranges: Vec<RangeState>, pub ranges: Vec<RangeState>,
pub next_id: u64, pub next_id: u64,
pub layout: Option<DiskLayout>, 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)] #[serde(default)]
pub io_lost: bool, pub io_lost: bool,
} }
@@ -93,7 +93,7 @@ pub fn run(
layout.gaps.clear(); layout.gaps.clear();
layout.table = "membro de RAID".into(); 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 { for p in &opts.extra_parts {
if !layout.partitions.iter().any(|q| q.start == p.start) { if !layout.partitions.iter().any(|q| q.start == p.start) {
let mut p = p.clone(); let mut p = p.clone();
@@ -103,7 +103,7 @@ pub fn run(
} }
cp.layout = Some(layout.clone()); 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 starts: HashMap<u64, (u64, Confidence)> = HashMap::new();
let mut fs_extents: Vec<Extent> = vec![]; let mut fs_extents: Vec<Extent> = vec![];
for f in existing { for f in existing {
@@ -239,7 +239,7 @@ pub fn run(
Outcome::Completed 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 { struct Pending {
found: Found, found: Found,
hit_len: u64, hit_len: u64,
@@ -256,16 +256,16 @@ struct Handler<'a> {
total: u64, total: u64,
index: crate::defrag::ClusterIndex, index: crate::defrag::ClusterIndex,
pending: Vec<Pending>, 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)>, intact_mp4: Vec<(u64, u64)>,
refs: Vec<crate::mp4rebuild::MediaConfig>, 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>, carved: Vec<Extent>,
} }
impl Handler<'_> { impl Handler<'_> {
fn rebuild_mp4(&mut self, p: Pending, off: u64) { 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 refs = self.refs.clone();
let mut near = self.intact_mp4.clone(); let mut near = self.intact_mp4.clone();
near.sort_by_key(|&(o, _)| o.abs_diff(off)); near.sort_by_key(|&(o, _)| o.abs_diff(off));
@@ -316,7 +316,7 @@ impl Handler<'_> {
} }
let mut used = self.fs_extents.clone(); let mut used = self.fs_extents.clone();
used.extend(self.carved.iter().copied()); 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)); used.retain(|e| !(e.offset == off));
let pool = crate::defrag::Pool { let pool = crate::defrag::Pool {
index: &self.index, index: &self.index,
@@ -365,7 +365,7 @@ impl CarveEvents for Handler<'_> {
return HitAction::Skip; return HitAction::Skip;
} }
if overlaps(&self.fs_extents, off, 1) { 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 hit = hit.clone();
let mut skip_len = None; 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), //! Raw full-disk text search (including free and unpartitioned space)
//! em UTF-8 e UTF-16LE, útil quando não há estrutura de arquivo para recuperar. //! across UTF-8 and UTF-16LE encodings, useful when filesystem structures are gone.
use crate::source::Source; use crate::source::Source;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -7,10 +7,10 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchOpts { pub struct SearchOpts {
pub terms: Vec<String>, pub terms: Vec<String>,
/// Ignora maiúsculas/minúsculas (letras ASCII). /// Case-insensitive matching (ASCII letters).
#[serde(default = "yes")] #[serde(default = "yes")]
pub ignore_case: bool, 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")] #[serde(default = "yes")]
pub utf16: bool, pub utf16: bool,
#[serde(default = "default_max")] #[serde(default = "default_max")]
@@ -116,7 +116,7 @@ fn snippet(buf: &[u8], at: usize, len: usize, utf16: bool) -> String {
.collect() .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( pub fn search(
src: &dyn Source, src: &dyn Source,
o: &SearchOpts, o: &SearchOpts,
@@ -144,7 +144,7 @@ pub fn search(
Ok(0) => break, Ok(0) => break,
Ok(n) => got += n, Ok(n) => got += n,
Err(_) => { Err(_) => {
// setor ruim: pula este trecho // unreadable chunk: skip and continue
for b in &mut buf[got..want] { for b in &mut buf[got..want] {
*b = 0; *b = 0;
} }
@@ -155,7 +155,7 @@ pub fn search(
if got == 0 { if got == 0 {
break; 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 folded: Vec<u8>;
let hay: &[u8] = if o.ignore_case { let hay: &[u8] = if o.ignore_case {
folded = buf[..got].to_ascii_lowercase(); folded = buf[..got].to_ascii_lowercase();
@@ -163,7 +163,7 @@ pub fn search(
} else { } else {
&buf[..got] &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() { for (pi, p) in pats.iter().enumerate() {
find_all(hay, p, |i| { find_all(hay, p, |i| {
if i < fresh { 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`): //! Background service layer backing the GUI (Tauri and `guara ui` mode):
//! jobs em segundo plano (varredura, recuperação, imagem) e despachante RPC. //! manages scan/recovery/imaging jobs, sessions, and JSON-RPC dispatch.
use crate::device; use crate::device;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -49,7 +49,7 @@ struct State {
report: Option<Value>, report: Option<Value>,
started: Instant, started: Instant,
finished_in: Option<f64>, 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)>, enrich: Option<(u64, u64)>,
recoveries: Vec<recover::RecoverReport>, recoveries: Vec<recover::RecoverReport>,
started_unix: Option<i64>, started_unix: Option<i64>,
@@ -64,11 +64,11 @@ pub struct Job {
cancel: AtomicBool, cancel: AtomicBool,
stop_requested: AtomicBool, stop_requested: AtomicBool,
src: Mutex<Option<Arc<dyn Source>>>, 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>, 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>>, fingerprint: Mutex<Option<String>>,
/// Arquivo de sessão salvo automaticamente. /// Path to the autosaved session file.
session_file: Mutex<Option<PathBuf>>, session_file: Mutex<Option<PathBuf>>,
st: Mutex<State>, st: Mutex<State>,
} }
@@ -213,7 +213,7 @@ impl Engine {
job job
} }
// ---------------------------------------------------------------- varredura // ---------------------------------------------------------------- scan
pub fn start_scan(&self, path: &str, mut opts: ScanOptions) -> Result<String> { 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))?; 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) 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 { if j.source().is_none() || io_lost {
self.reconnect(&j).map_err(|e| { self.reconnect(&j).map_err(|e| {
Error::msg(format!( Error::msg(format!(
@@ -359,14 +359,14 @@ impl Engine {
Ok(json!({ "total": total, "items": page })) 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> { pub fn facets(&self, id: &str) -> Result<Value> {
let j = self.job(id)?; let j = self.job(id)?;
let s = j.st.lock().unwrap(); let s = j.st.lock().unwrap();
Ok(crate::explorer::facets(&s.found)) 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> { pub fn db_info(&self, id: &str, file: u64) -> Result<Value> {
let (f, src) = self.open_file(id, file)?; let (f, src) = self.open_file(id, file)?;
let fs = recover::FoundSource { let fs = recover::FoundSource {
@@ -383,7 +383,7 @@ impl Engine {
Ok(v) 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( pub fn db_rows(
&self, &self,
id: &str, id: &str,
@@ -415,13 +415,13 @@ impl Engine {
Ok(serde_json::to_value(&page)?) 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> { pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
let (f, src) = self.open_file(id, file)?; let (f, src) = self.open_file(id, file)?;
Ok(crate::meta::extract(src.as_ref(), &f)) 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> { pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result<Arc<Vec<u8>>, u16> {
let size = size.clamp(48, 640); let size = size.clamp(48, 640);
let key = (job.to_string(), id, size); 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>)> { pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
let j = self.job(id)?; let j = self.job(id)?;
let s = j.st.lock().unwrap(); let s = j.st.lock().unwrap();
@@ -456,7 +456,7 @@ impl Engine {
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?)) Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
} }
// ---------------------------------------------------------------- recuperação // ---------------------------------------------------------------- recovery
pub fn start_recover( pub fn start_recover(
&self, &self,
@@ -575,13 +575,13 @@ impl Engine {
Ok(job.id.clone()) Ok(job.id.clone())
} }
// ---------------------------------------------------------------- imagem // ---------------------------------------------------------------- disk imaging
pub fn start_image(&self, path: &str, dest: &str) -> Result<String> { pub fn start_image(&self, path: &str, dest: &str) -> Result<String> {
self.start_image_as(path, dest, &image::ImageFormat::Raw) 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( pub fn start_image_as(
&self, &self,
path: &str, path: &str,
@@ -644,9 +644,9 @@ impl Engine {
Ok(job.id.clone()) 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> { pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result<usize> {
let j = self.job(id)?; let j = self.job(id)?;
let s = j.st.lock().unwrap(); let s = j.st.lock().unwrap();
@@ -662,7 +662,7 @@ impl Engine {
Ok(items.len()) 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( pub fn export_report(
&self, &self,
id: &str, id: &str,
@@ -714,7 +714,7 @@ impl Engine {
Ok(()) 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> { pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result<String> {
if opts.terms.iter().all(|t| t.trim().is_empty()) { 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> { pub fn start_hash(&self, path: &str) -> Result<String> {
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
let job = self.register( let job = self.register(
@@ -833,7 +833,7 @@ impl Engine {
Ok(job.id.clone()) Ok(job.id.clone())
} }
// ---------------------------------------------------------------- sessões // ---------------------------------------------------------------- sessions
pub fn save_session(&self, id: &str, path: &str) -> Result<()> { pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?; 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}" "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); let want = Path::new(path);
if let Some(j) = self if let Some(j) = self
.jobs .jobs
@@ -899,8 +899,8 @@ impl Engine {
Status::Paused Status::Paused
}; };
} }
// A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome); // Always load the session metadata. Attempt to reconnect the source disk (even under a new path);
// sem a origem ainda dá para navegar, exportar e gerar relatório. // if the source is offline, browsing, filtering, CSV export, and reporting still work.
match self.reconnect(&job) { match self.reconnect(&job) {
Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!( Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!(
"O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)." "O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)."
@@ -911,7 +911,7 @@ impl Engine {
data.target 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)); *job.session_file.lock().unwrap() = Some(PathBuf::from(path));
crate::audit::record( crate::audit::record(
"session.load", "session.load",
@@ -923,9 +923,9 @@ impl Engine {
Ok(job.id.clone()) Ok(job.id.clone())
} }
/// Reabre a origem da sessão: tenta o caminho atual e o original e, se o disco voltou com /// Reopens the session's source: tries the current and original paths first, then scans attached
/// outro nome, procura entre os dispositivos conectados o que tem o mesmo conteúdo. /// devices for a matching fingerprint if the drive reconnected under a new device node.
/// `Ok(Some(caminho))` quando achou o disco num caminho diferente do esperado. /// Returns `Ok(Some(new_path))` when found at a different device path.
fn reconnect(&self, j: &Arc<Job>) -> Result<Option<String>> { fn reconnect(&self, j: &Arc<Job>) -> Result<Option<String>> {
let fp = j.fingerprint.lock().unwrap().clone(); let fp = j.fingerprint.lock().unwrap().clone();
let size = let size =
@@ -970,8 +970,8 @@ impl Engine {
)) ))
} }
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual. /// Rebinds a loaded session to a user-selected source device or disk image.
/// Recusa uma origem que não seja o mesmo disco, a menos que `force`. /// Rejects a source whose fingerprint/size mismatches the session unless `force` is set.
pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> { pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> {
let j = self.job(id)?; let j = self.job(id)?;
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
@@ -1010,7 +1010,7 @@ impl Engine {
Ok(()) 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> { pub fn candidates(&self, id: &str) -> Result<Value> {
let j = self.job(id)?; let j = self.job(id)?;
let fp = j.fingerprint.lock().unwrap().clone(); let fp = j.fingerprint.lock().unwrap().clone();
@@ -1042,9 +1042,9 @@ impl Engine {
Ok(json!(out)) 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 { pub fn sessions_list(&self) -> Value {
let dir = sessions_dir(); let dir = sessions_dir();
let mut items: Vec<Value> = vec![]; let mut items: Vec<Value> = vec![];
@@ -1071,7 +1071,7 @@ impl Engine {
json!(items) 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<()> { pub fn session_delete(&self, path: &str) -> Result<()> {
let p = Path::new(path); let p = Path::new(path);
if p.parent() != Some(sessions_dir().as_path()) { if p.parent() != Some(sessions_dir().as_path()) {
@@ -1162,7 +1162,7 @@ impl Engine {
"health" => json!(crate::health::check(&s("path")?)), "health" => json!(crate::health::check(&s("path")?)),
"open_url" => { "open_url" => {
let u = s("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/") { if !u.starts_with("https://www.openstreetmap.org/") {
return Err(Error::msg("endereço não permitido")); return Err(Error::msg("endereço não permitido"));
} }
@@ -1332,7 +1332,7 @@ struct SessionFile {
use crate::explorer::cat_key; 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 { fn status_of(e: Error) -> u16 {
if e.to_string().contains("não está disponível") { if e.to_string().contains("não está disponível") {
503 503
@@ -1356,7 +1356,7 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
} }
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) { 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(); let saver = job.clone();
std::thread::spawn(move || autosave_loop(saver)); std::thread::spawn(move || autosave_loop(saver));
std::thread::spawn(move || { 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 /// Background worker that extracts EXIF dates, camera models, GPS, and image dimensions
/// navegar por data, câmera e resolução. /// so findings can be browsed by date, camera, and resolution.
fn spawn_enrich(job: Arc<Job>) { fn spawn_enrich(job: Arc<Job>) {
std::thread::spawn(move || { std::thread::spawn(move || {
let Some(src) = job.source() else { return }; let Some(src) = job.source() else { return };
@@ -1487,8 +1487,8 @@ pub fn base64(data: &[u8]) -> String {
out out
} }
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar /// Returns a safe MIME type for preview streaming. Formats capable of executing
/// script (HTML/SVG/XML) saem como texto puro. /// scripts (HTML/SVG/XML) are served as `text/plain`.
pub fn mime_for(ext: &str) -> &'static str { pub fn mime_for(ext: &str) -> &'static str {
match ext.to_ascii_lowercase().as_str() { match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg", "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 /// Byte slice of a recovered file, ready to answer an HTTP `Range` request
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri. /// in both `guara ui` mode and the Tauri custom protocol handler.
pub struct FileSlice { pub struct FileSlice {
found: Found, found: Found,
src: Arc<dyn Source>, src: Arc<dyn Source>,
pub start: u64, pub start: u64,
/// Inclusivo. /// Inclusive end byte offset.
pub end: u64, pub end: u64,
pub total: u64, pub total: u64,
pub partial: bool, pub partial: bool,
@@ -1549,7 +1549,7 @@ impl FileSlice {
("Accept-Ranges", "bytes".into()), ("Accept-Ranges", "bytes".into()),
("Cache-Control", "no-store".into()), ("Cache-Control", "no-store".into()),
("X-Content-Type-Options", "nosniff".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", "Content-Security-Policy",
"sandbox; default-src 'none'".into(), "sandbox; default-src 'none'".into(),
@@ -1569,7 +1569,7 @@ impl FileSlice {
} }
h 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>> { pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
let n = self.len().min(cap) as usize; let n = self.len().min(cap) as usize;
let mut out = vec![0u8; n]; let mut out = vec![0u8; n];
@@ -1621,7 +1621,7 @@ impl std::io::Read for FileSliceReader {
} }
impl Engine { 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( pub fn file_slice(
&self, &self,
job: &str, 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> { fn finish_source(j: &Job, raw: Arc<dyn Source>) -> Arc<dyn Source> {
match crate::assemble::assemble_source(raw.clone(), &j.opts.assembly) { match crate::assemble::assemble_source(raw.clone(), &j.opts.assembly) {
Ok(a) => a.source, Ok(a) => a.source,
@@ -1700,7 +1700,7 @@ fn new_session_path(target: &str) -> PathBuf {
fn snapshot(j: &Job) -> SessionFile { fn snapshot(j: &Job) -> SessionFile {
let s = j.st.lock().unwrap(); 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(); let mut options = j.opts.clone();
options.assembly.secrets.clear(); options.assembly.secrets.clear();
if !options.assembly.remember_keys { 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<()> { fn write_session(j: &Job, path: &Path) -> Result<()> {
let data = snapshot(j); let data = snapshot(j);
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?; crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?;
std::fs::rename(&tmp, path)?; std::fs::rename(&tmp, path)?;
crate::owner::give_to_user(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 { let status = if data.done {
"concluída" "concluída"
} else if data.checkpoint.is_some() { } else if data.checkpoint.is_some() {
@@ -1749,7 +1749,7 @@ fn write_session(j: &Job, path: &Path) -> Result<()> {
Ok(()) 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>) { fn autosave_loop(job: Arc<Job>) {
let mut last = Instant::now(); let mut last = Instant::now();
let mut interval = std::time::Duration::from_secs(45); 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() { if write_session(&job, &path).is_err() {
return; 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)); interval = (t0.elapsed() * 10).max(std::time::Duration::from_secs(45));
last = Instant::now(); last = Instant::now();
if !running { if !running {
+14 -14
View File
@@ -1,5 +1,5 @@
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o //! User preferences (theme, language, notifications, etc.), shared between normal and
//! reiniciado como administrador. //! elevated (root/administrator) instances.
use crate::error::Result; use crate::error::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -7,24 +7,24 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
/// "auto", "light" ou "dark". /// Theme mode: `"auto"`, `"light"`, or `"dark"`.
#[serde(default = "auto")] #[serde(default = "auto")]
pub theme: String, pub theme: String,
/// Idioma da interface: "auto", "pt" ou "en". /// UI language preference: `"auto"`, `"pt"`, or `"en"`.
#[serde(default = "auto")] #[serde(default = "auto")]
pub lang: String, pub lang: String,
/// Idioma que a interface resolveu de fato ("pt" ou "en"), para as /// Effective language resolved by the UI (`"pt"` or `"en"`), so OS desktop
/// notificações do sistema e a saída do terminal acompanharem a tela. /// notifications and CLI messages match the active UI language.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_lang: Option<String>, 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")] #[serde(default = "yes")]
pub notify: bool, 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)] #[serde(default)]
pub maps: bool, pub maps: bool,
/// Procurar versão nova no Gitea ao abrir o app (uma consulta ao `latest.json` da release, /// Check Gitea Releases for updates on startup (fetches `latest.json` once
/// sem enviar nada além do que qualquer navegador envia). /// without sending any telemetry).
#[serde(default = "yes")] #[serde(default = "yes")]
pub updates: bool, pub updates: bool,
} }
@@ -50,8 +50,8 @@ impl Default for Settings {
} }
} }
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root /// Configuration directory of the *real* invoking user, even when running as root
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios). /// via `sudo`/`pkexec` (so theme and language settings persist across elevation).
pub fn config_dir() -> PathBuf { pub fn config_dir() -> PathBuf {
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") { if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
if !d.is_empty() { if !d.is_empty() {
@@ -111,8 +111,8 @@ pub fn load() -> Settings {
.unwrap_or_default() .unwrap_or_default()
} }
/// Idioma efetivo para textos gerados fora da interface (notificações e CLI). /// Effective language for strings generated outside the webview (OS notifications and CLI).
/// Usa o que a interface informou; sem isso, a preferência e o ambiente. /// Uses the UI-resolved language when available, falling back to saved preference and `LANG`.
pub fn ui_lang() -> String { pub fn ui_lang() -> String {
let s = load(); let s = load();
if let Some(l) = s.ui_lang.as_deref() { 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 //! Ed25519 digital signatures for manifests, reports, and arbitrary files, plus full recovery
//! de uma recuperação inteira. //! folder verification.
//! //!
//! A assinatura fica ao lado do arquivo, em `<arquivo>.assinatura.json`, e cobre o SHA-256 do //! Signatures are stored alongside the file in `<file>.assinatura.json` and cover the content
//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo, //! SHA-256, byte size, filename, timestamp, and signer name. Verification recomputes the file
//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso //! hash, reconstructs the canonical signed message, and checks the Ed25519 signature against the
//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de //! embedded public key. To bind the signature to a specific examiner, compare `fingerprint`
//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou //! against the examiner's published fingerprint via `trusted_fingerprint`.
//! registrou fora da máquina, passando-a em `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 //! Note: `signed_at_utc` comes from the signing machine's local clock, not an RFC 3161 trusted
//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem //! timestamp authority.
//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá. //!
//! 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 crate::error::{Error, Result};
use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature}; use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature};
@@ -41,7 +41,7 @@ fn unhex(s: &str) -> Option<Vec<u8>> {
.collect() .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 { pub fn fingerprint(public_key: &[u8]) -> String {
let h = Sha256::digest(public_key); let h = Sha256::digest(public_key);
let h = hex(&h[..10]); let h = hex(&h[..10]);
@@ -52,7 +52,7 @@ pub fn fingerprint(public_key: &[u8]) -> String {
.join(":") .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 { fn same_fingerprint(a: &str, b: &str) -> bool {
let n = |s: &str| { let n = |s: &str| {
s.chars() s.chars()
@@ -64,7 +64,7 @@ fn same_fingerprint(a: &str, b: &str) -> bool {
!a.is_empty() && a == b !a.is_empty() && a == b
} }
/// Par de chaves de assinatura. /// Ed25519 signing keypair.
pub struct SigningKey { pub struct SigningKey {
kp: KeyPair, 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<()> { pub fn save(&self, path: &Path) -> Result<()> {
use std::io::Write; use std::io::Write;
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
@@ -148,7 +148,7 @@ fn pub_path(key: &Path) -> PathBuf {
key.with_extension("pub") 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 { pub fn default_key_path() -> PathBuf {
match std::env::var("GUARA_SIGNING_KEY") { match std::env::var("GUARA_SIGNING_KEY") {
Ok(p) if !p.is_empty() => PathBuf::from(p), 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 { pub fn signature_path(path: &Path) -> PathBuf {
let mut n = path.as_os_str().to_os_string(); let mut n = path.as_os_str().to_os_string();
n.push(".assinatura.json"); n.push(".assinatura.json");
@@ -190,7 +190,7 @@ fn message(f: &SignatureFile) -> Vec<u8> {
.into_bytes() .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)> { pub fn hash_file(path: &Path) -> Result<(String, u64)> {
let mut f = std::fs::File::open(path)?; let mut f = std::fs::File::open(path)?;
let mut h = Sha256::new(); let mut h = Sha256::new();
@@ -215,7 +215,7 @@ fn now_iso() -> String {
crate::audit::utc_iso(secs) 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> { pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
let (sha256, size) = hash_file(path)?; let (sha256, size) = hash_file(path)?;
let mut sig = SignatureFile { let mut sig = SignatureFile {
@@ -242,17 +242,17 @@ pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<
Ok(out) Ok(out)
} }
/// Resultado de conferir uma assinatura. /// Result of verifying a detached `.assinatura.json` signature.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Check { 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 valid: bool,
pub file_matches: bool, pub file_matches: bool,
pub signature_valid: bool, pub signature_valid: bool,
pub fingerprint: String, pub fingerprint: String,
pub signed_at: String, pub signed_at: String,
pub signer: Option<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 trusted: Option<bool>,
pub problems: Vec<String>, 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 /// Verifies a recovery directory: checks the manifest signature (if present) and verifies every
/// arquivo contra o que o manifesto registrou. /// recovered file's SHA-256 against the manifest.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct RecoveryCheck { pub struct RecoveryCheck {
pub manifest_signature: Option<Check>, pub manifest_signature: Option<Check>,
@@ -329,7 +329,7 @@ pub struct RecoveryCheck {
pub ok: usize, pub ok: usize,
pub missing: Vec<String>, pub missing: Vec<String>,
pub changed: 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, 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 { let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else {
continue; 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() { if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() {
changed.push(format!("{rel} (caminho fora da pasta)")); changed.push(format!("{rel} (caminho fora da pasta)"));
continue; 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 /// Auto-signs `path` if a signing key exists; never aborts the caller on error. Logs to the
/// trilha de auditoria. /// audit trail.
pub fn sign_if_configured(path: &Path) { pub fn sign_if_configured(path: &Path) {
let kp = default_key_path(); let kp = default_key_path();
if !kp.is_file() { 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) //! Each detector parses the internal container structure (headers, chunks, footers)
//! para determinar o tamanho exato e o nível de confiança, em vez de apenas //! to determine the exact file size and confidence level rather than relying on
//! procurar bytes mágicos. //! magic bytes alone.
use crate::source::{read_vec, Source}; use crate::source::{read_vec, Source};
use crate::types::{Category, Confidence}; 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)] #[derive(Debug, Clone)]
pub struct Hit { pub struct Hit {
pub len: u64, 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. /// Checks whether the leading bytes `head` match the expected magic for `ext`.
/// `None` = extensão sem assinatura conhecida (não dá para julgar). /// Returns `None` if `ext` has no known magic signature.
pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> { pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
let ext = ext.to_ascii_lowercase(); let ext = ext.to_ascii_lowercase();
let starts = |m: &[u8]| head.starts_with(m); let starts = |m: &[u8]| head.starts_with(m);
@@ -86,8 +86,8 @@ pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
Some(r) Some(r)
} }
/// Tabela de primeiros bytes -> detectores candidatos. /// Dispatch table mapping leading byte -> candidate format detectors.
/// O carving consulta `candidates(first_byte)` e chama `detect`. /// The carver queries `candidates(first_byte)` and invokes `detect`.
pub fn might_start(b0: u8) -> bool { pub fn might_start(b0: u8) -> bool {
crate::formats::might_start(b0) crate::formats::might_start(b0)
|| matches!( || matches!(
@@ -111,8 +111,8 @@ pub fn might_start(b0: u8) -> bool {
const MAX_HEADER: usize = 64 * 1024; const MAX_HEADER: usize = 64 * 1024;
/// Tenta reconhecer um arquivo que começa em `off`. /// Attempts to recognize a file starting at `off`.
/// `head` já contém os primeiros bytes lidos em `off` (pelo menos 16, se existirem). /// `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> { pub fn detect(src: &dyn Source, off: u64, head: &[u8], max_size: u64) -> Option<Hit> {
if head.len() < 12 { if head.len() < 12 {
return None; return None;
@@ -204,7 +204,7 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if !saw_sof { if !saw_sof {
return None; 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 mut pos = off + start as u64;
let limit = off + max.min(512 * 1024 * 1024); let limit = off + max.min(512 * 1024 * 1024);
let mut buf = vec![0u8; 1 << 20]; 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; 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 { Some(Hit {
len: (pos - off).min(4 * 1024 * 1024), len: (pos - off).min(4 * 1024 * 1024),
ext: "jpg", 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 len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
let ty = &c[4..8]; let ty = &c[4..8];
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do // Valid PNG chunk: 4 ASCII letters with uppercase 3rd letter (reserved bit), plausible size within
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante. // 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()) let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
&& ty[2].is_ascii_uppercase() && ty[2].is_ascii_uppercase()
&& len <= 128 * 1024 * 1024 && 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 { if first || !saw_idat || p > 64 * 1024 * 1024 {
return None; return None;
} }
@@ -388,7 +388,7 @@ fn pdf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let mut i = 0; let mut i = 0;
while let Some(f) = find(&hay[i..], b"%%EOF") { while let Some(f) = find(&hay[i..], b"%%EOF") {
let end = base + (i + f) as u64 + 5; 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 tail = read_vec(src, end, 32).ok()?;
let rest: Vec<u8> = tail let rest: Vec<u8> = tail
.iter() .iter()
@@ -428,7 +428,7 @@ fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle) hay.windows(needle.len()).position(|w| w == needle)
} }
// ---------------------------------------------------------------- ZIP & derivados // ---------------------------------------------------------------- ZIP & derivatives
fn zip(src: &dyn Source, off: u64, max: u64) -> Option<Hit> { fn zip(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let limit = max.min(4 << 30); 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/") { } else if has("ppt/") {
"pptx" "pptx"
} else if names.first().map(|n| n == "mimetype").unwrap_or(false) { } 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 head = read_vec(src, start, 256).unwrap_or_default();
let s = String::from_utf8_lossy(&head); let s = String::from_utf8_lossy(&head);
if s.contains("opendocument.text") { 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()); size = u64::from_be_bytes(b[8..16].try_into().unwrap());
} else if size == 0 { } else if size == 0 {
// Até o fim do arquivo: tamanho desconhecido. // Box extends to EOF: exact size unknown.
size = total - p; size = total - p;
} }
if size < 8 || p + size > max { if size < 8 || p + size > max {
@@ -705,7 +705,7 @@ pub fn crc32(data: &[u8]) -> u32 {
!crc !crc
} }
// ---------------------------------------------------------------- OLE2 (doc/xls/ppt legado) // ---------------------------------------------------------------- OLE2 (legacy doc/xls/ppt)
fn ole(src: &dyn Source, off: u64, max: u64) -> Option<Hit> { fn ole(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let hdr = read_vec(src, off, 512).ok()?; 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 { if frames < 20 {
return None; 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 let Ok(t) = read_vec(src, off + p, 3) {
if t == b"TAG" { if t == b"TAG" {
p += 128; 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::fs::File;
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use std::sync::Arc; 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 { pub trait Source: Send + Sync {
fn size(&self) -> u64; 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>; 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<()> { pub fn read_exact_at(src: &dyn Source, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
while !buf.is_empty() { while !buf.is_empty() {
let n = src.read_at(offset, buf)?; 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(()) 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>> { pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let mut v = vec![0u8; len]; let mut v = vec![0u8; len];
let mut got = 0; 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) 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 { pub struct FileSource {
file: File, file: File,
size: u64, size: u64,
/// Dispositivos brutos (Windows/macOS) exigem leituras alinhadas ao setor. /// Raw block devices on Windows/macOS require sector-aligned reads.
align: u64, align: u64,
} }
@@ -105,7 +105,7 @@ impl Source for FileSource {
if self.align <= 1 { if self.align <= 1 {
return self.raw_read(offset, &mut buf[..want]); 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 a = self.align;
let start = offset / a * a; let start = offset / a * a;
let end = ((offset + want as u64).div_ceil(a) * a).min(self.size.div_ceil(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 { pub struct SubSource {
inner: Arc<dyn Source>, inner: Arc<dyn Source>,
start: u64, 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>); pub struct MemSource(pub Vec<u8>);
impl Source for MemSource { impl Source for MemSource {
@@ -195,7 +195,7 @@ fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
#[cfg(windows)] #[cfg(windows)]
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> { fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt; 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() std::fs::OpenOptions::new()
.read(true) .read(true)
.share_mode(0x1 | 0x2) .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; const DKIOCGETBLOCKCOUNT: libc::c_ulong = 0x4008_6419;
let fd = file.as_raw_fd(); let fd = file.as_raw_fd();
let (mut bs, mut bc) = (0u32, 0u64); 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 { let ok = unsafe {
libc::ioctl(fd, DKIOCGETBLOCKSIZE, &mut bs) == 0 libc::ioctl(fd, DKIOCGETBLOCKSIZE, &mut bs) == 0
&& libc::ioctl(fd, DKIOCGETBLOCKCOUNT, &mut bc) == 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)) 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)] #[cfg(windows)]
pub fn open_windows_device(path: &Path) -> io::Result<FileSource> { pub fn open_windows_device(path: &Path) -> io::Result<FileSource> {
let file = open_readonly(path, true)?; 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; use windows_sys::Win32::System::IO::DeviceIoControl;
let mut info = GET_LENGTH_INFORMATION { Length: 0 }; let mut info = GET_LENGTH_INFORMATION { Length: 0 };
let mut ret = 0u32; 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 { let ok = unsafe {
DeviceIoControl( DeviceIoControl(
file.as_raw_handle() as _, file.as_raw_handle() as _,
@@ -263,7 +263,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
Ok(info.Length as 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>> { pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
#[cfg(windows)] #[cfg(windows)]
{ {
@@ -273,7 +273,7 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
} }
} }
let file = FileSource::open(path)?; 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 mut head = [0u8; 96];
let got = file.read_at(0, &mut head).unwrap_or(0); 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]) { 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)?)) Ok(Arc::new(FileSource::open(path)?))
} }
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB /// Content fingerprint of a source disk: SHA-256 of its byte size plus the first and last MiB
/// (tabela de partições, cabeçalhos e backup). Serve para reconhecer o mesmo disco /// (partition tables, headers, and backup GPT). Used to recognize the same physical drive
/// quando ele volta com outro nome (`/dev/sda` virando `/dev/sdb`). /// if it is unplugged and reattached under a different device name (`/dev/sda` -> `/dev/sdb`).
pub fn fingerprint(src: &dyn Source) -> Option<String> { pub fn fingerprint(src: &dyn Source) -> Option<String> {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
const MIB: u64 = 1024 * 1024; 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 //! Parses B-tree pages directly (without linking `libsqlite3`), so it works on
//! em bancos danificados ou truncados, para os quais a biblioteca oficial desiste. //! damaged or truncated database files that the official library rejects.
//! Também procura **registros apagados** nas áreas livres das páginas, onde o //! Also carves **deleted records** from page freeblocks and unallocated cell areas
//! SQLite deixa o conteúdo antigo até que outro dado o sobrescreva. //! where SQLite leaves unlinked row payloads until overwritten.
use crate::source::Source; use crate::source::Source;
use serde::Serialize; use serde::Serialize;
@@ -18,7 +18,7 @@ pub enum Cell {
Int(i64), Int(i64),
Real(f64), Real(f64),
Text(String), 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 { Blob {
len: usize, len: usize,
head: String, head: String,
@@ -41,7 +41,7 @@ pub struct DbTable {
pub columns: Vec<DbColumn>, pub columns: Vec<DbColumn>,
pub without_rowid: bool, pub without_rowid: bool,
pub virtual_table: 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)] #[serde(skip)]
pub rowid_alias: Option<usize>, pub rowid_alias: Option<usize>,
} }
@@ -83,7 +83,7 @@ pub struct Db<'a> {
type Res<T> = Result<T, String>; type Res<T> = Result<T, String>;
// ------------------------------------------------------------------ utilidades // ------------------------------------------------------------------ utilities
fn varint(b: &[u8]) -> Option<(i64, usize)> { fn varint(b: &[u8]) -> Option<(i64, usize)> {
let mut v: u64 = 0; 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>> { fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
let (hlen, n) = varint(p)?; let (hlen, n) = varint(p)?;
let hlen = hlen as usize; let hlen = hlen as usize;
@@ -201,7 +201,7 @@ fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
Some(cells) Some(cells)
} }
// ------------------------------------------------------------------ banco // ------------------------------------------------------------------ database
impl<'a> Db<'a> { impl<'a> Db<'a> {
pub fn open(src: &'a dyn Source) -> Res<Self> { 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( fn payload(
&self, &self,
page: &[u8], page: &[u8],
@@ -304,8 +304,8 @@ impl<'a> Db<'a> {
Some(out) Some(out)
} }
/// Percorre as folhas de uma árvore de tabela chamando `f(rowid, payload)`. /// Walks all leaf pages of a table B-tree, invoking `f(rowid, payload)` for each cell.
/// `f` devolve false para parar. Devolve os avisos de corrupção encontrados. /// `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> { fn walk_table(&self, root: u32, f: &mut dyn FnMut(i64, &[u8]) -> bool) -> Vec<String> {
let mut warnings = vec![]; let mut warnings = vec![];
let mut stack = vec![(root, 0u32)]; let mut stack = vec![(root, 0u32)];
@@ -407,7 +407,7 @@ impl<'a> Db<'a> {
leaves leaves
} }
// ---------------------------------------------------------- esquema // ---------------------------------------------------------- schema
pub fn schema(&self) -> DbSchema { pub fn schema(&self) -> DbSchema {
let mut s = DbSchema { let mut s = DbSchema {
@@ -486,7 +486,7 @@ impl<'a> Db<'a> {
.ok_or_else(|| format!("tabela {name} não encontrada")) .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 { pub fn count_rows(&self, t: &DbTable) -> u64 {
if t.rootpage == 0 || t.virtual_table || t.without_rowid { if t.rootpage == 0 || t.virtual_table || t.without_rowid {
return 0; return 0;
@@ -500,7 +500,7 @@ impl<'a> Db<'a> {
n n
} }
// ---------------------------------------------------------- linhas // ---------------------------------------------------------- rows
pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> { pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> {
let t = self.table(table)?; let t = self.table(table)?;
@@ -550,11 +550,11 @@ impl<'a> Db<'a> {
Ok(page) Ok(page)
} }
// ---------------------------------------------------------- registros apagados // ---------------------------------------------------------- deleted records
/// Procura registros apagados nas áreas livres das páginas da tabela. /// Carves deleted records from unallocated gaps and freeblocks across table leaf pages.
/// São "prováveis": o SQLite não marca o que foi apagado, então validamos pela forma /// Candidates are validated structurally against the table schema (column count,
/// do registro (mesmo número de colunas, tipos e textos coerentes). /// column type affinities, and valid UTF-8 text).
pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> { pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> {
let t = self.table(table)?; let t = self.table(table)?;
let n = t.columns.len(); let n = t.columns.len();
@@ -579,7 +579,7 @@ impl<'a> Db<'a> {
if content > ptr_end { if content > ptr_end {
regions.push((ptr_end, content)); regions.push((ptr_end, content));
} }
// cadeia de blocos livres // page freeblock chain
let mut fb = be16(&pg, hb + 1).unwrap_or(0); let mut fb = be16(&pg, hb + 1).unwrap_or(0);
let mut guard = 0; let mut guard = 0;
while fb != 0 && guard < 2000 { while fb != 0 && guard < 2000 {
@@ -620,10 +620,10 @@ impl<'a> Db<'a> {
Ok(out) Ok(out)
} }
/// Tenta ler um registro apagado no início de `b`, em três formas: /// Attempts to parse a deleted record at the start of `b` in three layouts:
/// (a) cabeçalho íntegro; (b) tamanho do cabeçalho perdido (o SQLite sobrescreve os 4 /// (a) intact record header; (b) overwritten header-size varint (SQLite overwrites the first
/// primeiros bytes do bloco livre); (c) como (b), mas sem o primeiro tipo (coluna que é /// 4 bytes of a freeblock with `next`/`size`); (c) like (b), but with the first serial type
/// alias do rowid, sempre NULL). /// (`INTEGER PRIMARY KEY` rowid alias, always `NULL`) also overwritten.
fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> { fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> {
let n = t.columns.len(); let n = t.columns.len();
// (a) // (a)
@@ -641,8 +641,8 @@ impl<'a> Db<'a> {
None None
} }
/// Lê `ntypes` tipos seriais a partir de `b[start..]` (com um tipo inicial opcional já /// Reads `ntypes` serial type varints from `b[start..]` (with an optional synthesized leading
/// conhecido) e o corpo logo depois do cabeçalho. /// type) followed immediately by the value body.
fn record_with_types( fn record_with_types(
&self, &self,
b: &[u8], b: &[u8],
@@ -654,7 +654,7 @@ impl<'a> Db<'a> {
let n = t.columns.len(); let n = t.columns.len();
let mut types: Vec<i64> = first.into_iter().collect(); let mut types: Vec<i64> = first.into_iter().collect();
let mut o = start; 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 { for _ in 0..ntypes {
let (ty, m) = varint(b.get(o..)?)?; let (ty, m) = varint(b.get(o..)?)?;
types.push(ty); types.push(ty);
@@ -665,7 +665,7 @@ impl<'a> Db<'a> {
hdr_bytes += 1; hdr_bytes += 1;
} }
if start == 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 { if *b.first()? as usize != o {
return None; return None;
} }
@@ -685,7 +685,7 @@ impl<'a> Db<'a> {
if body == 0 || o + body > b.len() { if body == 0 || o + body > b.len() {
return None; 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 rec = vec![];
let mut hdr = vec![]; let mut hdr = vec![];
for &ty in &types { 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 { fn type_ok(decl: &str, ty: i64) -> bool {
if ty == 10 || ty == 11 || ty < 0 { if ty == 10 || ty == 11 || ty < 0 {
return false; 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]) { fn apply_affinity(cells: &mut [Cell], cols: &[DbColumn]) {
for (c, col) in cells.iter_mut().zip(cols) { for (c, col) in cells.iter_mut().zip(cols) {
let d = col.decl_type.to_uppercase(); 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) { fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
let upper = sql.to_uppercase(); let upper = sql.to_uppercase();
let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID"); let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID");
let Some(open) = sql.find('(') else { let Some(open) = sql.find('(') else {
return (vec![], None, without_rowid); return (vec![], None, without_rowid);
}; };
// fecha o parêntese correspondente // find matching closing parenthesis
let mut depth = 0; let mut depth = 0;
let mut close = sql.len(); let mut close = sql.len();
let mut in_q: Option<char> = None; 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())]; 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 parts = vec![];
let (mut d, mut start, mut q) = (0, 0, None::<char>); let (mut d, mut start, mut q) = (0, 0, None::<char>);
for (i, ch) in body.char_indices() { 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) (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 { pub fn is_sqlite(head: &[u8]) -> bool {
head.len() >= 16 && &head[0..16] == MAGIC 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 //! Thumbnail generator for the file explorer grid. Uses DCT-scaled JPEG decoding
//! decodificação (JPEG) para ser rápido mesmo com milhares de fotos e, se a //! for fast generation across thousands of photos, falling back to the embedded
//! imagem estiver danificada, tenta a miniatura embutida no EXIF. //! EXIF thumbnail when the main image is damaged.
use crate::recover::read_found_at; use crate::recover::read_found_at;
use crate::source::Source; 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) (!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>> { pub fn thumbnail(src: &dyn Source, f: &Found, max: u32) -> Option<Vec<u8>> {
let data = read_all(src, f)?; let data = read_all(src, f)?;
let img = match f.ext.to_ascii_lowercase().as_str() { 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 jpeg_decoder::PixelFormat::CMYK32 => px
.chunks_exact(4) .chunks_exact(4)
.flat_map(|c| { .flat_map(|c| {
// Adobe CMYK vem invertido // Adobe CMYK JPEGs store inverted channel values
let k = c[3] as u32; let k = c[3] as u32;
[ [
(c[0] as u32 * k / 255) as u8, (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> { fn decode_other(data: &[u8], max: u32) -> Option<RgbImage> {
let dynimg = image::load_from_memory(data).ok()?; let dynimg = image::load_from_memory(data).ok()?;
let rgba = dynimg.to_rgba8(); 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()); let mut rgb = RgbImage::new(rgba.width(), rgba.height());
for (x, y, p) in rgba.enumerate_pixels() { for (x, y, p) in rgba.enumerate_pixels() {
let a = p[3] as u32; 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}; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extent { pub struct Extent {
pub offset: u64, pub offset: u64,
pub len: u64, pub len: u64,
/// Trecho esparso: lido como zeros. /// Sparse hole (`offset == 0`): read as zero bytes.
#[serde(default, skip_serializing_if = "is_false")] #[serde(default, skip_serializing_if = "is_false")]
pub sparse: bool, pub sparse: bool,
} }
@@ -99,14 +99,14 @@ impl Extent {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
pub enum DataLoc { 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 { Resident {
bytes: Vec<u8>, bytes: Vec<u8>,
}, },
Extents { Extents {
extents: Vec<Extent>, 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 {
parts: Vec<Part>, parts: Vec<Part>,
}, },
@@ -119,11 +119,11 @@ pub enum Part {
#[serde(with = "b64")] #[serde(with = "b64")]
bytes: Vec<u8>, bytes: Vec<u8>,
}, },
/// Trecho contíguo da origem. /// Contiguous byte run on the source.
Run { offset: u64, len: u64 }, Run { offset: u64, len: u64 },
/// Zeros (buracos de arquivos esparsos). /// Zero-filled range (sparse file hole).
Zeros { len: u64 }, 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), Packed(Packed),
} }
@@ -137,20 +137,20 @@ pub enum PackedCodec {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Packed { pub struct Packed {
/// Onde estão os bytes comprimidos na origem. /// Source byte offset of the compressed extent.
pub offset: u64, pub offset: u64,
pub disk_len: u64, pub disk_len: u64,
/// Tamanho da extensão depois de descompactada. /// Total uncompressed byte length of the extent.
pub ram_len: u64, 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, pub skip: u64,
/// Bytes úteis a partir de `skip`. /// Number of uncompressed bytes to expose starting at `skip`.
pub len: u64, pub len: u64,
pub codec: PackedCodec, pub codec: PackedCodec,
} }
impl Packed { 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>> { pub fn read(&self, src: &dyn crate::source::Source) -> std::io::Result<Vec<u8>> {
use std::io::Read; use std::io::Read;
let raw = crate::source::read_vec(src, self.offset, self.disk_len as usize)?; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Found { pub struct Found {
pub id: u64, pub id: u64,
pub name: String, 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 dir: Option<String>,
pub ext: String, pub ext: String,
pub category: Category, pub category: Category,
@@ -296,31 +296,31 @@ pub struct Found {
pub origin: Origin, pub origin: Origin,
pub confidence: Confidence, pub confidence: Confidence,
pub data: DataLoc, pub data: DataLoc,
/// Segundos desde 1970-01-01 UTC. /// Seconds since 1970-01-01 00:00:00 UTC.
pub modified: Option<i64>, pub modified: Option<i64>,
#[serde(default)] #[serde(default)]
pub created: Option<i64>, pub created: Option<i64>,
#[serde(default)] #[serde(default)]
pub accessed: Option<i64>, 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)] #[serde(default)]
pub taken: Option<i64>, pub taken: Option<i64>,
#[serde(default)] #[serde(default)]
pub camera: Option<String>, pub camera: Option<String>,
/// Largura × altura em pixels, quando conhecidas. /// Image/video dimensions `(width, height)` in pixels, when known.
#[serde(default)] #[serde(default)]
pub px: Option<(u32, u32)>, pub px: Option<(u32, u32)>,
/// Há coordenadas GPS nos metadados. /// Whether EXIF GPS coordinates are present.
#[serde(default)] #[serde(default)]
pub gps: bool, 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)] #[serde(default)]
pub sig: Option<u64>, pub sig: Option<u64>,
pub note: Option<String>, pub note: Option<String>,
} }
impl Found { impl Found {
/// Data mais representativa: quando foi tirada > modificada > criada. /// Best representative timestamp: `taken > modified > created`.
pub fn when(&self) -> Option<i64> { pub fn when(&self) -> Option<i64> {
self.taken.or(self.modified).or(self.created) self.taken.or(self.modified).or(self.created)
} }
@@ -362,11 +362,11 @@ pub enum FsKind {
Refs, Refs,
Bitlocker, Bitlocker,
Luks, Luks,
/// FileVault 2 (Core Storage). /// Apple FileVault 2 (Core Storage).
Corestorage, Corestorage,
/// Membro de volume LVM (physical volume). /// Linux LVM2 Physical Volume (PV).
Lvm, Lvm,
/// Membro de RAID por software (mdadm). /// Linux `mdadm` software RAID member.
Raid, Raid,
Unknown, 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> { pub fn encrypted(self) -> Option<&'static str> {
match self { match self {
FsKind::Bitlocker => Some("BitLocker"), 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 { pub fn has_metadata_scanner(self) -> bool {
matches!( matches!(
self, self,
@@ -429,10 +429,10 @@ pub struct PartInfo {
pub len: u64, pub len: u64,
pub fs: FsKind, pub fs: FsKind,
pub label: Option<String>, 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 lost: bool,
pub encrypted: Option<String>, 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>, pub table_type: Option<String>,
} }
@@ -445,10 +445,10 @@ pub struct Gap {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiskLayout { pub struct DiskLayout {
pub size: u64, pub size: u64,
/// "gpt", "mbr" ou "none". /// Partition table scheme: `"gpt"`, `"mbr"`, or `"none"`.
pub table: String, pub table: String,
pub partitions: Vec<PartInfo>, pub partitions: Vec<PartInfo>,
/// Espaço não particionado relevante (> 1 MiB). /// Significant unpartitioned byte gaps (`> 1 MiB`).
pub gaps: Vec<Gap>, pub gaps: Vec<Gap>,
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -456,37 +456,37 @@ pub struct DiskLayout {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ScanMode { pub enum ScanMode {
/// Só metadados dos sistemas de arquivos (segundos). /// Fast scan: filesystem metadata readers only (completes in seconds).
Quick, Quick,
/// Metadados + carving por assinatura em todo espaço livre/desconhecido. /// Deep scan: filesystem metadata plus signature carving across free/unallocated space.
Deep, Deep,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions { pub struct ScanOptions {
pub mode: ScanMode, 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)] #[serde(default)]
pub include_allocated: bool, pub include_allocated: bool,
/// Limita o carving a estas categorias (vazio = todas). /// Restrict signature carving to these categories (empty = all categories).
#[serde(default)] #[serde(default)]
pub categories: Vec<Category>, 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")] #[serde(default = "default_max_carve")]
pub max_carve_size: u64, 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)] #[serde(default)]
pub assembly: crate::assemble::Assembly, 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)] #[serde(default)]
pub extra_parts: Vec<PartInfo>, 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)] #[serde(default)]
pub covered: Vec<u64>, 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)] #[serde(default)]
pub skip_primary: bool, 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)] #[serde(default)]
pub references: Vec<String>, 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, //! DMG (Apple UDIF): parses the `koly` trailer, plist `mish` block tables, and zero-fill, raw,
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem //! ADC, zlib, bzip2, and LZFSE (`ULFO`) chunks. Encrypted DMG images (`encrcdsa`) are rejected
//! clara. //! with a descriptive error.
use super::{bad, be32, be64, read_exact, read_vec, Backend}; use super::{bad, be32, be64, read_exact, read_vec, Backend};
use crate::source::{FileSource, Source}; use crate::source::{FileSource, Source};
@@ -21,7 +21,7 @@ pub(super) fn is_dmg(file: &FileSource) -> bool {
&& be32(&t, 8) == 512 && 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 { pub(super) fn is_encrypted(head: &[u8]) -> bool {
head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr") head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr")
} }
@@ -65,7 +65,7 @@ fn base64(s: &str) -> Vec<u8> {
out out
} }
/// Descompacta ADC (Apple Data Compression). /// Decompresses an Apple Data Compression (ADC) chunk.
fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> { fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max); let mut out: Vec<u8> = Vec::with_capacity(max);
let mut i = 0; let mut i = 0;
@@ -210,7 +210,7 @@ impl Backend for Dmg {
return Ok(n); return Ok(n);
}; };
if voff < c.start { 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); let n = buf.len().min((c.start - voff) as usize);
buf[..n].fill(0); buf[..n].fill(0);
return Ok(n); 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 dmg;
mod qcow2; 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()) 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 /// Reads within a single format block/cluster; returns the number of bytes filled (may be
/// menos que `buf.len()`, nunca zero antes do fim do disco). /// less than `buf.len()`, but never `0` before the logical end of disk).
pub(crate) trait Backend: Send + Sync { pub(crate) trait Backend: Send + Sync {
fn size(&self) -> u64; fn size(&self) -> u64;
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize>; 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 { pub struct VDisk {
inner: Box<dyn Backend>, inner: Box<dyn Backend>,
format: &'static str, format: &'static str,
} }
impl VDisk { impl VDisk {
/// Nome do formato (`QCOW2`, `VMDK`, `VHD`, `VHDX`, `DMG`). /// Format label (`"QCOW2"`, `"VMDK"`, `"VHD"`, `"VHDX"`, `"DMG"`).
pub fn format(&self) -> &'static str { pub fn format(&self) -> &'static str {
self.format self.format
} }
@@ -88,12 +88,12 @@ pub(crate) fn io_err(e: io::Error, what: &str) -> Error {
Error::msg(format!("{what}: {e}")) Error::msg(format!("{what}: {e}"))
} }
/// Cadeias de arquivos pai (VHD/VHDX diferenciais) não passam deste limite, o que também /// Maximum parent/backing chain depth (prevents infinite loops on cyclic parent references).
/// corta ciclos. ///
const MAX_DEPTH: u32 = 16; const MAX_DEPTH: u32 = 16;
/// Formato de contêiner reconhecido pelos primeiros bytes (e, para VHD fixo e DMG, pelo /// Identifies a virtual disk container format from its leading bytes (and trailing footer
/// fim do arquivo). `None` quando é um disco bruto. /// for fixed VHD and DMG). Returns `None` for raw disk images.
pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> { pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
if head.starts_with(b"QFI\xfb") { if head.starts_with(b"QFI\xfb") {
return Some("QCOW2"); return Some("QCOW2");
@@ -113,7 +113,7 @@ pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
None 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>> { pub fn open(path: &Path) -> Result<Option<VDisk>> {
open_depth(path, 0) 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 })) Ok(Some(VDisk { inner, format }))
} }
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o /// Opens a parent/backing image at `depth`. When `raw` is `true`, treats the backing file
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato. /// directly as a raw image without probing for a container header.
pub(crate) fn open_parent( pub(crate) fn open_parent(
candidates: &[std::path::PathBuf], candidates: &[std::path::PathBuf],
depth: u32, 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<()> { pub(crate) fn read_exact(f: &FileSource, off: u64, buf: &mut [u8]) -> io::Result<()> {
crate::source::read_exact_at(f, off, buf) 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) 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> { pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Result<usize> {
const MAX: u64 = 512 * 1024 * 1024; const MAX: u64 = 512 * 1024 * 1024;
if entries.saturating_mul(entry_size) > MAX { 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) 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 { pub(crate) fn utf16(b: &[u8], big: bool) -> String {
let units: Vec<u16> = b let units: Vec<u16> = b
.chunks_exact(2) .chunks_exact(2)
@@ -200,8 +200,8 @@ pub(crate) fn utf16(b: &[u8], big: bool) -> String {
String::from_utf16_lossy(&units) String::from_utf16_lossy(&units)
} }
/// Caminhos onde procurar o disco pai: o caminho gravado (relativo ao arquivo filho, com `\` /// Candidate paths for locating a parent/backing disk: relative to the child image's directory
/// virando `/`) e, como alternativa, só o nome do arquivo na mesma pasta. /// (normalizing `\` to `/`), falling back to the basename in the same directory.
pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> { pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> {
let mut out: Vec<std::path::PathBuf> = vec![]; let mut out: Vec<std::path::PathBuf> = vec![];
for n in names { for n in names {
@@ -228,7 +228,7 @@ pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::Pa
out 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( pub(crate) fn read_parent(
parent: &Option<Box<dyn Source>>, parent: &Option<Box<dyn Source>>,
voff: u64, 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), //! QCOW2 (QEMU) v2 and v3: L1/L2 tables, compressed clusters (deflate and zstd),
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai. //! zero-flagged clusters, and backing files (unallocated child clusters fall through to parent).
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um //! Encrypted images, external data files, extended L2 subclusters, and non-local backing URIs
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas. //! (`json:`, `nbd:`, URLs) are rejected.
use super::{ use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec, 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>, l1: Vec<u64>,
zstd: bool, zstd: bool,
parent: Option<Box<dyn Source>>, 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>)>>, 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` /// Opens the backing file. Its format comes from header extension `0xE2792ACA`; `"raw"`
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz. /// makes the backing file open directly as raw bytes without probing.
fn open_backing( fn open_backing(
file: &FileSource, file: &FileSource,
h: &[u8], 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" "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 { let hlen = if version == 3 && h.len() >= 104 {
be32(h, 100) as u64 be32(h, 100) as u64
} else { } else {
@@ -201,7 +201,7 @@ impl Backend for Qcow2 {
} }
let off = entry & OFFSET_MASK; let off = entry & OFFSET_MASK;
if entry & 1 != 0 { 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 { } else if off == 0 {
read_parent(&self.parent, voff, &mut buf[..n])?; read_parent(&self.parent, voff, &mut buf[..n])?;
} else { } else {
+4 -4
View File
@@ -1,6 +1,6 @@
//! VHD (Microsoft Virtual PC e Hyper-V): discos fixos, dinâmicos e diferenciais. O disco //! VHD (Microsoft Virtual PC and Hyper-V): fixed, dynamic, and differencing disks.
//! diferencial lê o pai (achado pelos localizadores do cabeçalho) nos setores que a camada //! Differencing disks resolve parent locators (`W2ku`/`W2ru`) and fall back to the parent
//! filha não preencheu. //! image for sectors not present in the child block bitmap.
use super::{ use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec, 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) !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 { pub(super) fn is_fixed(file: &FileSource) -> bool {
let size = file.size(); let size = file.size();
if size < 2 * SECTOR { 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, //! VHDX (Hyper-V): region table, metadata table, Block Allocation Table (BAT) with interleaved
//! discos fixos, dinâmicos e diferenciais (pai achado pelo localizador de pai). //! 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::{ use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent, 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; 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] { fn guid(s: &str) -> [u8; 16] {
let hex: Vec<u8> = s let hex: Vec<u8> = s
.replace('-', "") .replace('-', "")
@@ -93,7 +93,7 @@ impl Vhdx {
} }
let chunk_ratio = (1u64 << 23) * sector_size / block_size; let chunk_ratio = (1u64 << 23) * sector_size / block_size;
let payload = size.div_ceil(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 { let want = if has_parent {
payload.div_ceil(chunk_ratio) * (chunk_ratio + 1) payload.div_ceil(chunk_ratio) * (chunk_ratio + 1)
} else { } else {
@@ -166,7 +166,7 @@ impl Backend for Vhdx {
read_exact(&self.file, off + within, &mut buf[..n])?; read_exact(&self.file, off + within, &mut buf[..n])?;
} }
7 => { 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 chunk = block / self.chunk_ratio;
let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio); let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio);
let ss = self.sector_size; let ss = self.sector_size;
+14 -14
View File
@@ -1,7 +1,7 @@
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos //! VMDK (VMware, VirtualBox): sparse extents (including `streamOptimized` with deflate-compressed
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como //! grains) and text descriptors referencing `FLAT`, `SPARSE`, and `ZERO` extents such as
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos //! `monolithicFlat` and `twoGbMaxExtentSparse`. Snapshot/delta VMDKs fall back to their parent
//! que o filho não alocou; o formato SEsparse é recusado. //! for unallocated grains; `SEsparse` is rejected.
use super::{ use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent, 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>>, 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> { fn desc_value(descriptor: &str, key: &str) -> Option<String> {
descriptor.lines().find_map(|l| { descriptor.lines().find_map(|l| {
l.trim() 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 /// Reads the VMDK text descriptor from `path` (either a standalone text file or embedded inside
/// logo depois do cabeçalho de uma extensão esparsa. /// a monolithic sparse header).
fn descriptor_of(path: &Path) -> Option<String> { fn descriptor_of(path: &Path) -> Option<String> {
let f = FileSource::open(path).ok()?; let f = FileSource::open(path).ok()?;
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).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()) .then(|| String::from_utf8_lossy(&head).into_owned())
} }
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só /// Parent reference parsed from a VMDK descriptor: `parentFileNameHint` and/or VirtualBox
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter. /// `ddb.uuid.parent`, plus the expected `parentCID`.
struct ParentRef { struct ParentRef {
name: Option<String>, name: Option<String>,
uuid: 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 }) (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> { fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
let mut d = Some(dir); let mut d = Some(dir);
for _ in 0..3 { 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 /// Reads within a single grain. Returns `(bytes_read, allocated_or_zeroed)`: when the `bool` is
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros. /// `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)> { fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
let g = off / self.grain; let g = off / self.grain;
let within = 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>> { fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
let mut out = vec![]; let mut out = vec![];
let mut start = 0u64; let mut start = 0u64;
@@ -290,7 +290,7 @@ impl Vmdk {
)); ));
} }
if let Some(cid) = &r.cid { 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()) { if let Some(p) = found.iter().find(|p| p.is_file()) {
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID")); let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
if have if have
+6 -6
View File
@@ -1,8 +1,8 @@
//! Disco virtual: a origem real com trechos substituídos por volumes desbloqueados, //! Virtual disk address space: overlays a base `Source` with unlocked encrypted volumes,
//! membros de RAID montados ou volumes lógicos (LVM) anexados depois do fim do disco. //! 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 //! Byte offsets remain globally addressable: scanning and recovery operate on a single unified
//! espaço de endereços, desde que a montagem seja refeita igual (a `Assembly` é determinística). //! address space as long as the deterministic `Assembly` is re-applied.
use crate::source::Source; use crate::source::Source;
use std::io; use std::io;
@@ -58,7 +58,7 @@ impl Source for Overlay {
let n = want.min((r.start + r.len - offset) as usize); let n = want.min((r.start + r.len - offset) as usize);
return r.src.read_at(offset - r.start, &mut buf[..n]); 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); let n = want.min((r.start - offset) as usize);
return self.read_base(offset, &mut buf[..n]); return self.read_base(offset, &mut buf[..n]);
} }
@@ -69,7 +69,7 @@ impl Source for Overlay {
impl Overlay { impl Overlay {
fn read_base(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> { fn read_base(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.base.size() { 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); buf.fill(0);
return Ok(buf.len()); 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::aff4::{write_aff4, Aff4Options, Aff4Source};
use guara_core::source::{open_path, read_vec, MemSource, Source}; use guara_core::source::{open_path, read_vec, MemSource, Source};
@@ -28,11 +28,11 @@ fn reads_reference_images_and_stored_hash_matches() {
Some(true), Some(true),
"{name}: hash do fluxo" "{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 s = open_path(&p).unwrap();
let head = read_vec(s.as_ref(), 0, 4096).unwrap(); let head = read_vec(s.as_ref(), 0, 4096).unwrap();
assert_eq!(head.len(), 4096); 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(); let far = read_vec(s.as_ref(), 200 << 20, 512).unwrap();
assert_eq!(far.len(), 512); assert_eq!(far.len(), 512);
eprintln!( 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; mod common;
use 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 { fn le16(b: &[u8], o: usize) -> usize {
u16::from_le_bytes([b[o], b[o + 1]]) as 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 mut d = std::fs::read(&p).unwrap();
let bs = 4096usize; let bs = 4096usize;
let blk = |d: &[u8], n: u64| d[n as usize * bs..(n as usize + 1) * bs].to_vec(); 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 (desc_blocks, desc_base) = (le32(&d, 104) as u64, le64(&d, 112));
let mut nx = d[..bs].to_vec(); let mut nx = d[..bs].to_vec();
for i in 0..desc_blocks { for i in 0..desc_blocks {
@@ -153,11 +153,11 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
nx = b; nx = b;
} }
} }
// omap do contêiner -> volume // container omap -> volume superblock (`APSB`)
let cont_omap = blk(&d, le64(&nx, 160)); let cont_omap = blk(&d, le64(&nx, 160));
let cont_tree = blk(&d, le64(&cont_omap, 48)); let cont_tree = blk(&d, le64(&cont_omap, 48));
let (_, cont_entries) = read_node(&{ 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(); let mut c = cont_tree.clone();
fixed_to_var(&mut c); fixed_to_var(&mut c);
c c
@@ -187,7 +187,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
let root = blk(&d, root_paddr); let root = blk(&d, root_paddr);
assert_eq!(le16(&root, 34), 0, "raiz deveria ser folha nesta imagem"); assert_eq!(le16(&root, 34), 0, "raiz deveria ser folha nesta imagem");
let (_, recs) = read_node(&root); let (_, recs) = read_node(&root);
// inode do arquivo // find target file inode number
let victim = b"passwords.txt"; let victim = b"passwords.txt";
let ino = recs let ino = recs
.iter() .iter()
@@ -216,12 +216,12 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
.collect(); .collect();
assert!(kept.len() < recs.len()); assert!(kept.len() < recs.len());
let newnode = write_node(&root, &kept); 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) 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) .find(|&n| d[n * bs..(n + 1) * bs].iter().all(|&x| x == 0) && n != vol_tree_paddr as usize)
.expect("bloco livre"); .expect("bloco livre");
d[free * bs..(free + 1) * bs].copy_from_slice(&newnode); 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 toc_off = le16(&vt_orig, 40);
let key_base = 56 + toc_off + le16(&vt_orig, 42); let key_base = 56 + toc_off + le16(&vt_orig, 42);
let _ = key_base; 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 dir = tempfile::tempdir().unwrap();
let p2 = dir.path().join("a.img"); let p2 = dir.path().join("a.img");
std::fs::write(&p2, &d).unwrap(); 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(); let src = FileSource::open(&p2).unwrap();
assert!(!live_paths(&src, 0).iter().any(|f| f.0 == "passwords.txt")); assert!(!live_paths(&src, 0).iter().any(|f| f.0 == "passwords.txt"));
init(); 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>) { fn fixed_to_var(b: &mut Vec<u8>) {
let flags = le16(b, 32) as u16; let flags = le16(b, 32) as u16;
if flags & 4 == 0 { 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> { fn write_node_flagsless(old: &[u8], entries: &[Entry]) -> Vec<u8> {
let mut b = write_node(old, entries); 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; let fl = le16(&b, 32) as u16;
b[32..34].copy_from_slice(&fl.to_le_bytes()); b[32..34].copy_from_slice(&fl.to_le_bytes());
b 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; mod common;
use 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); assert!(e[0].time.ends_with('Z') && e[0].time.len() == 20);
let v = log.verify().unwrap(); let v = log.verify().unwrap();
assert!(v.ok && v.entries == 4 && v.head == e[3].hash); 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()); let again = Log::at(log.path());
assert_eq!(again.append("mais", json!({})).unwrap().seq, 5); assert_eq!(again.append("mais", json!({})).unwrap().seq, 5);
assert!(again.verify().unwrap().ok); assert!(again.verify().unwrap().ok);
@@ -74,7 +74,7 @@ fn removing_or_reordering_lines_is_detected() {
tamper(&log, |l| l.swap(1, 2)); tamper(&log, |l| l.swap(1, 2));
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 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); let (_d, log) = log_with(3);
tamper(&log, |l| { tamper(&log, |l| {
let h = l[0].rsplit('"').nth(1).unwrap().to_string(); 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. //! BitLocker: real test images from the `cryptsetup` test suite (with known passwords/recovery keys).
//! Baixe com `scripts/fetch-testdata.sh`; sem elas os testes são pulados. //! Download with `scripts/fetch-testdata.sh`; tests are skipped if images are absent.
use guara_core::crypt::{bitlocker, Secret, UnlockError}; use guara_core::crypt::{bitlocker, Secret, UnlockError};
use guara_core::partition::detect_fs_in; 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) { 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 ") { if what.contains("-eow ") {
return; 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 src: Arc<dyn Source> = Arc::new(FileSource::open(&path).unwrap());
let pw = c.get("PW").cloned().unwrap_or_default(); let pw = c.get("PW").cloned().unwrap_or_default();
let rp = c.get("RP").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(), &[]) { if let Ok(u) = bitlocker::unlock(src.clone(), &[]) {
check_fs(u.source.as_ref(), &format!("{name} (chave limpa)")); check_fs(u.source.as_ref(), &format!("{name} (chave limpa)"));
eprintln!("ok {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 //! Btrfs: images created with `mkfs.btrfs --rootdir`; COW deletion simulated by writing a
//! (nova versão da folha em outro bloco, a antiga continua no disco). //! new fs-tree leaf generation to a free metadata block while leaving the old leaf on disk.
mod common; mod common;
use 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)); 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 { struct Leaf {
raw: Vec<u8>, raw: Vec<u8>,
@@ -118,7 +118,7 @@ impl Leaf {
let c = crc32c(&self.raw[32..]); let c = crc32c(&self.raw[32..]);
self.raw[0..4].copy_from_slice(&c.to_le_bytes()); 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 { fn without(&self, drop: impl Fn(u64, u8) -> bool, new_bytenr: u64) -> Leaf {
let mut kept: Vec<(usize, Vec<u8>)> = vec![]; let mut kept: Vec<(usize, Vec<u8>)> = vec![];
for i in 0..self.nritems() { for i in 0..self.nritems() {
@@ -152,7 +152,7 @@ fn superblock(img: &[u8]) -> &[u8] {
&img[0x10000..0x11000] &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)> { fn chunk_map(img: &[u8]) -> Vec<(u64, u64, u64)> {
let sb = superblock(img); let sb = superblock(img);
let n = u32::from_le_bytes(sb[160..164].try_into().unwrap()) as usize; 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 { let fl = Leaf {
raw: img[fs_leaf.0..fs_leaf.0 + nodesize].to_vec(), 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 victim = "relatorio.dat";
let mut ino = 0; let mut ino = 0;
for k in 0..fl.nritems() { for k in 0..fl.nritems() {
@@ -207,7 +207,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
} }
} }
assert!(ino > 256); 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; let mut data_bytenr = 0;
for k in 0..fl.nritems() { for k in 0..fl.nritems() {
let (obj, ty, _, _, _) = fl.item(k); 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); 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) let free_slot = (0..img.len() / nodesize)
.map(|n| n * nodesize) .map(|n| n * nodesize)
.find(|&o| { .find(|&o| {
@@ -228,11 +228,11 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
&& o < fs_leaf.0 + (8 << 20) && o < fs_leaf.0 + (8 << 20)
}) })
.expect("bloco livre"); .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 new_bytenr = to_logical(free_slot);
let newleaf = fl.without(|o, _| o == ino, new_bytenr); let newleaf = fl.without(|o, _| o == ino, new_bytenr);
img[free_slot..free_slot + nodesize].copy_from_slice(&newleaf.raw); 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 { let mut rl = Leaf {
raw: img[root_leaf.0..root_leaf.0 + nodesize].to_vec(), 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(); rl.seal();
img[root_leaf.0..root_leaf.0 + nodesize].copy_from_slice(&rl.raw); 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 { let el = Leaf {
raw: img[ext_leaf.0..ext_leaf.0 + nodesize].to_vec(), 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 rp = PathBuf::from(&rep.recovered[0].path);
let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap(); let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap();
assert!(got == i.files[0].1); 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 assert!(!c
.found .found
.iter() .iter()
@@ -300,7 +300,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
#[test] #[test]
fn lzo_literals_and_a_match() { fn lzo_literals_and_a_match() {
// 20 literais + fim // 20 literals + EOF marker
let mut s = vec![17 + 20]; let mut s = vec![17 + 20];
s.extend((0..20u8).map(|i| b'a' + i)); s.extend((0..20u8).map(|i| b'a' + i));
s.extend_from_slice(&[0x11, 0, 0]); s.extend_from_slice(&[0x11, 0, 0]);
@@ -308,7 +308,7 @@ fn lzo_literals_and_a_match() {
lzo1x_decompress(&s, 4096).unwrap(), lzo1x_decompress(&s, 4096).unwrap(),
(0..20u8).map(|i| b'a' + i).collect::<Vec<_>>() (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]; 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"); assert_eq!(lzo1x_decompress(&s, 4096).unwrap(), b"abcdabcd");
} }
@@ -334,7 +334,7 @@ fn packed_extents_decode_zlib_and_zstd() {
codec: PackedCodec::Zlib, codec: PackedCodec::Zlib,
}; };
assert_eq!(p.read(&MemSource(disk)).unwrap(), &plain[1000..6000]); 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") { if have("zstd") {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("p.bin"); 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() { pub fn init() {
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
let d = DIR.get_or_init(|| tempfile::tempdir().unwrap()); let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
@@ -69,7 +69,7 @@ pub fn sha256(b: &[u8]) -> String {
.collect() .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> { pub fn fixtures() -> Option<PathBuf> {
static ONCE: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new(); static ONCE: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
ONCE.get_or_init(fixtures_inner).clone() 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(); let mut bb = b.clone();
pad(&mut bb); pad(&mut bb);
disk.extend_from_slice(&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 a2_off = disk.len() as u64;
let mut rest = a[split..].to_vec(); let mut rest = a[split..].to_vec();
pad(&mut rest); pad(&mut rest);
@@ -76,7 +76,7 @@ fn rebuilds_a_jpeg_split_around_another_one() {
let mut idx = ClusterIndex::new(disk.len() as u64); let mut idx = ClusterIndex::new(disk.len() as u64);
idx.scan_chunk(0, &disk); 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)); assert!(idx.get(a2_off));
eprintln!("TRUE a2_off={a2_off}"); eprintln!("TRUE a2_off={a2_off}");
let pool = Pool { let pool = Pool {
@@ -97,7 +97,7 @@ fn rebuilds_a_jpeg_split_around_another_one() {
} }
other => panic!("esperava Rebuilt, veio {other:?}"), other => panic!("esperava Rebuilt, veio {other:?}"),
} }
// O outro JPEG, íntegro, continua íntegro. // The intact decoy JPEG remains a single contiguous extent.
let pool2 = Pool { let pool2 = Pool {
index: &idx, index: &idx,
used: &[], used: &[],
@@ -124,7 +124,7 @@ fn rebuilds_three_fragments_among_many_decoys() {
decoys.push((disk.len() as u64, d.len() as u64)); decoys.push((disk.len() as u64, d.len() as u64));
disk.extend_from_slice(&d); disk.extend_from_slice(&d);
if k == 1 { if k == 1 {
// segundo fragmento de A no meio // second fragment of image A placed in the middle
let off2 = disk.len() as u64; let off2 = disk.len() as u64;
disk.extend_from_slice(&a[s1..s2]); disk.extend_from_slice(&a[s1..s2]);
decoys.push((off2, (s2 - s1) as u64)); decoys.push((off2, (s2 - s1) as u64));
@@ -142,7 +142,7 @@ fn rebuilds_three_fragments_among_many_decoys() {
let used: Vec<Extent> = decoys let used: Vec<Extent> = decoys
.iter() .iter()
.enumerate() .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)) .map(|(_, (o, l))| Extent::new(*o, *l))
.collect(); .collect();
let pool = Pool { 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; mod common;
use common::*; use common::*;
@@ -39,7 +39,7 @@ fn png(extra: usize) -> Vec<u8> {
v 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] #[test]
fn particao_mbr_e_espaco_nao_particionado() { fn particao_mbr_e_espaco_nao_particionado() {
let Some(dir) = fixtures() else { return }; let Some(dir) = fixtures() else { return };
@@ -66,14 +66,14 @@ fn particao_mbr_e_espaco_nao_particionado() {
"gap: {:?}", "gap: {:?}",
layout.gaps 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 let fat_found: Vec<_> = res
.found .found
.iter() .iter()
.filter(|f| f.origin == Origin::Fat) .filter(|f| f.origin == Origin::Fat)
.collect(); .collect();
assert_eq!(fat_found.len(), 7); 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 let carved: Vec<_> = res
.found .found
.iter() .iter()
@@ -117,11 +117,11 @@ fn particao_perdida_e_encontrada_sem_tabela() {
fn volume_cifrado_e_detectado_e_nao_varrido() { fn volume_cifrado_e_detectado_e_nao_varrido() {
let mut disk = vec![0u8; 8 * MIB as usize]; let mut disk = vec![0u8; 8 * MIB as usize];
disk[..512].copy_from_slice(&mbr(&[(0x07, MIB, 4 * MIB), (0x83, 5 * MIB, 2 * MIB)])); 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-"); disk[MIB as usize + 3..MIB as usize + 11].copy_from_slice(b"-FVE-FS-");
let l = 5 * MIB as usize; let l = 5 * MIB as usize;
disk[l..l + 6].copy_from_slice(b"LUKS\xBA\xBE"); 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 p = png(10_000);
let at = (2 * MIB) as usize; let at = (2 * MIB) as usize;
disk[at..at + p.len()].copy_from_slice(&p); disk[at..at + p.len()].copy_from_slice(&p);
@@ -141,7 +141,7 @@ fn volume_cifrado_e_detectado_e_nao_varrido() {
#[test] #[test]
fn gpt_e_lido() { 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]; let mut disk = vec![0u8; 8 * MIB as usize];
disk[..512].copy_from_slice(&mbr(&[(0xEE, 512, 8 * MIB - 512)])); disk[..512].copy_from_slice(&mbr(&[(0xEE, 512, 8 * MIB - 512)]));
let mut h = vec![0u8; 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!(rep.bad_ranges.is_empty());
assert_eq!(rep.sha256, sha256_file(&src_path)); assert_eq!(rep.sha256, sha256_file(&src_path));
assert_eq!(sha256_file(&dest), 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()); 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 path = dir.join("ext4.img");
let full = scan_path(&path, &ScanOptions::default()); let full = scan_path(&path, &ScanOptions::default());
let src = source::open_path(&path).unwrap(); 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 n = std::sync::atomic::AtomicUsize::new(0);
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 2; let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 2;
let mut sink = Collect::default(); let mut sink = Collect::default();
@@ -233,7 +233,7 @@ fn pausa_e_retoma_encontra_o_mesmo() {
assert_eq!(a, b); 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] #[test]
fn file_slice_com_range_bate_com_o_arquivo_recuperado() { fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
init(); init();
@@ -302,7 +302,7 @@ fn configuracoes_de_tema_persistem() {
settings::load().updates, settings::load().updates,
"a verificação de atualização vem ligada" "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( std::fs::write(
settings::config_dir().join("settings.json"), settings::config_dir().join("settings.json"),
r#"{"theme":"dark"}"#, r#"{"theme":"dark"}"#,
@@ -328,8 +328,8 @@ fn wait_done(eng: &service::Engine, job: &str) {
panic!("job não terminou"); 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 /// Strips post-v0.1.0 fields from a real scan session JSON to simulate a legacy v0.1.0 session file.
/// varredura real da imagem, com o alvo informado. ///
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile { fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
init(); init();
let eng = service::Engine::new(); let eng = service::Engine::new();
@@ -358,7 +358,7 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
tmp 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] #[test]
fn sessao_da_v0_1_0_abre_e_funciona() { fn sessao_da_v0_1_0_abre_e_funciona() {
init(); init();
@@ -374,7 +374,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
assert_eq!(st["source"], true); assert_eq!(st["source"], true);
let res = eng.results(&job, &service::Query::default()).unwrap(); let res = eng.results(&job, &service::Query::default()).unwrap();
assert_eq!(res["total"], 7); 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 out = tempfile::tempdir().unwrap();
let rj = eng let rj = eng
.start_recover( .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] #[test]
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() { fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
init(); init();
let Some(dir) = fixtures() else { return }; let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img"); let img = dir.join("ntfs.img");
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe"); 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 = let mut v: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap(); serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
v["done"] = serde_json::json!(false); v["done"] = serde_json::json!(false);
@@ -413,7 +413,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.as_str() .as_str()
.unwrap() .unwrap()
.contains("não está disponível")); .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!( assert_eq!(
eng.results(&job, &service::Query::default()).unwrap()["total"], eng.results(&job, &service::Query::default()).unwrap()["total"],
7 7
@@ -424,7 +424,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.unwrap(), .unwrap(),
7 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_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
assert!(eng assert!(eng
.resume(&job) .resume(&job)
@@ -442,7 +442,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
) )
.unwrap_err(); .unwrap_err();
assert!(e.to_string().contains("não está disponível"), "{e}"); 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(); eng.relink(&job, img.to_str().unwrap(), false).unwrap();
let st = eng.status(&job).unwrap(); let st = eng.status(&job).unwrap();
assert_eq!(st["source"], true); assert_eq!(st["source"], true);
@@ -458,7 +458,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.unwrap(); .unwrap();
wait_done(&eng, &rj); wait_done(&eng, &rj);
assert_eq!(hashes_in(out.path()).len(), 7); 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(); std::fs::write(tmp.path(), b"nao e json").unwrap();
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err(); let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
assert!( 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] #[test]
fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() { 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 tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
let eng = service::Engine::new(); let eng = service::Engine::new();
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap(); 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(); eng.relink(&job, img.to_str().unwrap(), false).unwrap();
let work = tempfile::tempdir().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"); let clone = work.path().join("clone.img");
std::fs::copy(&img, &clone).unwrap(); std::fs::copy(&img, &clone).unwrap();
eng.relink(&job, clone.to_str().unwrap(), false).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 outro = work.path().join("outro.img");
let mut bytes = std::fs::read(&img).unwrap(); let mut bytes = std::fs::read(&img).unwrap();
bytes[0..16].copy_from_slice(b"OUTRO DISCO....!"); 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(); .unwrap_err();
assert!(e.to_string().contains("conteúdo inicial"), "{e}"); assert!(e.to_string().contains("conteúdo inicial"), "{e}");
eng.relink(&job, outro.to_str().unwrap(), true).unwrap(); 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"); let menor = work.path().join("menor.img");
std::fs::write(&menor, &bytes[..bytes.len() / 2]).unwrap(); std::fs::write(&menor, &bytes[..bytes.len() / 2]).unwrap();
let e = eng let e = eng
@@ -503,7 +503,7 @@ fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
assert!(e.to_string().contains("bytes"), "{e}"); 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 { struct Flaky {
inner: MemSource, inner: MemSource,
broken_from: u64, broken_from: u64,
@@ -541,8 +541,8 @@ fn varredura_pausa_quando_o_disco_some_e_retoma_do_ponto_certo() {
v v
}; };
let (a, b) = (png(3000), png(4000)); let (a, b) = (png(3000), png(4000));
disk[2 * mib..2 * mib + a.len()].copy_from_slice(&a); // antes 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); // depois da falha disk[50 * mib..50 * mib + b.len()].copy_from_slice(&b); // PNG after the disconnect point
let flaky = Flaky { let flaky = Flaky {
inner: MemSource(disk.clone()), inner: MemSource(disk.clone()),
broken_from: 16 * mib as u64, 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!(cp.io_lost, "deveria marcar perda de E/S");
assert_eq!(sink.found.len(), 1, "só o PNG anterior à falha"); 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); 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 healthy = MemSource(disk);
let before = sink.found.clone(); let before = sink.found.clone();
let out = scan::run(&healthy, &opts, Some(cp), &before, &|| false, &mut sink); 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()) .start_scan(img.to_str().unwrap(), ScanOptions::default())
.unwrap(); .unwrap();
wait_done(&eng, &job); 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; let mut item = None;
for _ in 0..200 { for _ in 0..200 {
let list = eng.sessions_list(); let list = eng.sessions_list();
@@ -596,7 +596,7 @@ fn varredura_salva_sessao_sozinha_e_ela_reabre() {
assert_eq!(item["found"], 7); assert_eq!(item["found"], 7);
let path = item["path"].as_str().unwrap().to_string(); let path = item["path"].as_str().unwrap().to_string();
assert!(std::path::Path::new(&path).exists()); 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 job2 = eng.load_session(&path).unwrap();
let st = eng.status(&job2).unwrap(); let st = eng.status(&job2).unwrap();
assert_eq!( assert_eq!(
@@ -607,13 +607,13 @@ fn varredura_salva_sessao_sozinha_e_ela_reabre() {
), ),
("done", 7, true) ("done", 7, true)
); );
// apagar // deleting the autosaved session removes it from the list
eng.session_delete(&path).unwrap(); eng.session_delete(&path).unwrap();
assert!(!std::path::Path::new(&path).exists()); assert!(!std::path::Path::new(&path).exists());
assert!(eng.session_delete("/etc/passwd").is_err()); assert!(eng.session_delete("/etc/passwd").is_err());
} }
// ------------------------------------------------------------------ organização e espaço // ------------------------------------------------------------------ output organization and free-space check
#[test] #[test]
fn recuperar_organiza_por_tipo_data_ou_tudo_junto() { 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())); 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(); let out = tempfile::tempdir().unwrap();
recover::recover_with( recover::recover_with(
src.as_ref(), src.as_ref(),
@@ -699,7 +699,7 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
let Some(dir) = fixtures() else { return }; let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img"); let img = dir.join("ntfs.img");
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap()); 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 = let mut v: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap(); serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
v["found"][0]["size"] = serde_json::json!(1u64 << 60); 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; mod common;
use common::*; use common::*;
@@ -150,7 +150,7 @@ fn deleted_file_inside_luks2_is_recovered_with_password() {
.success()); .success());
let base: Arc<dyn Source> = Arc::new(FileSource::open(&img).unwrap()); 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(); let locked = assemble_source(base.clone(), &Assembly::default()).unwrap();
assert_eq!(locked.unlocked.len(), 0); assert_eq!(locked.unlocked.len(), 0);
assert_eq!(locked.locked.len(), 1); 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; mod common;
use common::*; use common::*;
@@ -92,7 +92,7 @@ fn deleted_file_on_degraded_raid5() {
let logical = std::fs::read(&plain).unwrap(); let logical = std::fs::read(&plain).unwrap();
// RAID 5 left-symmetric // 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]; let mut disks = vec![vec![0u8; data_off as usize * 512 + rows * CHUNK]; n];
for row in 0..rows { for row in 0..rows {
let pd = n - 1 - row % n; let pd = n - 1 - row % n;
@@ -110,7 +110,7 @@ fn deleted_file_on_degraded_raid5() {
let mut paths = vec![]; let mut paths = vec![];
for (i, d) in disks.iter_mut().enumerate() { for (i, d) in disks.iter_mut().enumerate() {
if i == 2 { 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); let sb = md_super(n as u32, i as u32, data_off, (rows * CHUNK / 512) as u64);
d[4096..8192].copy_from_slice(&sb); 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); 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![]; let mut paths = vec![];
for (j, &i) in order.iter().enumerate() { for (j, &i) in order.iter().enumerate() {
let p = dir.path().join(format!("m{j}.img")); 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}"); assert_eq!(s.size(), want.len() as u64, "{name}");
let got = read_vec(s.as_ref(), 0, want.len()).unwrap(); let got = read_vec(s.as_ref(), 0, want.len()).unwrap();
assert!(got == want, "{name}: conteúdo difere"); 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(); let tail = read_vec(s.as_ref(), want.len() as u64 - 5000, 9000).unwrap();
assert_eq!(tail, &want[want.len() - 5000..], "{name}"); 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); 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] #[test]
#[ignore] #[ignore]
fn emit_for_external_verification() { 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; mod common;
use 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 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 = u32::from_le_bytes(d[96..100].try_into().unwrap()) as usize;
let root_off = heap + (root - 2) * cs; 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; let mut slot = root_off;
while d[slot] != 0 { while d[slot] != 0 {
slot += 32; slot += 32;
@@ -43,15 +43,15 @@ fn exfat_apagado_e_recuperado_com_nome() {
d[data_off..data_off + content.len()].copy_from_slice(&content); d[data_off..data_off + content.len()].copy_from_slice(&content);
let name = "relatório final.txt"; let name = "relatório final.txt";
let units: Vec<u16> = name.encode_utf16().collect(); 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); let n_name = units.len().div_ceil(15);
d[slot] = 0x05; d[slot] = 0x05;
d[slot + 1] = (1 + n_name) as u8; 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 d[slot + 14..slot + 16].copy_from_slice(&((44u16 << 9) | (6 << 5) | 15).to_le_bytes()); // 2024-06-15
let st = slot + 32; let st = slot + 32;
d[st] = 0x40; 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 + 3] = units.len() as u8;
d[st + 20..st + 24].copy_from_slice(&first_cluster.to_le_bytes()); d[st + 20..st + 24].copy_from_slice(&first_cluster.to_le_bytes());
d[st + 24..st + 32].copy_from_slice(&(content.len() as u64).to_le_bytes()); d[st + 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; mod common;
use common::*; use common::*;
@@ -152,7 +152,7 @@ fn deleted_by_debugfs_keeps_extents_and_name() {
); );
assert!(recovered(&i.img, f) == *data, "{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 assert!(!c
.found .found
.iter() .iter()
@@ -161,7 +161,7 @@ fn deleted_by_debugfs_keeps_extents_and_name() {
#[test] #[test]
fn truncated_inode_recovered_by_remnant_extents() { 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 { let Some(i) = build(&["-t", "ext4"]) else {
return; return;
}; };
@@ -181,7 +181,7 @@ fn truncated_inode_recovered_by_remnant_extents() {
.find(|f| f.name == "contrato.dat") .find(|f| f.name == "contrato.dat")
.expect("contrato.dat"); .expect("contrato.dat");
assert_eq!(f.origin, Origin::Ext); 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); assert_eq!(f.size, 12288);
let got = recovered(&i.img, f); let got = recovered(&i.img, f);
assert_eq!(&got[..9000], &i.files[1].1[..]); 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 { let Some(i) = build(&["-t", "ext4", "-O", "has_journal"]) else {
return; 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 ino = inode_of(&i.img, "/fotos/viagem.dat");
let o = debugfs(&i.img, &["stats"]); let o = debugfs(&i.img, &["stats"]);
let _ = o; let _ = o;
@@ -234,7 +234,7 @@ fn journal_copy_restores_exact_size() {
eprintln!("SKIP: debugfs sem suporte a escrita no diário: {j}"); eprintln!("SKIP: debugfs sem suporte a escrita no diário: {j}");
return; return;
} }
// Exclui e zera como o kernel faria. // Delete the file and wipe its inode table slot (leaving only the journal copy).
debugfs( debugfs(
&i.img, &i.img,
&[ &[
@@ -260,8 +260,8 @@ fn journal_copy_restores_exact_size() {
#[test] #[test]
fn ext2_blockmap_deleted_is_not_invented() { fn ext2_blockmap_deleted_is_not_invented() {
// ext2 zera os ponteiros ao apagar; sem diário não há o que recuperar por metadados, // Linux ext2 zeroes block pointers on unlink; without a journal, metadata recovery finds nothing,
// mas a varredura não pode falhar nem listar o arquivo que continua existindo. // but the scan must succeed cleanly without reporting live files as deleted.
let Some(i) = build(&["-t", "ext2"]) else { let Some(i) = build(&["-t", "ext2"]) else {
return; 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; mod common;
use common::*; use common::*;
use guara_core::*; use guara_core::*;
use serde_json::json; use serde_json::json;
// ------------------------------------------------------------------ saúde (SMART) // ------------------------------------------------------------------ disk health (SMART)
#[test] #[test]
fn smart_reprovado_e_setores_ruins_viram_alerta_serio() { 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"); assert_eq!(h.status, "unknown");
} }
// ------------------------------------------------------------------ configurações // ------------------------------------------------------------------ settings
#[test] #[test]
fn configuracoes_antigas_ganham_os_valores_padrao_novos() { fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
init(); 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::create_dir_all(settings::config_dir()).unwrap();
std::fs::write( std::fs::write(
settings::config_dir().join("settings.json"), 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")] #[cfg(target_os = "linux")]
#[test] #[test]
@@ -195,7 +195,7 @@ fn notificacao_chama_o_notify_send() {
); );
} }
// ------------------------------------------------------------------ retomar recuperação // ------------------------------------------------------------------ resumable recovery
#[test] #[test]
fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() { 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 out = tempfile::tempdir().unwrap();
let key = "disco-de-teste"; 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 n = std::sync::atomic::AtomicUsize::new(0);
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3; let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3;
let r1 = recover::recover_resumable( 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(); let journal = std::fs::read_to_string(out.path().join(recover::JOURNAL)).unwrap();
assert_eq!(journal.lines().count(), 3); 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( let r2 = recover::recover_resumable(
src.as_ref(), src.as_ref(),
&files, &files,
@@ -259,7 +259,7 @@ fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
7 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); let victim = out.path().join(&r2.recovered[0].path);
std::fs::write(&victim, b"lixo").unwrap(); std::fs::write(&victim, b"lixo").unwrap();
let r3 = recover::recover_resumable( 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" "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( let r4 = recover::recover_resumable(
src.as_ref(), src.as_ref(),
&files, &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::crypt::{filevault, Secret, UnlockError};
use guara_core::hashing::MultiHasher; use guara_core::hashing::MultiHasher;
+23 -23
View File
@@ -1,5 +1,5 @@
//! Recursos periciais: hashes, metadados (EXIF/PDF/Office/MP4), miniaturas, //! Forensic features: multi-hash, metadata extraction (EXIF/PDF/Office/MP4), thumbnails,
//! busca de texto, exportações e navegação (facetas/duplicadas). //! full-disk text search, CSV/HTML exports, and explorer facets/deduplication.
use guara_core::explorer::{self, Query}; use guara_core::explorer::{self, Query};
use guara_core::hashing::hash_bytes; 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> { 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); let mut px = Vec::with_capacity(w * h * 3);
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
@@ -143,7 +143,7 @@ fn exif_camera_data_e_gps() {
let m = meta::extract(&MemSource(vec![]), &f); let m = meta::extract(&MemSource(vec![]), &f);
assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D")); assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D"));
assert_eq!(m.px, Some((64, 48))); 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)); assert_eq!(m.taken, Some(1_689_358_925));
let (lat, lon) = m.gps.unwrap(); let (lat, lon) = m.gps.unwrap();
assert!((lat + 23.5550).abs() < 0.001, "{lat}"); assert!((lat + 23.5550).abs() < 0.001, "{lat}");
@@ -157,14 +157,14 @@ fn exif_camera_data_e_gps() {
#[test] #[test]
fn miniatura_respeita_orientacao_e_tamanho() { fn miniatura_respeita_orientacao_e_tamanho() {
let src = MemSource(vec![]); 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 f = resident("foto.jpg", jpeg_with_exif(6, 64, 48));
let t = thumb::thumbnail(&src, &f, 32).expect("miniatura"); let t = thumb::thumbnail(&src, &f, 32).expect("miniatura");
let mut dec = jpeg_decoder::Decoder::new(&t[..]); let mut dec = jpeg_decoder::Decoder::new(&t[..]);
dec.decode().unwrap(); dec.decode().unwrap();
let i = dec.info().unwrap(); let i = dec.info().unwrap();
assert_eq!((i.width, i.height), (24, 32)); assert_eq!((i.width, i.height), (24, 32));
// PNG também // PNG thumbnail generation
let mut img = ::image::RgbImage::new(100, 50); let mut img = ::image::RgbImage::new(100, 50);
for p in img.pixels_mut() { for p in img.pixels_mut() {
*p = ::image::Rgb([200, 30, 30]); *p = ::image::Rgb([200, 30, 30]);
@@ -179,7 +179,7 @@ fn miniatura_respeita_orientacao_e_tamanho() {
(dec.info().unwrap().width, dec.info().unwrap().height), (dec.info().unwrap().width, dec.info().unwrap().height),
(40, 20) (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()); 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")); assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
} }
// ------------------------------------------------------------------ busca de texto // ------------------------------------------------------------------ text search
#[test] #[test]
fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() { 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()) .flat_map(|c| c.to_le_bytes())
.collect(); .collect();
put(&mut disk, 3 * mb + 6, &u16s); 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"); put(&mut disk, 8 * mb - 4, b"SEGREDO-123");
let opts = search::SearchOpts { let opts = search::SearchOpts {
terms: vec!["segredo-123".into()], 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].offset, 3 * mb as u64 + 6);
assert_eq!(hits[1].encoding, "UTF-16"); assert_eq!(hits[1].encoding, "UTF-16");
assert_eq!(hits[2].offset, 8 * mb as u64 - 4); 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 { let opts = search::SearchOpts {
terms: vec!["Segredo-123".into()], terms: vec!["Segredo-123".into()],
ignore_case: false, ignore_case: false,
@@ -381,7 +381,7 @@ fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
assert_eq!(n, 1); assert_eq!(n, 1);
} }
// ------------------------------------------------------------------ exportações // ------------------------------------------------------------------ exports
#[test] #[test]
fn csv_escapa_e_neutraliza_formulas() { 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")); assert!(html.contains("somente para leitura"));
} }
// ------------------------------------------------------------------ navegação // ------------------------------------------------------------------ explorer facets & sorting
#[test] #[test]
fn facetas_filtros_e_duplicadas() { fn facetas_filtros_e_duplicadas() {
@@ -473,7 +473,7 @@ fn facetas_filtros_e_duplicadas() {
Some("Canon"), Some("Canon"),
Some(8), 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), mk(4, "d.jpg", None, None, None, None),
]; ];
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() }; 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["undated"], 1);
assert_eq!(f["carved"], 1); assert_eq!(f["carved"], 1);
assert_eq!(f["duplicates"], 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!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
assert_eq!(f["cameras"][0]["name"], "Canon"); assert_eq!(f["cameras"][0]["name"], "Canon");
let dcim = f["folders"] let dcim = f["folders"]
@@ -507,8 +507,8 @@ fn facetas_filtros_e_duplicadas() {
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1); assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
let mut items = q("{}").select(&found); let mut items = q("{}").select(&found);
explorer::sort_items(&mut items, Some("date")); explorer::sort_items(&mut items, Some("date"));
assert_eq!(items[0].id, 2); // mais recente primeiro assert_eq!(items[0].id, 2); // newest first
assert_eq!(items.last().unwrap().id, 4); // sem data por último assert_eq!(items.last().unwrap().id, 4); // undated files sort last
} }
// ------------------------------------------------------------------ carving // ------------------------------------------------------------------ carving
@@ -521,8 +521,8 @@ fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
c c
} }
/// Regressão: texto ASCII depois de um PNG truncado vira "tamanho" de ~1,9 GB; /// Regression test: ASCII text immediately following a truncated PNG looks like a ~1.9 GB chunk
/// o carving não pode inventar um PNG gigante. /// length; the carver must reject the bogus chunk and never emit a multi-GB PNG.
#[test] #[test]
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() { 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]; 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); 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]; 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.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
d2.resize(1 << 20, b'e'); d2.resize(1 << 20, b'e');
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none()); 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]; 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"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
ok.extend(png_chunk(b"IDAT", &[1; 100])); 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)); 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 /// `open_external` materializes the recovered file into a private temp folder and invokes
/// (aqui um `xdg-open`/`open` falso grava o que recebeu). /// the platform opener (stubbed here via a fake `xdg-open`/`open` script in `PATH`).
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
#[test] #[test]
fn abre_no_player_do_sistema() { 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