Compare commits
3
Commits
2786d28e7e
...
6328220c60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6328220c60 | ||
|
|
da212e6926 | ||
|
|
341a5a2cb5 |
No files matched your search
@@ -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 }
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
Executable
+42
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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"]}}
|
||||||
+121
-46
@@ -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 || {
|
||||||
|
eng.rpc(&method, params).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.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
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
@@ -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
@@ -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
@@ -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
@@ -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; };
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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,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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(§ors.to_le_bytes());
|
v[16..24].copy_from_slice(§ors.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(§ors_desc)?;
|
w.f.write_all(§ors_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(§ion_desc(ty, start + table_size, table_size))?;
|
w.put(§ion_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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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![];
|
||||||
|
|||||||
@@ -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)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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};
|
||||||
|
|||||||
@@ -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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(), ""));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
|||||||
@@ -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("&", "&")
|
.replace("&", "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, §or0, size);
|
let mp = read_mbr(src, §or0, 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()));
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user