3 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5 6328220c60 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
2026-10-30 20:43:02 -03:00
Lucas RayanandClaude Sonnet 5 da212e6926 docs: refresh desktop screenshots
Re-captured the explorer (light/dark, en/pt) and Settings language
screenshots from the real UI against a synthetic test image, replacing
the outdated set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:42:49 -03:00
Lucas RayanandClaude Sonnet 5 341a5a2cb5 fix(ci): stop dd from corrupting the x86_64 linuxdeploy launcher
On the aarch64 Gitea runner, tauri-bundler's prepare_tools step runs
'dd ... seek=8 conv=notrunc' on the cached linuxdeploy-x86_64 binary to
hide it from AppImage integration tools. That zeroed bytes 8-10 of the
file; our cached binary there was a bash wrapper script, so the dd wrote
into the middle of its shebang/interpreter line, corrupting it, and the
bundler's subsequent spawn failed with ENOENT ('failed to bundle
project: No such file or directory').

Replace the shell wrapper with a small native ELF launcher compiled with
cc: bytes 8-10 of an ELF header are EI_PAD padding, unused by the
loader, so the same dd leaves it executable. The launcher sets the same
environment the old wrapper did and execs the real aarch64 linuxdeploy
binary. Also cache AppRun-x86_64 explicitly so the bundler does not need
to fetch it itself mid-build.

Verified locally: compiled a minimal ELF binary, ran the same dd
command against it, confirmed it still executes (the shebang-script
equivalent does not).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:42:23 -03:00
139 changed files with 2083 additions and 1921 deletions

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 139 files, more files were not shown because too many files have changed in this diff. Show more