Compare commits
10
Commits
2786d28e7e
...
v1.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21eb4eafae | ||
|
|
5ecf40056f | ||
|
|
803346469b | ||
|
|
82895581e9 | ||
|
|
5e508bbd14 | ||
|
|
40ebf705ed | ||
|
|
76e4466652 | ||
|
|
6328220c60 | ||
|
|
da212e6926 | ||
|
|
341a5a2cb5 |
No files matched your search
@@ -1,17 +1,17 @@
|
||||
name: Relatar um problema
|
||||
description: Algo não funcionou como esperado
|
||||
name: Report a bug
|
||||
description: Something did not work as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: o-que
|
||||
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." }
|
||||
id: what-happened
|
||||
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
|
||||
validations: { required: true }
|
||||
- type: input
|
||||
id: sistema
|
||||
attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" }
|
||||
id: system
|
||||
attributes: { label: "Operating system and Guará version", placeholder: "Ubuntu 24.04 / guara 1.3.2" }
|
||||
- type: input
|
||||
id: midia
|
||||
attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" }
|
||||
id: media
|
||||
attributes: { label: "Media type and filesystem", placeholder: "32 GB USB drive, FAT32" }
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes: { label: "Saída do terminal (guara inspect / scan)", render: shell }
|
||||
attributes: { label: "Terminal output (guara inspect / scan)", render: shell }
|
||||
@@ -1,7 +1,8 @@
|
||||
## O que muda e por quê
|
||||
## What changes and why
|
||||
|
||||
## Checklist
|
||||
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos
|
||||
- [ ] `cargo test --workspace` passa
|
||||
- [ ] Nada neste PR escreve no dispositivo de origem
|
||||
- [ ] CHANGELOG atualizado (se houver mudança visível)
|
||||
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Nothing in this PR writes to the source device
|
||||
- [ ] Commits follow Conventional Commits in English and code/comments are in English
|
||||
- [ ] CHANGELOG updated (if there is a user-visible change)
|
||||
+29
-32
@@ -1,7 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI.
|
||||
# Documentation-only or local agent state changes do not trigger CI jobs.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
|
||||
@@ -15,18 +15,18 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
|
||||
# a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório
|
||||
# target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job
|
||||
# usa o seu próprio target (flags diferentes invalidariam o de outro job).
|
||||
# The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
|
||||
# cargo registry, npm/apt caches, and target directories live there so nothing
|
||||
# is re-downloaded or rebuilt from scratch on every run. Each job uses its own
|
||||
# target directory so differing RUSTFLAGS do not invalidate another job's cache.
|
||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Formatação e clippy
|
||||
runs-on: ubuntu-latest
|
||||
name: Format and clippy
|
||||
runs-on: linux-arm64
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint
|
||||
steps:
|
||||
@@ -37,65 +37,62 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cobertura da tradução da interface
|
||||
- name: UI translation coverage
|
||||
run: node scripts/check-ui-i18n.mjs
|
||||
- name: Script do latest.json do atualizador
|
||||
run: python3 scripts/test_make_latest_json.py
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64.
|
||||
# Linux-only runner (ARM64).
|
||||
test:
|
||||
name: Testes (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
name: Tests (Linux)
|
||||
runs-on: linux-arm64
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Ferramentas para imagens de teste (Linux)
|
||||
- name: Test image tools (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/guara-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
|
||||
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||
- name: Reference disk images (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /opt/hostedtoolcache/guara-testdata target
|
||||
ln -sfn /opt/hostedtoolcache/guara-testdata target/testdata
|
||||
scripts/fetch-testdata.sh
|
||||
# Monta a imagem por loop (mount -o loop), o que exige um container
|
||||
# privilegiado. O runner do Gitea roda os jobs sem privilégio de propósito
|
||||
# (o CI de repositório público executa código de terceiros), então este
|
||||
# passo pode falhar aqui; nesse caso os testes que dependem dele são
|
||||
# pulados em vez de reprovar a execução.
|
||||
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
|
||||
id: xfs
|
||||
# Mounts an image via loop device (mount -o loop), which requires a
|
||||
# privileged container. Unprivileged runners skip this step cleanly.
|
||||
- name: Real XFS deletion fixture (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: scripts/make-xfs-real-deletion.sh
|
||||
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
|
||||
run: |
|
||||
if [ ! -f target/testdata/xfs-real-delete/disk.img ] || [ ! -f target/testdata/xfs-real-delete/expected.tsv ]; then
|
||||
sudo scripts/make-xfs-real-deletion.sh
|
||||
fi
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = "Linux" ] && [ "${{ steps.xfs.outcome }}" = "success" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||
if [ "${RUNNER_OS}" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||
cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Auditoria de dependências
|
||||
runs-on: ubuntu-latest
|
||||
name: Dependency audit
|
||||
runs-on: linux-arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
|
||||
# needs nothing but the advisory database, and installing it once into the
|
||||
# persistent CARGO_HOME makes every later run skip the compile.
|
||||
# persistent CARGO_HOME makes every later run skip compilation.
|
||||
- name: cargo audit
|
||||
run: |
|
||||
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
gui:
|
||||
name: Compila o app desktop (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
name: Check desktop app (Linux)
|
||||
runs-on: linux-arm64
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui
|
||||
steps:
|
||||
@@ -104,10 +101,10 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri
|
||||
- name: Tauri system dependencies
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
- name: Generate icons
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||
env:
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
name: Release
|
||||
|
||||
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do
|
||||
# CHANGELOG.md, compila o CLI (Linux x86_64 e aarch64) e o app desktop (Linux
|
||||
# e Windows, este por compilação cruzada com cargo-xwin), e monta o
|
||||
# latest.json que o atualizador do app lê.
|
||||
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
|
||||
# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with
|
||||
# a .sha256 checksum alongside each archive, plus the desktop bundles (Linux
|
||||
# aarch64, Linux x86_64, and Windows via cargo-xwin).
|
||||
#
|
||||
# Sem macOS: o Tauri precisa do SDK e da assinatura da Apple, presos a
|
||||
# hardware Apple e a uma conta de desenvolvedor, que este runner não tem.
|
||||
#
|
||||
# Segredos de usuário necessários: REGISTRY_TOKEN (não usado aqui, fica para o
|
||||
# registro de containers de outros projetos), GUARA_TAURI_SIGNING_PRIVATE_KEY e
|
||||
# GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (assinam os pacotes para o
|
||||
# atualizador). Os nomes antigos, sem prefixo, continuam aceitos como fallback.
|
||||
# No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which
|
||||
# this Linux ARM64 runner does not have.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
@@ -25,7 +20,7 @@ env:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Cria a release
|
||||
name: Create release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -37,9 +32,9 @@ jobs:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: |
|
||||
# Uma execução repetida não recria a release.
|
||||
# Re-running the workflow does not recreate an existing release.
|
||||
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "a release $TAG já existe"; exit 0
|
||||
echo "release $TAG already exists"; exit 0
|
||||
fi
|
||||
scripts/release-notes.sh "$TAG" > notes.md
|
||||
pre=false; case "$TAG" in *-*) pre=true ;; esac
|
||||
@@ -51,54 +46,55 @@ jobs:
|
||||
cli:
|
||||
name: CLI (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-cli-${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "" }
|
||||
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu" }
|
||||
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "", runner: linux-arm64 }
|
||||
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu", runner: linux-amd64 }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Linker desta plataforma
|
||||
if: ${{ matrix.apt != '' }}
|
||||
- name: Platform cross-linker
|
||||
if: ${{ matrix.apt != '' && matrix.runner != 'linux-amd64' }}
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
|
||||
- name: Compila e empacota
|
||||
- name: Build and package
|
||||
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
|
||||
- name: Anexa à release
|
||||
- name: Upload to release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
|
||||
|
||||
gui:
|
||||
name: App desktop (${{ matrix.platform }})
|
||||
name: Desktop app (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-gui-${{ matrix.platform }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.GUARA_TAURI_SIGNING_PRIVATE_KEY || secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [linux-aarch64, linux-x86_64, windows]
|
||||
include:
|
||||
- { platform: linux-aarch64, runner: linux-arm64 }
|
||||
- { platform: linux-x86_64, runner: linux-amd64 }
|
||||
- { platform: windows, runner: linux-amd64 }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri (Linux aarch64)
|
||||
- name: Tauri dependencies (Linux aarch64)
|
||||
if: matrix.platform == 'linux-aarch64'
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Dependências do Tauri (Linux x86_64)
|
||||
- name: Tauri dependencies (Linux x86_64)
|
||||
if: matrix.platform == 'linux-x86_64'
|
||||
timeout-minutes: 25
|
||||
run: |
|
||||
@@ -118,35 +114,15 @@ jobs:
|
||||
else
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
fi
|
||||
- name: Ferramentas para compilação cruzada (Windows)
|
||||
- name: Cross-compilation tools (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
|
||||
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
|
||||
- name: Compila e empacota
|
||||
- name: Build and package
|
||||
run: scripts/build-gui.sh "${{ matrix.platform }}"
|
||||
- name: Anexa à release
|
||||
- name: Upload to release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
|
||||
|
||||
atualizador:
|
||||
name: Monta o latest.json
|
||||
needs: gui
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: latest.json
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: |
|
||||
scripts/release-notes.sh "$TAG" > notes.md
|
||||
python3 scripts/make-latest-json.py "$TAG" notes.md latest.json
|
||||
- name: Anexa à release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" latest.json
|
||||
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
|
||||
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
|
||||
set -euo pipefail
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
# Build
|
||||
# Build artifacts
|
||||
target/
|
||||
apps/gui/src-tauri/target/
|
||||
node_modules/
|
||||
@@ -7,15 +7,15 @@ node_modules/
|
||||
.DS_Store
|
||||
**/*.rs.bk
|
||||
|
||||
# Capturas e estado local do Sabiá
|
||||
# Sabiá local captures and audit log
|
||||
*.pcap
|
||||
*.cap
|
||||
.sabia-audit.log
|
||||
|
||||
# Logo copiada para a interface pelo build.rs
|
||||
# Logo copied to the UI directory by build.rs
|
||||
/apps/gui/ui/logo.png
|
||||
|
||||
# Configuração e memória local de agentes de IA — não faz parte do produto
|
||||
# Local AI agent configuration and state — not part of the product
|
||||
.claude/
|
||||
.wolf/
|
||||
.opencode/
|
||||
@@ -27,16 +27,16 @@ CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
|
||||
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Lixo local
|
||||
# Local scratch files
|
||||
*.eml
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Pacotes gerados por scripts/package-cli.sh
|
||||
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
|
||||
/guara-v*
|
||||
/sabia-v*
|
||||
/dist-gui
|
||||
@@ -4,6 +4,22 @@
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [1.3.4] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- **Desktop app: only one window on startup.** The main window used to be visible from launch, so the unprivileged instance's normal window would show, flash while the system asked for the administrator password, and only then get replaced by the elevated relaunch's window a moment later. The window now stays hidden until elevation is resolved — either already elevated, elevation failed (the window then shows the warning), or this is the already-elevated relaunched instance — so only one window ever appears, matching Sabiá.
|
||||
|
||||
## [1.3.3] - 2026-10-30
|
||||
|
||||
Unified updater architecture with Sabiá: migrated from the signed `tauri-plugin-updater` to direct Gitea Releases API checking and self-updating with SHA-256 verification.
|
||||
|
||||
### Changed
|
||||
- **New update mechanism:** The updater was rewritten to match Sabiá's model. Updates now query the Gitea Releases API directly, verify package checksums via `.sha256`, and no longer rely on `latest.json` or minisign updater keys.
|
||||
- **CLI self-update (`guara update`):** The CLI now supports `guara update` (checks for new versions) and `guara update --apply` (downloads, verifies SHA-256 checksum, and atomically replaces the binary).
|
||||
- **Desktop app notice:** The desktop app checks Gitea on startup and links directly to the new release packages.
|
||||
- **Important notice for existing installations:** Versions up to 1.3.2 used the previous signed `tauri-plugin-updater` mechanism looking for `latest.json`. Because that mechanism has been retired, existing installs will not automatically find version 1.3.3: download version 1.3.3 once manually from Gitea Releases, and future update checks will work through the new unified mechanism.
|
||||
- Removed `tauri-plugin-updater` dependency, `createUpdaterArtifacts` configuration, and the `updater` (`Build latest.json`) CI job.
|
||||
|
||||
## [1.3.2] - 2026-10-30
|
||||
|
||||
The project moved from GitHub to Gitea, and the update check followed it.
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [1.3.4] - 2026-10-31
|
||||
|
||||
### Corrigido
|
||||
- **App desktop: só uma janela na abertura.** A janela principal ficava visível desde o lançamento, então a instância sem privilégio mostrava a janela normal, piscava enquanto o sistema pedia a senha de administrador, e só depois era substituída pela janela da instância já elevada. Agora a janela fica escondida até a elevação ser resolvida — já elevada, falhou (a janela aí mostra o aviso), ou é a própria instância já relançada como administrador — então só uma janela aparece, igual ao Sabiá.
|
||||
|
||||
## [1.3.3] - 2026-10-30
|
||||
|
||||
Arquitetura de atualizador unificada com o Sabiá: migração do `tauri-plugin-updater` assinado para consulta direta à API de Releases do Gitea e autoatualização com verificação de SHA-256.
|
||||
|
||||
### Alterado
|
||||
- **Novo mecanismo de atualização:** O atualizador foi reescrito para seguir o modelo do Sabiá. As atualizações agora consultam diretamente a API de Releases do Gitea, conferem a integridade via `.sha256` e não dependem mais de `latest.json` nem de chaves minisign.
|
||||
- **Autoatualização do CLI (`guara update`):** O CLI agora conta com `guara update` (para verificar novas versões) e `guara update --apply` (que baixa, confere o SHA-256 e substitui o binário atomicamente).
|
||||
- **Aviso no app desktop:** O app desktop consulta o Gitea ao abrir e direciona para os novos pacotes da release.
|
||||
- **Aviso importante para instalações existentes:** Versões até a 1.3.2 usavam o mecanismo assinado do `tauri-plugin-updater` baseado em `latest.json`. Como esse mecanismo foi descontinuado, instalações anteriores não encontrarão a versão 1.3.3 sozinhas: baixe a versão 1.3.3 manualmente uma vez nas Releases do Gitea, e as próximas verificações funcionarão pelo novo mecanismo unificado.
|
||||
- Removidos a dependência `tauri-plugin-updater`, a configuração `createUpdaterArtifacts` e o job de CI `updater` (`Build latest.json`).
|
||||
|
||||
## [1.3.2] - 2026-10-30
|
||||
|
||||
O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto.
|
||||
|
||||
+8
-7
@@ -7,13 +7,14 @@ Thanks for helping! Golden rules:
|
||||
1. **Never write to the source.** Any code that opens a device must use `source::open_path` (read-only). PRs that write to the source disk are rejected.
|
||||
2. **Test with real data.** New detectors/readers need a test that recovers a known file bit for bit (see `crates/guara-core/tests/` and `scripts/make-fixtures.sh`).
|
||||
3. **Be honest about confidence.** If the content cannot be validated, confidence is at most medium.
|
||||
4. **100% English codebase and comments.** All source code, comments, scripts, CI workflows, and commit messages must be written in English, while the GUI/CLI remains bilingual (PT-BR and EN via `i18n-en.js`).
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
|
||||
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
|
||||
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
|
||||
scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks
|
||||
scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver
|
||||
scripts/check.sh --full # includes cargo test --workspace and cargo audit
|
||||
```
|
||||
|
||||
The CI takes 5 to 30 minutes on Linux, so run the check before pushing: almost everything that makes the CI fail shows up locally in seconds. Documentation-only changes (`*.md`, `docs/`) skip the CI.
|
||||
@@ -22,11 +23,11 @@ On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro
|
||||
|
||||
For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `scripts/fetch-testdata.sh`; without them those tests are skipped. The BitLocker test takes about 75 s.
|
||||
|
||||
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 and Versioning
|
||||
|
||||
## Releases
|
||||
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) and attach the Linux CLI (x86_64 and aarch64) and the desktop app for Linux (x86_64 and aarch64) and Windows.
|
||||
|
||||
There is no macOS build: it needs Apple's SDK and code signing, which a Linux runner cannot provide.
|
||||
|
||||
@@ -48,4 +49,4 @@ The main version is in English and every file has a Portuguese translation (`*.p
|
||||
|
||||
## Commits
|
||||
|
||||
Short messages in the imperative (`fix reading of sparse runs in NTFS`). One logical change per PR.
|
||||
Commits must follow **Conventional Commits 1.0.0** in English (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), e.g. `fix(ntfs): handle sparse data runs correctly`. One logical change per PR. Enforced automatically by `.githooks/commit-msg`.
|
||||
@@ -7,13 +7,14 @@ Obrigado por ajudar! Regras de ouro:
|
||||
1. **Nunca escreva na origem.** Qualquer código que abra um dispositivo deve usar `source::open_path` (somente leitura). PRs que escrevem no disco de origem são recusados.
|
||||
2. **Teste com dados reais.** Novos detectores/leitores precisam de teste que recupera um arquivo conhecido bit a bit (veja `crates/guara-core/tests/` e `scripts/make-fixtures.sh`).
|
||||
3. **Seja honesto sobre confiança.** Se o conteúdo não puder ser validado, a confiança é no máximo média.
|
||||
4. **Código, comentários e commits 100% em inglês.** Todo código-fonte, comentários, scripts, workflows de CI e mensagens de commit devem ser escritos em inglês, enquanto a interface gráfica/CLI permanece bilíngue (PT-BR e EN via `i18n-en.js`).
|
||||
|
||||
## Fluxo
|
||||
|
||||
```bash
|
||||
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
|
||||
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
|
||||
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
|
||||
scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push
|
||||
scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver
|
||||
scripts/check.sh --full # inclui cargo test --workspace e cargo audit
|
||||
```
|
||||
|
||||
O CI leva de 5 a 30 minutos em Linux, então rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
|
||||
@@ -22,11 +23,11 @@ No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro
|
||||
|
||||
Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), rode `scripts/fetch-testdata.sh`; sem elas esses testes são pulados. O teste de BitLocker leva cerca de 75 s.
|
||||
|
||||
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 e Versionamento
|
||||
|
||||
## Releases
|
||||
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) e anexar o CLI para Linux (x86_64 e aarch64) e o app desktop para Linux (x86_64 e aarch64) e Windows.
|
||||
|
||||
Não há build para macOS: ele exige o SDK e a assinatura da Apple, que um runner Linux não tem.
|
||||
|
||||
@@ -48,4 +49,4 @@ A versão principal é em inglês e cada arquivo tem uma tradução em portuguê
|
||||
|
||||
## Commits
|
||||
|
||||
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por PR.
|
||||
Os commits devem seguir **Conventional Commits 1.0.0** em inglês (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), ex.: `fix(ntfs): handle sparse data runs correctly`. Uma mudança lógica por PR. Validado automaticamente por `.githooks/commit-msg`.
|
||||
Generated
+2
-2
@@ -416,7 +416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -429,7 +429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"argon2",
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
|
||||
@@ -17,6 +17,6 @@ opt-level = 3
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
# Dependências otimizadas também em debug: os testes de cifra fazem milhões de SHA-256.
|
||||
# Optimize dependencies in debug builds as well: cipher tests run millions of SHA-256 rounds.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
@@ -45,7 +45,7 @@ The CLI is Linux-only; the desktop app ships for Linux and Windows. Every CLI
|
||||
package has a `.sha256` next to it. To build from source: `cargo build
|
||||
--release -p guara-cli` (Rust 1.80+, Linux).
|
||||
|
||||
**Updates:** the desktop app (AppImage and Windows) checks Gitea for a newer version when it opens; if there is one, a card offers **Update now**: it downloads, verifies the package signature, installs and reopens, so you no longer swap the AppImage by hand. The check is a single request for the release's `latest.json` and nothing of yours is sent; turn it off in **Settings**. Installs from `.deb`/`.rpm` only show the notice, because the package manager owns those files.
|
||||
**Updates:** the desktop app checks Gitea on startup; when a new version exists, a notice offers the release link. The CLI updates the same way (`guara update`); it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||
|
||||
> The binaries are **not signed yet**: Windows SmartScreen will warn you. Check the SHA-256.
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ O CLI é só para Linux; o app desktop sai para Linux e Windows. Cada pacote do
|
||||
CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
|
||||
guara-cli` (Rust 1.80+, Linux).
|
||||
|
||||
**Atualizações:** o app desktop (AppImage e Windows) consulta o Gitea ao abrir; havendo versão nova, um cartão oferece **Atualizar agora**: ele baixa, confere a assinatura do pacote, instala e reabre, e você deixa de trocar o AppImage na mão. A consulta é uma única requisição ao `latest.json` da release e nada seu é enviado; desligue em **Configurações**. Instalações por `.deb`/`.rpm` só mostram o aviso, porque quem manda nesses arquivos é o gerenciador de pacotes.
|
||||
**Atualizações:** o app desktop consulta o Gitea ao abrir; havendo versão nova, um aviso oferece o link da release. O CLI atualiza do mesmo jeito (`guara update`); ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
|
||||
|
||||
> Os binários ainda **não são assinados**: o Windows SmartScreen vai avisar. Confira o SHA-256.
|
||||
|
||||
|
||||
Generated
+18
-514
@@ -450,16 +450,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -483,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -496,7 +486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -897,16 +887,6 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -932,16 +912,6 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
@@ -1187,17 +1157,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1383,7 +1342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"argon2",
|
||||
@@ -1414,7 +1373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-gui"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"guara-core",
|
||||
"libc",
|
||||
@@ -1423,7 +1382,6 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1548,21 +1506,6 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -1582,11 +1525,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1905,36 +1846,6 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2082,12 +1993,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -2163,12 +2068,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2456,18 +2355,6 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2531,32 +2418,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2961,20 +2828,15 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3012,20 +2874,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ripemd"
|
||||
version = "0.2.0"
|
||||
@@ -3050,92 +2898,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3160,15 +2922,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3226,29 +2979,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
@@ -3482,22 +3212,6 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
@@ -3622,12 +3336,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.9"
|
||||
@@ -3691,27 +3399,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3733,7 +3420,7 @@ checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3743,7 +3430,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni 0.21.1",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3776,17 +3463,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3810,7 +3486,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3966,40 +3642,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"libc",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.12.2"
|
||||
@@ -4010,7 +3652,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"jni",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -4033,7 +3675,7 @@ checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"jni",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -4099,19 +3741,6 @@ dependencies = [
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
@@ -4233,16 +3862,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.20"
|
||||
@@ -4480,12 +4099,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4748,15 +4361,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4927,17 +4531,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -4962,16 +4555,7 @@ version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4989,29 +4573,13 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5038,90 +4606,42 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -5180,7 +4700,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni 0.21.1",
|
||||
"jni",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -5226,16 +4746,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.4"
|
||||
@@ -5280,12 +4790,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Aplicativo desktop (Tauri 2). Fica fora do workspace principal porque exige
|
||||
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
|
||||
# Desktop application (Tauri 2). Kept outside the main workspace because it
|
||||
# requires system WebView libraries (WebKitGTK on Linux).
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "1.3.2"
|
||||
description = "Guará: graphical file recovery interface"
|
||||
version = "1.3.4"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["Guará contributors"]
|
||||
@@ -18,7 +18,6 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
guara-core = { path = "../../../crates/guara-core" }
|
||||
|
||||
@@ -3,11 +3,11 @@ fn main() {
|
||||
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Ler discos brutos no Windows exige administrador: o manifesto pede elevação (UAC).
|
||||
// Reading raw disks on Windows requires Administrator privileges: embed UAC elevation manifest.
|
||||
let manifest = include_str!("windows-app-manifest.xml");
|
||||
let attrs = tauri_build::Attributes::new()
|
||||
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest));
|
||||
tauri_build::try_build(attrs).expect("falha no tauri-build");
|
||||
tauri_build::try_build(attrs).expect("tauri-build failed");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
tauri_build::build();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Permissões da janela principal",
|
||||
"description": "Main window permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"DenLine truncated
|
||||
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"DenLine truncated
|
||||
@@ -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"]}}
|
||||
@@ -2281,60 +2281,6 @@
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2281,60 +2281,6 @@
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+140
-105
@@ -1,4 +1,4 @@
|
||||
// Evita a janela de console extra no Windows em release.
|
||||
// Prevents an extra console window on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use guara_core::service::Engine;
|
||||
@@ -6,85 +6,35 @@ use std::sync::Arc;
|
||||
|
||||
struct State(Arc<Engine>);
|
||||
|
||||
/// Ponte única entre a interface e o núcleo (mesmos métodos do modo `guara ui`).
|
||||
/// Single bridge between the UI and the core (same methods as `guara ui` mode).
|
||||
#[tauri::command]
|
||||
async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
async fn rpc(
|
||||
state: tauri::State<'_, State>,
|
||||
method: String,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let eng = state.0.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || eng.rpc(&method, params).map_err(|e| e.to_string()))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
eng.rpc(&method, params).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Versão nova encontrada na release do Gitea.
|
||||
#[derive(serde::Serialize)]
|
||||
struct UpdateInfo {
|
||||
current: String,
|
||||
version: String,
|
||||
notes: String,
|
||||
/// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote
|
||||
/// (.deb/.rpm) só avisa e leva à página da release.
|
||||
auto: bool,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn self_update_possible() -> bool {
|
||||
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some()
|
||||
}
|
||||
|
||||
/// Procura uma versão mais nova. `None` = já está na última.
|
||||
#[tauri::command]
|
||||
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?;
|
||||
Ok(found.map(|u| UpdateInfo {
|
||||
current: u.current_version.clone(),
|
||||
notes: u.body.clone().unwrap_or_default(),
|
||||
url: format!("https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/tag/v{}", u.version),
|
||||
version: u.version,
|
||||
auto: self_update_possible(),
|
||||
}))
|
||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(status).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app.
|
||||
/// O progresso sai no evento `update-progress`.
|
||||
#[tauri::command]
|
||||
async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
if !self_update_possible() {
|
||||
return Err("Esta instalação não se atualiza sozinha (pacote do sistema). Baixe a versão nova na página da release.".into());
|
||||
}
|
||||
let update = app
|
||||
.updater()
|
||||
.map_err(|e| e.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or("Já está na versão mais recente.")?;
|
||||
let (a, b) = (app.clone(), app.clone());
|
||||
let mut got = 0u64;
|
||||
update
|
||||
.download_and_install(
|
||||
move |chunk, total| {
|
||||
got += chunk as u64;
|
||||
let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total }));
|
||||
},
|
||||
move || {
|
||||
let _ = b.emit("update-progress", serde_json::json!({ "installing": true }));
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Não foi possível atualizar: {e}"))?;
|
||||
// Rodando como administrador, o arquivo novo nasce do root: devolve ao usuário que abriu o app.
|
||||
if let Some(p) = std::env::var_os("APPIMAGE") {
|
||||
guara_core::owner::give_to_user(std::path::Path::new(&p));
|
||||
}
|
||||
app.restart()
|
||||
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
|
||||
state.0.update_apply().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `true` quando o processo já tem privilégio suficiente para ler discos brutos.
|
||||
/// No Windows o manifesto já exige administrador antes mesmo do app abrir,
|
||||
/// então aqui é sempre `true`.
|
||||
/// `true` when the process already has sufficient privileges to read raw disks.
|
||||
/// On Windows the manifest already requires administrator privileges before launch,
|
||||
/// so this is always `true` here.
|
||||
#[tauri::command]
|
||||
fn is_elevated() -> bool {
|
||||
#[cfg(unix)]
|
||||
@@ -97,9 +47,9 @@ fn is_elevated() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
||||
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
|
||||
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
|
||||
/// Relaunches the app with administrator privileges (Linux/macOS). On Windows the
|
||||
/// manifest already enforces elevation. The new instance terminates this one on startup; if
|
||||
/// authorization fails, this window stays open and the UI displays the reason.
|
||||
#[tauri::command]
|
||||
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
@@ -120,7 +70,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let cfg = guara_core::settings::config_dir();
|
||||
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
|
||||
let _ = std::fs::write(&log, b"");
|
||||
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
|
||||
let out = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&log)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let pid = std::process::id().to_string();
|
||||
|
||||
@@ -128,7 +81,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("pkexec");
|
||||
c.arg("/usr/bin/env");
|
||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
|
||||
for k in [
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"GDK_BACKEND",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
@@ -146,16 +106,21 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
pid,
|
||||
exe.display()
|
||||
);
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
||||
let script = format!(
|
||||
"do shell script \"{}\" with administrator privileges",
|
||||
shell.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
);
|
||||
let mut c = std::process::Command::new("osascript");
|
||||
c.args(["-e", &script]);
|
||||
c
|
||||
};
|
||||
cmd.stdout(out).stderr(err);
|
||||
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
|
||||
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
|
||||
// On Linux, pkexec only exits with success when the elevated app closes (and the elevated
|
||||
// app will have already terminated this process). Any failure before that becomes a warning.
|
||||
let status = child.wait();
|
||||
if matches!(&status, Ok(s) if s.success()) {
|
||||
let _ = std::fs::remove_file(&log);
|
||||
@@ -165,7 +130,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let msg = match status.ok().and_then(|s| s.code()) {
|
||||
Some(126) => "Autorização cancelada.".to_string(),
|
||||
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
|
||||
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
|
||||
_ => format!(
|
||||
"Não foi possível reiniciar como administrador. {}",
|
||||
detail.lines().last().unwrap_or("").trim()
|
||||
),
|
||||
};
|
||||
let _ = app.emit("elevate-failed", msg);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
@@ -173,31 +141,43 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
|
||||
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
|
||||
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||
/// Executable path accessible to root. Inside an AppImage, `current_exe` points
|
||||
/// inside a user FUSE mount that root cannot access: use the
|
||||
/// `.AppImage` file itself (`APPIMAGE` environment variable).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.is_file())
|
||||
{
|
||||
return Ok(p);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
|
||||
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
|
||||
/// Serves recovered files for preview (`guara://localhost/file/<job>/<id>`),
|
||||
/// with Range support for video/audio/PDF, reading directly from the source.
|
||||
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
|
||||
use tauri::http::Response;
|
||||
if let Some(rest) = req.uri().path().strip_prefix("/thumb/") {
|
||||
let mut it = rest.split('/');
|
||||
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let size = req
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
|
||||
let err = |code: u16| {
|
||||
Response::builder()
|
||||
.status(code)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
};
|
||||
let Some(id) = id else { return err(400) };
|
||||
return match eng.thumb(job, id, size) {
|
||||
Ok(b) => Response::builder()
|
||||
@@ -212,8 +192,17 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http:
|
||||
}
|
||||
const CAP: u64 = 8 * 1024 * 1024;
|
||||
let mut it = req.uri().path().trim_start_matches("/file/").split('/');
|
||||
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
|
||||
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let err = |code: u16| {
|
||||
Response::builder()
|
||||
.status(code)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
};
|
||||
let Some(id) = id else { return err(400) };
|
||||
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
|
||||
let slice = match eng.file_slice(job, id, range) {
|
||||
@@ -225,14 +214,24 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http:
|
||||
let want = slice.len();
|
||||
let mut headers = slice.headers();
|
||||
let partial = slice.partial || want > CAP;
|
||||
let Ok(data) = slice.read_all(CAP) else { return err(500) };
|
||||
let Ok(data) = slice.read_all(CAP) else {
|
||||
return err(500);
|
||||
};
|
||||
let mut b = Response::builder().status(if partial { 206 } else { 200 });
|
||||
headers.retain(|(k, _)| *k != "Content-Range");
|
||||
for (k, v) in &headers {
|
||||
b = b.header(*k, v);
|
||||
}
|
||||
if partial && !data.is_empty() {
|
||||
b = b.header("Content-Range", format!("bytes {}-{}/{}", start, start + data.len() as u64 - 1, total));
|
||||
b = b.header(
|
||||
"Content-Range",
|
||||
format!(
|
||||
"bytes {}-{}/{}",
|
||||
start,
|
||||
start + data.len() as u64 - 1,
|
||||
total
|
||||
),
|
||||
);
|
||||
}
|
||||
b.body(data).unwrap_or_else(|_| err(500))
|
||||
}
|
||||
@@ -242,19 +241,22 @@ fn main() {
|
||||
let for_protocol = engine.clone();
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(State(engine))
|
||||
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
||||
let eng = for_protocol.clone();
|
||||
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
||||
})
|
||||
.setup(|app| {
|
||||
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||
use tauri::{Listener, Manager};
|
||||
// When launched elevated by the unprivileged instance, terminate the old process.
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = std::env::var("GUARA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
|
||||
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
|
||||
.ok()
|
||||
.and_then(|p| p.parse::<i32>().ok())
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||
// SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
|
||||
unsafe {
|
||||
if libc::geteuid() == 0 {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
@@ -263,19 +265,52 @@ fn main() {
|
||||
});
|
||||
}
|
||||
|
||||
// Ler discos brutos exige root: pede elevação assim que o app abre,
|
||||
// sem esperar o usuário procurar um botão. Se a autorização falhar,
|
||||
// a janela continua aberta e a interface mostra o aviso.
|
||||
// The window starts hidden (tauri.conf.json: "visible": false) so the
|
||||
// unprivileged instance never flashes on screen before being replaced.
|
||||
// Reading raw disks requires root: request elevation as soon as the app
|
||||
// launches, without waiting for the user to look for a button, and only
|
||||
// show a window once we know which one it is — the elevated relaunch's,
|
||||
// or this same one if elevation turned out not to be needed/possible.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = do_relaunch_elevated(handle);
|
||||
});
|
||||
{
|
||||
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
||||
use tauri::Emitter;
|
||||
let handle = app.handle().clone();
|
||||
let handle_for_failure = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
// do_relaunch_elevated only emits "elevate-failed" itself once the
|
||||
// child process has actually spawned and later exits non-zero. If it
|
||||
// fails earlier (pkexec/osascript missing, exe path unresolved, log
|
||||
// file unwritable), it returns Err directly without emitting anything
|
||||
// — emit it here too, or the hidden window would never reappear.
|
||||
if let Err(e) = do_relaunch_elevated(handle) {
|
||||
let _ = handle_for_failure.emit("elevate-failed", e);
|
||||
}
|
||||
});
|
||||
// Whichever path emits it, make sure the window comes back so the
|
||||
// user sees the warning instead of a silently stuck, invisible app.
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
app.handle().listen("elevate-failed", move |_| {
|
||||
let _ = win.show();
|
||||
});
|
||||
}
|
||||
} else if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
}
|
||||
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_apply
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("erro ao iniciar o Guará");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guará",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.4",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -15,7 +15,8 @@
|
||||
"height": 780,
|
||||
"minWidth": 420,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
"resizable": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -34,18 +35,6 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
|
||||
"endpoints": [
|
||||
"https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+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); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* ------------------------------------------------------------ componentes */
|
||||
/* ------------------------------------------------------------ components */
|
||||
.hero { padding: 56px 0 28px; max-width: 640px; }
|
||||
.hero h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.05; font-weight: 700; }
|
||||
.hero p { color: var(--text-2); font-size: 18px; margin: 14px 0 0; }
|
||||
@@ -194,7 +194,7 @@ details.adv[open] summary { margin-bottom: 10px; }
|
||||
.check { display: flex; gap: 10px; align-items: flex-start; padding: 6px 0; font-size: 14px; }
|
||||
.check input { margin-top: 3px; accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
|
||||
/* progresso */
|
||||
/* progress */
|
||||
.progress-card { background: var(--surface); border-radius: 22px; box-shadow: var(--shadow); padding: 26px; display: grid; gap: 14px; }
|
||||
.bar { height: 10px; border-radius: 99px; background: color-mix(in srgb, var(--text) 9%, transparent); overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .3s ease; }
|
||||
@@ -204,7 +204,7 @@ details.adv[open] summary { margin-bottom: 10px; }
|
||||
.stat b { display: block; font-size: 28px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stat span { color: var(--text-2); font-size: 13px; }
|
||||
|
||||
/* resultados */
|
||||
/* results */
|
||||
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 22px 0 12px; }
|
||||
.chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
@@ -212,7 +212,7 @@ details.adv[open] summary { margin-bottom: 10px; }
|
||||
.chip[aria-pressed="true"] { background: var(--text); color: var(--bg); }
|
||||
.chip small { opacity: .6; margin-left: 4px; font-variant-numeric: tabular-nums; }
|
||||
.input, .select { border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; min-width: 0; }
|
||||
/* O fundo do select é translúcido; a lista aberta não herda isso e vinha branca com texto claro no tema escuro. */
|
||||
/* The select background is translucent; the open dropdown does not inherit it and rendered white with light text in dark mode. */
|
||||
.select option, .select optgroup { background-color: var(--surface); color: var(--text); }
|
||||
.input { flex: 1 1 200px; }
|
||||
.input::placeholder { color: var(--text-3); }
|
||||
@@ -255,7 +255,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
||||
.scrim { display: none; }
|
||||
.back { color: var(--accent); background: none; border: 0; cursor: pointer; font-weight: 500; padding: 0; margin-bottom: 8px; }
|
||||
|
||||
/* ------------------------------------------------------------ responsivo */
|
||||
/* ------------------------------------------------------------ responsive */
|
||||
@media (max-width: 1100px) {
|
||||
.split { grid-template-columns: 1fr; }
|
||||
.preview { position: static; order: -1; grid-template-columns: 120px 1fr; align-items: start; }
|
||||
@@ -285,12 +285,12 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
||||
* { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ tema */
|
||||
/* ------------------------------------------------------------ theme */
|
||||
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
|
||||
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
|
||||
|
||||
/* ------------------------------------------------------------ pré-visualização */
|
||||
/* ------------------------------------------------------------ preview */
|
||||
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
|
||||
.viewer.large { max-height: none; height: 100%; min-height: 0; }
|
||||
.viewer > * { max-width: 100%; }
|
||||
@@ -335,7 +335,7 @@ dialog.lightbox[open] { display: grid; }
|
||||
.hero-logo { width: min(340px, 70%); height: auto; margin: 0 0 14px -6px; }
|
||||
.pill.accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* ------------------------------------------------------------ explorador de arquivos */
|
||||
/* ------------------------------------------------------------ file explorer */
|
||||
.main.fixed { overflow: hidden; }
|
||||
.page.wide { max-width: none; padding: 14px 18px 0; }
|
||||
.page.fill { height: 100%; display: flex; flex-direction: column; }
|
||||
@@ -467,7 +467,7 @@ dialog.lightbox[open] { display: grid; }
|
||||
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
|
||||
.cand .s { white-space: normal; }
|
||||
|
||||
/* ------------------------------------------------------------ visualizador de bancos de dados */
|
||||
/* ------------------------------------------------------------ database viewer */
|
||||
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
|
||||
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
|
||||
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
|
||||
@@ -497,7 +497,7 @@ dialog.lightbox[open] { display: grid; }
|
||||
.xp-tiles img { width: 100%; height: 100%; display: block; }
|
||||
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
|
||||
|
||||
/* saúde do disco (SMART) */
|
||||
/* disk health (SMART) */
|
||||
.h-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
|
||||
.h-tile { background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.h-tile small { color: var(--text-3); font-size: 12px; }
|
||||
@@ -516,10 +516,10 @@ dialog.lightbox[open] { display: grid; }
|
||||
.h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); }
|
||||
.h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); }
|
||||
|
||||
/* logo: uma arte só (laranja sobre transparente) serve aos temas claro e escuro */
|
||||
/* logo: a single artwork (orange on transparent) works for both light and dark themes */
|
||||
.logo-img { display: block; flex: none; }
|
||||
|
||||
/* atualização do app */
|
||||
/* app update */
|
||||
.update-card { position: fixed; right: 18px; bottom: 18px; z-index: 60; width: min(400px, calc(100vw - 36px)); display: grid; gap: 10px; padding: 16px 18px; border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||
.update-card .upd-head { display: grid; gap: 2px; }
|
||||
.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
+51
-66
@@ -1,5 +1,5 @@
|
||||
// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
||||
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
||||
// Guará: UI. Communicates with the core via `rpc(method, params)`:
|
||||
// • Tauri app: invoke('rpc') • `guara ui` mode: POST /rpc/<method> with token.
|
||||
|
||||
import { createExplorer } from './explorer.js';
|
||||
import { setLang, LOC, currentLang } from './i18n.js';
|
||||
@@ -10,7 +10,7 @@ let token = params.get('token');
|
||||
try {
|
||||
if (token) sessionStorage.setItem('guara-token', token);
|
||||
else token = sessionStorage.getItem('guara-token');
|
||||
} catch { /* armazenamento indisponível */ }
|
||||
} catch { /* storage unavailable */ }
|
||||
|
||||
async function rpc(method, p = {}) {
|
||||
if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: p });
|
||||
@@ -29,7 +29,7 @@ const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost'
|
||||
const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`);
|
||||
const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`);
|
||||
|
||||
// Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover).
|
||||
// Update HTML only when changed (prevents flickering and losing focus/hover).
|
||||
function setHTML(el, html) {
|
||||
if (!el || el._h === html) return false;
|
||||
el._h = html;
|
||||
@@ -37,7 +37,7 @@ function setHTML(el, html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tema
|
||||
// ------------------------------------------------------------------ theme
|
||||
function applyTheme(t) {
|
||||
const root = document.documentElement;
|
||||
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
|
||||
@@ -47,19 +47,19 @@ try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { apply
|
||||
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
|
||||
const t = b.dataset.themePick;
|
||||
applyTheme(t);
|
||||
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
|
||||
try { localStorage.setItem('guara-theme', t); } catch { /* no storage */ }
|
||||
saveSettings({ theme: t });
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------------------ configurações
|
||||
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
||||
// ------------------------------------------------------------------ settings
|
||||
// (declared before use: `S` is defined further below, so the object lives here)
|
||||
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
|
||||
async function saveSettings(patch) {
|
||||
Object.assign(SETTINGS, patch);
|
||||
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
||||
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ utilidades
|
||||
// ------------------------------------------------------------------ utilities
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const human = (n) => {
|
||||
@@ -153,14 +153,14 @@ const S = {
|
||||
sessions: [], // {job, target, label}
|
||||
device: null, // {path,name,...}
|
||||
layout: null,
|
||||
job: null, // id da varredura atual
|
||||
job: null, // active scan job id
|
||||
status: null,
|
||||
filters: { category: null, minConfidence: null, query: '', sort: 'confidence' },
|
||||
items: [],
|
||||
total: 0,
|
||||
selected: new Set(),
|
||||
allFiltered: false,
|
||||
current: null, // arquivo em foco (pré-visualização)
|
||||
current: null, // focused file (preview)
|
||||
poll: null,
|
||||
recJob: null,
|
||||
};
|
||||
@@ -259,7 +259,7 @@ $('#loadSessionBtn').onclick = async () => {
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ início
|
||||
// ------------------------------------------------------------------ home
|
||||
function renderHome() {
|
||||
main.classList.remove('fixed');
|
||||
stopPoll();
|
||||
@@ -278,7 +278,7 @@ function renderHome() {
|
||||
$('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dispositivo
|
||||
// ------------------------------------------------------------------ device
|
||||
async function openDevice(d) {
|
||||
main.classList.remove('fixed');
|
||||
stopPoll();
|
||||
@@ -291,7 +291,7 @@ async function openDevice(d) {
|
||||
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
|
||||
S.layout = layout;
|
||||
renderDevice(err);
|
||||
// saúde (SMART) em segundo plano: não atrasa a tela
|
||||
// Query SMART health in the background without blocking the screen.
|
||||
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ function paintHealth() {
|
||||
${attrs.map((a) => `<tr><td><span class="h-dot ${esc(a.status)}" title="${HEALTH_WORD[a.status] || ''}"></span></td><td class="mono">${esc(a.id)}</td><td class="mono">${esc(a.name)}</td><td>${dash(a.value)}</td><td>${dash(a.worst)}</td><td>${dash(a.thresh)}</td><td class="mono">${esc(a.raw)}</td></tr>`).join('')}
|
||||
</tbody></table></div></details>` : '';
|
||||
const scope = h.partition ? `<p class="sub">O SMART é do disco inteiro: os dados abaixo são de <span class="mono">${esc(h.disk)}</span>, que contém <span class="mono">${esc(h.partition)}</span>.</p>` : '';
|
||||
// o que motivou o alerta (os campos da ficha já aparecem abaixo)
|
||||
// Fields that triggered the alert (standard summary fields appear below).
|
||||
const known = new Set(['Modelo', 'Nº de série', 'Horas ligado', 'Temperatura', 'Autoavaliação SMART', 'Vida útil usada']);
|
||||
const why = (h.details || []).filter(([k]) => !known.has(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
|
||||
el.innerHTML = `${bad ? `<div class="notice ${h.status === 'failing' ? 'bad' : ''}">${warnSvg}<div><b>${esc(h.summary)}</b>
|
||||
@@ -338,7 +338,7 @@ function paintHealth() {
|
||||
$('#healthImg')?.addEventListener('click', startImage);
|
||||
}
|
||||
|
||||
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
|
||||
/** Disk showing signs of failure: offer to create a disk image before scanning. */
|
||||
function confirmRisky() {
|
||||
return new Promise((resolve) => {
|
||||
const h = S.health;
|
||||
@@ -532,8 +532,8 @@ function openSearch(job, terms) {
|
||||
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ varredura
|
||||
let EX = null; // explorador de arquivos (grade, navegação, detalhes)
|
||||
// ------------------------------------------------------------------ scan
|
||||
let EX = null; // file explorer (grid, navigation, details)
|
||||
|
||||
function openJob(s) {
|
||||
stopPoll();
|
||||
@@ -556,7 +556,7 @@ async function tick() {
|
||||
S.status = st;
|
||||
paintStatus();
|
||||
EX?.onStatus(st);
|
||||
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
|
||||
// Keep polling while the scan is running or dates/EXIF are still being enriched.
|
||||
if (st.status !== 'running' && !st.enriching) {
|
||||
stopPoll();
|
||||
loadRecent();
|
||||
@@ -615,7 +615,7 @@ function paintStatus() {
|
||||
|
||||
async function chooseSource() {
|
||||
let cands = [];
|
||||
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
|
||||
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* fall back to manual path input */ }
|
||||
const st = S.status || {};
|
||||
const size = st.layout?.size;
|
||||
const d = $('#dlg');
|
||||
@@ -711,7 +711,7 @@ async function recover(sel) {
|
||||
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
|
||||
// Modal de progresso de tarefas auxiliares (recuperação / imagem).
|
||||
// Progress modal for auxiliary jobs (recovery / disk imaging).
|
||||
function watchAux(job, title, doneHtml) {
|
||||
const d = $('#dlg');
|
||||
d.onclose = null;
|
||||
@@ -740,7 +740,7 @@ function watchAux(job, title, doneHtml) {
|
||||
|
||||
async function openJobFromList(s) { openJob(s); }
|
||||
|
||||
// ------------------------------------------------------------------ configurações (janela)
|
||||
// ------------------------------------------------------------------ settings modal
|
||||
$('#settingsBtn')?.addEventListener('click', () => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
|
||||
@@ -764,65 +764,51 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
||||
d.showModal();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ atualização do app
|
||||
// O núcleo do app (Tauri) consulta o `latest.json` da release no Gitea, confere a assinatura do
|
||||
// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer.
|
||||
// ------------------------------------------------------------------ app updater
|
||||
// The desktop shell (Tauri) queries Gitea Releases API and notifies the user.
|
||||
// No-op in browser mode (`guara ui`).
|
||||
let UPD = null;
|
||||
|
||||
async function checkUpdate(manual) {
|
||||
if (!tauri?.core?.invoke) return;
|
||||
const updNow = $('#updNow');
|
||||
if (updNow) updNow.textContent = 'checando…';
|
||||
try {
|
||||
UPD = await tauri.core.invoke('update_check');
|
||||
renderUpdateCard();
|
||||
if (manual) toast(UPD ? `Nova versão ${UPD.version} disponível` : 'Você já está na versão mais recente');
|
||||
const status = await tauri.core.invoke('update_check');
|
||||
if (status?.update_available) {
|
||||
UPD = {
|
||||
current: status.current_version,
|
||||
version: status.latest_version,
|
||||
url: 'https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases',
|
||||
};
|
||||
renderUpdateCard();
|
||||
if (updNow) updNow.textContent = `atualizar para ${status.latest_version}`;
|
||||
if (manual) toast(`Nova versão ${status.latest_version} disponível`);
|
||||
} else {
|
||||
UPD = null;
|
||||
renderUpdateCard();
|
||||
if (updNow) updNow.textContent = 'já atualizado';
|
||||
if (manual) toast('Você já está na versão mais recente');
|
||||
}
|
||||
} catch (e) {
|
||||
if (updNow) updNow.textContent = 'Verificar agora';
|
||||
if (manual) toast(`Não foi possível verificar atualizações: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpdateCard(state) {
|
||||
function renderUpdateCard() {
|
||||
let el = $('#updateCard');
|
||||
if (!UPD) { el?.remove(); return; }
|
||||
if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); }
|
||||
if (state === 'busy') return;
|
||||
el.innerHTML = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
|
||||
${UPD.auto ? '' : '<p class="sub">Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:</p><p class="mono upd-url">' + esc(UPD.url) + '</p>'}
|
||||
<p class="sub">Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:</p><p class="mono upd-url">${esc(UPD.url)}</p>
|
||||
<div class="upd-actions">
|
||||
${UPD.auto ? '<button class="btn primary sm" id="updGo">Atualizar agora</button>' : ''}
|
||||
${UPD.notes ? '<button class="btn sm" id="updNotes">Novidades</button>' : ''}
|
||||
<button class="btn sm" id="updLater">Depois</button>
|
||||
</div>`;
|
||||
$('#updGo')?.addEventListener('click', installUpdate);
|
||||
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
|
||||
$('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `<pre class="upd-notes">${esc(UPD.notes)}</pre>` }));
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
const el = $('#updateCard');
|
||||
el.innerHTML = `<div class="upd-head"><b>Atualizando o Guará…</b></div>
|
||||
<div class="bar indet" id="updBar"><i style="width:0"></i></div><p class="sub" id="updMsg">Baixando a atualização…</p>`;
|
||||
renderUpdateCard('busy');
|
||||
try {
|
||||
await tauri.core.invoke('update_install');
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
renderUpdateCard();
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(p) {
|
||||
const bar = $('#updBar'); const msg = $('#updMsg');
|
||||
if (!bar || !msg) return;
|
||||
if (p.installing) { bar.classList.add('indet'); msg.textContent = 'Instalando… o Guará vai reabrir sozinho.'; return; }
|
||||
if (p.total) {
|
||||
const pct = Math.min(100, Math.round((p.downloaded / p.total) * 100));
|
||||
bar.classList.remove('indet');
|
||||
bar.firstElementChild.style.width = `${pct}%`;
|
||||
msg.textContent = `Baixando… ${pct}%`;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ fechar com varredura em andamento
|
||||
// ------------------------------------------------------------------ close while scan is running
|
||||
function askClose() {
|
||||
return new Promise((resolve) => {
|
||||
const d = $('#dlg');
|
||||
@@ -848,14 +834,14 @@ function watchClose() {
|
||||
try {
|
||||
await rpc('scan_pause', { job: S.job });
|
||||
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
|
||||
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
|
||||
} catch { /* sai mesmo assim */ }
|
||||
await new Promise((r) => setTimeout(r, 600)); // allow auto-save to flush
|
||||
} catch { /* exit anyway */ }
|
||||
}
|
||||
win.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ início do app
|
||||
// ------------------------------------------------------------------ app startup
|
||||
(async function init() {
|
||||
try {
|
||||
const v = await rpc('version');
|
||||
@@ -870,7 +856,6 @@ function watchClose() {
|
||||
setLang(SETTINGS.lang);
|
||||
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
||||
watchClose();
|
||||
tauri?.event?.listen?.('update-progress', (e) => updateProgress(e.payload));
|
||||
if (SETTINGS.updates !== false) checkUpdate(false);
|
||||
if (tauri) S.elevated = await tauri.core.invoke('is_elevated').catch(() => true);
|
||||
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
||||
|
||||
+24
-24
@@ -1,7 +1,7 @@
|
||||
// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta
|
||||
// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção
|
||||
// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e
|
||||
// visualização rápida em tela cheia.
|
||||
// Recovered file explorer: thumbnail grid (virtualized, handles
|
||||
// tens of thousands of items), navigation by type/date/folder/camera, file-manager
|
||||
// style selection (Ctrl/Shift/keyboard), inspector panel, and
|
||||
// fullscreen quick look.
|
||||
|
||||
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
|
||||
import { LOC } from './i18n.js';
|
||||
@@ -66,7 +66,7 @@ export function createExplorer(deps) {
|
||||
try {
|
||||
E.view = localStorage.getItem('guara-view') || 'grid';
|
||||
E.tile = +localStorage.getItem('guara-tile') || 150;
|
||||
} catch { /* sem armazenamento */ }
|
||||
} catch { /* no storage */ }
|
||||
|
||||
host.innerHTML = `<div class="xp" data-view="${E.view}">
|
||||
<div class="xp-bar">
|
||||
@@ -91,7 +91,7 @@ export function createExplorer(deps) {
|
||||
$('#xpSort').value = E.sort;
|
||||
$('#xpSize').value = E.tile;
|
||||
|
||||
// ------------------------------------------------------------ dados
|
||||
// ------------------------------------------------------------ data
|
||||
const toQuery = () => {
|
||||
const f = E.filters;
|
||||
const q = {};
|
||||
@@ -139,8 +139,8 @@ export function createExplorer(deps) {
|
||||
|
||||
const reloadAll = () => { reload(); loadFacets(); };
|
||||
|
||||
// ------------------------------------------------------------ layout virtual
|
||||
// Agrupa por dia; se houver poucos itens por dia, agrupa por mês.
|
||||
// ------------------------------------------------------------ virtual layout
|
||||
// Group by day; if there are too few items per day, group by month.
|
||||
let groupBy = 'day';
|
||||
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
|
||||
const dayLabel = (k) => {
|
||||
@@ -150,7 +150,7 @@ export function createExplorer(deps) {
|
||||
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
|
||||
};
|
||||
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
|
||||
// Metadados do núcleo trazem datas em ISO 8601; o idioma decide a ordem.
|
||||
// Core metadata provides ISO 8601 dates; the active locale determines the order.
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
|
||||
const fmtMeta = (v) => ISO_DATE.test(v) ? new Date(v.endsWith('Z') ? v : v + 'Z').toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'medium' }) : v;
|
||||
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
|
||||
@@ -195,7 +195,7 @@ export function createExplorer(deps) {
|
||||
});
|
||||
flush();
|
||||
if (grouped) {
|
||||
// contagem por grupo
|
||||
// count per group
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
if (rows[r].t !== 'head') continue;
|
||||
let n = 0;
|
||||
@@ -245,7 +245,7 @@ export function createExplorer(deps) {
|
||||
sizer.appendChild(frag);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ itens
|
||||
// ------------------------------------------------------------ items
|
||||
const isSel = (id) => E.allFiltered || E.sel.has(id);
|
||||
|
||||
function headHtml(row) {
|
||||
@@ -288,7 +288,7 @@ export function createExplorer(deps) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// imagens que falham viram um aviso (arquivo possivelmente danificado)
|
||||
// failed images become a warning (file possibly damaged)
|
||||
scroll.addEventListener('error', (e) => {
|
||||
const img = e.target;
|
||||
if (img.tagName !== 'IMG' || !img.dataset.th) return;
|
||||
@@ -300,7 +300,7 @@ export function createExplorer(deps) {
|
||||
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
}, true);
|
||||
|
||||
// ------------------------------------------------------------ seleção
|
||||
// ------------------------------------------------------------ selection
|
||||
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
|
||||
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
|
||||
|
||||
@@ -387,7 +387,7 @@ export function createExplorer(deps) {
|
||||
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ barra de ações
|
||||
// ------------------------------------------------------------ action bar
|
||||
function paintActions() {
|
||||
const n = selCount();
|
||||
const html = n
|
||||
@@ -407,7 +407,7 @@ export function createExplorer(deps) {
|
||||
else if (a === 'recall') onRecover({ filter: toQuery() });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ navegação lateral
|
||||
// ------------------------------------------------------------ sidebar navigation
|
||||
function paintNav() {
|
||||
const f = E.facets;
|
||||
if (!f) return;
|
||||
@@ -424,7 +424,7 @@ export function createExplorer(deps) {
|
||||
+ (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : '');
|
||||
}).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
|
||||
const folders = f.folders.filter((d) => {
|
||||
// só mostra se todos os ancestrais estiverem expandidos
|
||||
// only show if all ancestors are expanded
|
||||
const parts = d.path.split('/');
|
||||
for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false;
|
||||
return true;
|
||||
@@ -528,7 +528,7 @@ export function createExplorer(deps) {
|
||||
changed();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ barra superior
|
||||
// ------------------------------------------------------------ top bar
|
||||
let qt;
|
||||
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
|
||||
$('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); });
|
||||
@@ -550,7 +550,7 @@ export function createExplorer(deps) {
|
||||
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
|
||||
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
|
||||
|
||||
// ------------------------------------------------------------ painel de detalhes
|
||||
// ------------------------------------------------------------ inspector panel
|
||||
let inspT, stopPv = null, metaToken = 0;
|
||||
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
|
||||
|
||||
@@ -584,7 +584,7 @@ export function createExplorer(deps) {
|
||||
<div class="xp-meta" id="xpMeta"></div>
|
||||
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
|
||||
// clicar na miniatura abre o modal grande
|
||||
// clicking the thumbnail opens the large modal
|
||||
$('#xpPv').addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button, a, .pv-pdfbar')) return;
|
||||
openLightbox(idxOf(it.id));
|
||||
@@ -599,7 +599,7 @@ export function createExplorer(deps) {
|
||||
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
|
||||
}).catch(() => {});
|
||||
}
|
||||
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
|
||||
/** Coordinates and, if enabled by the user, a map (3x3 OpenStreetMap tiles around the point). */
|
||||
function gpsHtml([lat, lon]) {
|
||||
let map = '';
|
||||
if (deps.getSettings?.().maps) {
|
||||
@@ -625,7 +625,7 @@ export function createExplorer(deps) {
|
||||
else if (a === 'close') root.classList.remove('show-insp');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ tela cheia
|
||||
// ------------------------------------------------------------ fullscreen
|
||||
let stopLb = null;
|
||||
function openLightbox(i) {
|
||||
const it = E.items[i];
|
||||
@@ -649,14 +649,14 @@ export function createExplorer(deps) {
|
||||
if (!d.open) d.showModal();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ paint geral
|
||||
// ------------------------------------------------------------ main paint
|
||||
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
|
||||
|
||||
paintView(); paintCrumb(); paintActions(); paintInspector();
|
||||
reloadAll();
|
||||
|
||||
return {
|
||||
/** Chamado a cada atualização de status da varredura. */
|
||||
/** Called on every scan status update. */
|
||||
onStatus(st) {
|
||||
E.status = st;
|
||||
const enriching = !!st.enriching;
|
||||
@@ -672,7 +672,7 @@ export function createExplorer(deps) {
|
||||
},
|
||||
query: () => toQuery(),
|
||||
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
|
||||
/** Idioma trocado em Configurações: refaz o que foi montado em JS (datas, meses, rótulos). */
|
||||
/** Language changed in Settings: rebuild JS-rendered elements (dates, months, labels). */
|
||||
relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
|
||||
focus() { scroll.focus({ preventScroll: true }); },
|
||||
};
|
||||
|
||||
+28
-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 = {
|
||||
// saúde do disco (SMART)
|
||||
// disk health (SMART)
|
||||
'O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco.': 'SMART could not be read. Install the smartmontools package (smartctl command) and run as administrator to see the disk health.',
|
||||
'Setores realocados': 'Reallocated sectors',
|
||||
'Setores pendentes de realocação': 'Pending sectors',
|
||||
@@ -38,7 +38,7 @@ export const EN = {
|
||||
'O SMART é do disco inteiro: os dados abaixo são de': 'SMART belongs to the whole disk: the data below is for',
|
||||
', que contém': ', which contains',
|
||||
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.',
|
||||
// barra lateral e estrutura
|
||||
// sidebar and layout
|
||||
'Recuperação de arquivos': 'File recovery',
|
||||
'Dispositivos': 'Devices',
|
||||
'Dispositivos e sessões': 'Devices and sessions',
|
||||
@@ -64,7 +64,7 @@ export const EN = {
|
||||
'· requer admin': '· needs admin',
|
||||
'requer admin': 'needs admin',
|
||||
'{n} · requer admin': '{n} · needs admin',
|
||||
// início
|
||||
// home
|
||||
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
|
||||
'Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
|
||||
'Uma varredura, tudo junto': 'One scan, everything together',
|
||||
@@ -74,7 +74,7 @@ export const EN = {
|
||||
'Origem protegida': 'Protected source',
|
||||
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
|
||||
'Nenhum disco apareceu.': 'No disks showed up.',
|
||||
// dispositivo
|
||||
// device
|
||||
'Mapa do disco': 'Disk map',
|
||||
'Como procurar': 'How to search',
|
||||
'Recomendado': 'Recommended',
|
||||
@@ -108,7 +108,7 @@ export const EN = {
|
||||
'encontrada por varredura': 'found by scanning',
|
||||
'Desconhecido': 'Unknown',
|
||||
'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
|
||||
// saúde do disco
|
||||
// disk health
|
||||
'Saúde do disco': 'Disk health',
|
||||
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
|
||||
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
|
||||
@@ -119,7 +119,7 @@ export const EN = {
|
||||
'Varrer mesmo assim': 'Scan anyway',
|
||||
'Cancelar': 'Cancel',
|
||||
'Este disco parece estar com defeito': 'This disk seems to be failing',
|
||||
// varredura
|
||||
// scan
|
||||
'‹ Dispositivos': '‹ Devices',
|
||||
'Procurando…': 'Searching…',
|
||||
'Varredura concluída': 'Scan complete',
|
||||
@@ -152,7 +152,7 @@ export const EN = {
|
||||
'Lendo tabela de partições': 'Reading partition table',
|
||||
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
|
||||
'Varredura profunda por assinatura': 'Deep signature scan',
|
||||
// explorador
|
||||
// explorer
|
||||
'Navegação': 'Navigation',
|
||||
'Filtros': 'Filters',
|
||||
'Todos os arquivos': 'All files',
|
||||
@@ -246,7 +246,7 @@ export const EN = {
|
||||
'Tem localização GPS': 'Has GPS location',
|
||||
'Confiança {n}': 'Confidence {n}',
|
||||
'Nada por aqui': 'Nothing here',
|
||||
// pré-visualização
|
||||
// preview
|
||||
'Imagem': 'Image',
|
||||
'Vídeo': 'Video',
|
||||
'PDF': 'PDF',
|
||||
@@ -277,7 +277,7 @@ export const EN = {
|
||||
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
|
||||
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
|
||||
'pasta': 'folder',
|
||||
// banco de dados
|
||||
// database
|
||||
'Abrir o visualizador': 'Open the viewer',
|
||||
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
|
||||
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
|
||||
@@ -297,7 +297,7 @@ export const EN = {
|
||||
'{n} a {n} de {n}': '{n} to {n} of {n}',
|
||||
'‹ Anterior': '‹ Previous',
|
||||
'Próximas ›': 'Next ›',
|
||||
// recuperar
|
||||
// recover
|
||||
'Recuperar para…': 'Recover to…',
|
||||
'Recuperar': 'Recover',
|
||||
'Pasta de destino': 'Destination folder',
|
||||
@@ -336,7 +336,7 @@ export const EN = {
|
||||
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
|
||||
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
|
||||
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
|
||||
// imagem, hash, busca
|
||||
// image, hash, search
|
||||
'Criando imagem do disco': 'Creating disk image',
|
||||
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
|
||||
'Calculando hashes da origem': 'Computing source hashes',
|
||||
@@ -354,7 +354,7 @@ export const EN = {
|
||||
'{n} ocorrência{n}': '{n} occurrence{n}',
|
||||
'Termos: {n}': 'Terms: {n}',
|
||||
'{n}% · {n} até agora': '{n}% · {n} so far',
|
||||
// sessões e origem
|
||||
// sessions and source
|
||||
'Abrir arquivo de imagem': 'Open disk image file',
|
||||
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
|
||||
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
|
||||
@@ -374,7 +374,7 @@ export const EN = {
|
||||
'mesmo disco': 'same disk',
|
||||
'tamanho igual, conteúdo diferente': 'same size, different content',
|
||||
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
|
||||
// configurações e fechamento
|
||||
// settings and shutdown
|
||||
'Idioma': 'Language',
|
||||
'Notificações': 'Notifications',
|
||||
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
|
||||
@@ -389,14 +389,14 @@ export const EN = {
|
||||
'Sair sem pausar': 'Quit without pausing',
|
||||
'Continuar varrendo': 'Keep scanning',
|
||||
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
|
||||
// erros comuns vindos do núcleo
|
||||
// common errors from the core
|
||||
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
|
||||
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
|
||||
'Autorização cancelada.': 'Authorization canceled.',
|
||||
'Origem desconectada': 'Source disconnected',
|
||||
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
|
||||
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
|
||||
// formulário de opções de varredura
|
||||
// scan options form
|
||||
'Volumes cifrados, RAID e vídeos': 'Encrypted volumes, RAID and videos',
|
||||
'Senha do volume (BitLocker, LUKS, FileVault 2)': 'Volume password (BitLocker, LUKS, FileVault 2)',
|
||||
'Chave de recuperação do BitLocker': 'BitLocker recovery key',
|
||||
@@ -407,7 +407,7 @@ export const EN = {
|
||||
'Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)': 'Intact video from the same camera/drone (rebuilds the index of an MP4 without moov)',
|
||||
'Lembrar as chaves dos volumes abertos na sessão': 'Remember the keys of opened volumes in the session',
|
||||
'A senha nunca é gravada; só a chave derivada.': 'The password is never saved; only the derived key.',
|
||||
// criar imagem do disco
|
||||
// disk image creation
|
||||
'Arquivo de destino': 'Destination file',
|
||||
'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)',
|
||||
'Formato da imagem': 'Image format',
|
||||
@@ -416,18 +416,18 @@ export const EN = {
|
||||
'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)',
|
||||
'Imagem salva em': 'Image saved to',
|
||||
'Hashes do conteúdo inteiro de': 'Hashes of the entire content of',
|
||||
// resultados e partições
|
||||
// results and partitions
|
||||
'arquivo(s) salvos em': 'file(s) saved to',
|
||||
'arquivos em {n}': 'files in {n}',
|
||||
'Espaço não particionado: {n}': 'Unpartitioned space: {n}',
|
||||
'Partição {n}': 'Partition {n}',
|
||||
'erro {n}': 'error {n}',
|
||||
// saúde do disco (campos vindos do núcleo)
|
||||
// disk health (fields from the core)
|
||||
'Autoavaliação SMART': 'SMART self-assessment',
|
||||
'Vida útil usada': 'Life used',
|
||||
'aprovado': 'passed',
|
||||
'REPROVADO: o próprio disco prevê falha': 'FAILED: the disk itself predicts failure',
|
||||
// pré-visualização
|
||||
// preview
|
||||
'Pré-visualização': 'Preview',
|
||||
'Pré-visualização de {n}': 'Preview of {n}',
|
||||
'vídeo': 'video',
|
||||
@@ -438,7 +438,7 @@ export const EN = {
|
||||
'diretório do ZIP não encontrado (arquivo possivelmente incompleto)': 'ZIP directory not found (file possibly incomplete)',
|
||||
'visualizador indisponível': 'viewer unavailable',
|
||||
'© colaboradores do OpenStreetMap': '© OpenStreetMap contributors',
|
||||
// explorador: textos que a interface divide em nós separados
|
||||
// explorer: strings split across separate DOM nodes by the UI
|
||||
'selecionado': 'selected',
|
||||
'selecionados': 'selected',
|
||||
'selecionado (todos desta vista)': 'selected (all in this view)',
|
||||
@@ -459,7 +459,7 @@ export const EN = {
|
||||
'Conteúdo do arquivo compactado · {n}': 'Archive contents · {n}',
|
||||
'Banco de dados SQLite · {n}': 'SQLite database · {n}',
|
||||
'Dados brutos (hexadecimal) · {n}': 'Raw data (hexadecimal) · {n}',
|
||||
// tipos e campos de metadados vindos do núcleo
|
||||
// metadata types and fields from the core
|
||||
'Imagem JPEG': 'JPEG image',
|
||||
'Imagem PNG': 'PNG image',
|
||||
'Imagem GIF': 'GIF image',
|
||||
@@ -482,7 +482,7 @@ export const EN = {
|
||||
'Última modificação por': 'Last modified by',
|
||||
'Páginas': 'Pages',
|
||||
'Duração': 'Duration',
|
||||
// saúde do disco e confiança vindas do núcleo
|
||||
// disk health and confidence levels from the core
|
||||
'SSD (sem rotação)': 'SSD (no rotation)',
|
||||
'Cache de gravação': 'Write cache',
|
||||
'Segurança ATA': 'ATA security',
|
||||
@@ -490,13 +490,13 @@ export const EN = {
|
||||
'membro de RAID': 'RAID member',
|
||||
'sem rodapé/estrutura completa: pode estar truncado': 'no footer/complete structure: may be truncated',
|
||||
'vídeo sem índice (moov)': 'video without index (moov)',
|
||||
// visualizador de SQLite
|
||||
// SQLite viewer
|
||||
'Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada.': 'No table found in the schema. The first page may be damaged.',
|
||||
'Tabela virtual: o conteúdo não fica armazenado como linhas comuns.': 'Virtual table: the content is not stored as ordinary rows.',
|
||||
'Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida.': 'WITHOUT ROWID table: the rows live in an index tree, not shown yet.',
|
||||
'Arquivo truncado: o cabeçalho indica {n} páginas, mas só há {n}. Parte dos dados pode estar faltando.': 'Truncated file: the header says {n} pages, but there are only {n}. Part of the data may be missing.',
|
||||
|
||||
// mensagens de erro e avisos do núcleo (valores variáveis usam {n})
|
||||
// core error and warning messages (variable values use {n})
|
||||
'job {n} não existe': 'job {n} does not exist',
|
||||
'a varredura não está pausada': 'the scan is not paused',
|
||||
'A origem desta sessão não está disponível ({n}). Reconecte o disco ou escolha a origem para continuar.': 'The source of this session is unavailable ({n}). Reconnect the disk or choose the source to continue.',
|
||||
@@ -587,6 +587,9 @@ export const EN = {
|
||||
'Atualizações automáticas': 'Automatic updates',
|
||||
'Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks Gitea for a new version (one request, nothing of yours is sent)',
|
||||
'Verificar agora': 'Check now',
|
||||
'checando…': 'checking…',
|
||||
'atualizar para {n}': 'update to {n}',
|
||||
'já atualizado': 'already up to date',
|
||||
'Nova versão {n} disponível': 'New version {n} available',
|
||||
'Você já está na versão mais recente': 'You are already on the latest version',
|
||||
'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}',
|
||||
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
|
||||
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
|
||||
// continua observando a página para traduzir o que for desenhado depois.
|
||||
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
|
||||
// UI language. Source strings are in Portuguese; in English mode a translator
|
||||
// replaces displayed strings (text nodes and attributes) using a dictionary, and
|
||||
// keeps observing the page to translate dynamically rendered elements.
|
||||
// Variable placeholders use {n} in the dictionary (e.g. "{n} files").
|
||||
|
||||
import { EN } from './i18n-en.js';
|
||||
|
||||
@@ -16,7 +16,7 @@ export function resolveLang(pref) {
|
||||
}
|
||||
|
||||
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
|
||||
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
|
||||
// User data (names, paths, content) is never translated.
|
||||
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
|
||||
|
||||
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
|
||||
@@ -26,7 +26,7 @@ const patterns = Object.entries(EN)
|
||||
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
|
||||
})
|
||||
// padrões mais específicos (mais texto fixo) primeiro
|
||||
// more specific patterns (more fixed text) first
|
||||
.sort((a, b) => b.re.source.length - a.re.source.length);
|
||||
|
||||
const cache = new Map();
|
||||
@@ -43,7 +43,7 @@ export function tr(text) {
|
||||
const m = core.match(p.re);
|
||||
if (m) {
|
||||
let i = 0;
|
||||
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
|
||||
// captured values may also contain translatable parts (e.g. type names)
|
||||
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
|
||||
break;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function tr(text) {
|
||||
return lead + out + trail;
|
||||
}
|
||||
|
||||
const orig = new WeakMap(); // nó -> texto original em português
|
||||
const orig = new WeakMap(); // node -> original source text
|
||||
let observer = null;
|
||||
|
||||
function translateNode(n) {
|
||||
|
||||
+12
-12
@@ -1,8 +1,8 @@
|
||||
import { LOC } from './i18n.js';
|
||||
// Pré-visualização de arquivos recuperados.
|
||||
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
|
||||
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
|
||||
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
|
||||
// Preview of recovered files.
|
||||
// Content is streamed from the core (with Range support) via `url`; nothing is
|
||||
// written to disk. Supported types: image, video, audio, PDF (pdf.js), text,
|
||||
// Office/OpenDocument documents (extracted text), ZIP listing, and hex view.
|
||||
|
||||
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
|
||||
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
|
||||
@@ -44,7 +44,7 @@ async function fetchRange(url, a, b) {
|
||||
return new Uint8Array(await r.arrayBuffer());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ZIP mínimo
|
||||
// ------------------------------------------------------------------ minimal ZIP reader
|
||||
async function zipDir(url, size) {
|
||||
const tailLen = Math.min(size, 66000);
|
||||
const tail = await fetchRange(url, size - tailLen, size - 1);
|
||||
@@ -86,7 +86,7 @@ async function zipRead(url, e, cap = 8 << 20) {
|
||||
|
||||
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
|
||||
|
||||
// ------------------------------------------------------------------ renderizadores
|
||||
// ------------------------------------------------------------------ renderers
|
||||
function note(host, html, cls = '') {
|
||||
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
|
||||
}
|
||||
@@ -248,7 +248,7 @@ async function office(host, item, url, state) {
|
||||
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
|
||||
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||||
} else {
|
||||
// pptx / odp: texto por slide
|
||||
// pptx / odp: per-slide text
|
||||
let slides;
|
||||
if (ext === 'pptx') {
|
||||
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
|
||||
@@ -275,9 +275,9 @@ async function zip(host, item, url, state) {
|
||||
note(host, `${ents.length} item(ns) no arquivo compactado.`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ bancos de dados (SQLite)
|
||||
// ------------------------------------------------------------------ databases (SQLite)
|
||||
|
||||
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
|
||||
/** Integer values that look like timestamps (Unix, Chrome/WebKit, macOS) become a readable hint. */
|
||||
function tsHint(v) {
|
||||
if (!Number.isInteger(v)) return null;
|
||||
let ms = null, kind = '';
|
||||
@@ -361,7 +361,7 @@ async function database(host, item, state, large, db, onOpen) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ API
|
||||
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
|
||||
/** Renders the preview of `item` inside `host`. Returns a cleanup function. */
|
||||
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
|
||||
const ico = kind === 'video' ? '🎞' : '🎵';
|
||||
const canInline = inlineMedia && large;
|
||||
@@ -385,8 +385,8 @@ export function renderPreview(host, item, { url, large = false, thumb = false, i
|
||||
host.classList.remove('zoomable', 'actual');
|
||||
host.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
const kind = kindOf(item);
|
||||
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
|
||||
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
|
||||
// Video and audio: never instantiate <video>/<audio> in the small panel, nor in the large view
|
||||
// on Linux desktop (WebKitGTK without media plugins), as the webview process would crash.
|
||||
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
|
||||
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
|
||||
return () => { state.dead = true; };
|
||||
|
||||
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]
|
||||
name = "guara-cli"
|
||||
description = "Guará: recuperação de arquivos pelo terminal (e servidor da interface gráfica)"
|
||||
description = "Guará: command-line file recovery tool and web UI server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -20,34 +20,34 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SignCmd {
|
||||
/// Cria o par de chaves de assinatura (recusa sobrescrever uma chave existente)
|
||||
/// Generate a signing keypair (refuses to overwrite an existing key)
|
||||
Keygen {
|
||||
/// Onde gravar a chave privada (padrão: assinatura.key na pasta de configuração ou GUARA_SIGNING_KEY)
|
||||
/// Where to write the private key (default: assinatura.key in the config dir or GUARA_SIGNING_KEY)
|
||||
#[arg(long)]
|
||||
key: Option<String>,
|
||||
},
|
||||
/// Assina um arquivo e grava <arquivo>.assinatura.json
|
||||
/// Sign a file and write <file>.assinatura.json
|
||||
File {
|
||||
path: String,
|
||||
#[arg(long)]
|
||||
key: Option<String>,
|
||||
/// Nome de quem assina (padrão: GUARA_EXAMINER)
|
||||
/// Signer name (default: GUARA_EXAMINER)
|
||||
#[arg(long)]
|
||||
signer: Option<String>,
|
||||
},
|
||||
/// Confere a assinatura de um arquivo
|
||||
/// Verify a file signature
|
||||
Verify {
|
||||
path: String,
|
||||
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json)
|
||||
/// Signature file (default: <file>.assinatura.json)
|
||||
#[arg(long)]
|
||||
sig: Option<String>,
|
||||
/// Impressão digital esperada da chave, publicada pelo perito
|
||||
/// Expected key fingerprint published by the examiner
|
||||
#[arg(long)]
|
||||
trust: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Confere uma pasta de recuperação: assinatura do manifesto e hash de cada arquivo
|
||||
/// Verify a recovery folder: manifest signature and hash of every file
|
||||
Recovery {
|
||||
dir: String,
|
||||
#[arg(long)]
|
||||
@@ -59,103 +59,103 @@ enum SignCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Lista discos e pendrives detectados
|
||||
/// List detected disks and USB drives
|
||||
Devices {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Mostra partições, sistemas de arquivos, espaço não particionado e volumes cifrados
|
||||
/// Show partitions, filesystems, unpartitioned space, and encrypted volumes
|
||||
Inspect {
|
||||
/// Dispositivo (ex.: /dev/sdb, \\.\PhysicalDrive1) ou arquivo de imagem
|
||||
/// Device (e.g. /dev/sdb, \\.\PhysicalDrive1) or image file
|
||||
path: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
asm: AsmArgs,
|
||||
},
|
||||
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
|
||||
/// Disk health (SMART): status, temperature, usage, and attribute table
|
||||
Health {
|
||||
/// Dispositivo ou partição (o SMART lido é o do disco que a contém)
|
||||
/// Device or partition (reads SMART from the containing disk)
|
||||
path: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Procura arquivos apagados/perdidos (metadados + assinatura)
|
||||
/// Scan for deleted/lost files (metadata + file carving)
|
||||
Scan(ScanArgs),
|
||||
/// Continua uma varredura pausada (Ctrl+C) a partir do arquivo de sessão
|
||||
/// Resume a paused scan (Ctrl+C) from the session file
|
||||
Resume {
|
||||
session: String,
|
||||
#[command(flatten)]
|
||||
out: OutArgs,
|
||||
},
|
||||
/// Recupera arquivos de uma sessão salva
|
||||
/// Recover files from a saved session
|
||||
Recover {
|
||||
session: String,
|
||||
/// Pasta de destino (em OUTRO disco)
|
||||
/// Destination directory (on ANOTHER disk)
|
||||
dest: String,
|
||||
#[command(flatten)]
|
||||
filter: FilterArgs,
|
||||
},
|
||||
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
|
||||
/// Create a fault-tolerant disk image (skipping bad sectors) to work on a copy
|
||||
Image {
|
||||
path: String,
|
||||
dest: String,
|
||||
/// Formato da imagem: raw (padrão), e01 (Expert Witness) ou aff4 (AFF4 v1.0), com hash e metadados
|
||||
/// Image format: raw (default), e01 (Expert Witness), or aff4 (AFF4 v1.0), with hash and metadata
|
||||
#[arg(long, default_value = "raw")]
|
||||
format: String,
|
||||
/// E01: número do caso
|
||||
/// E01: case number
|
||||
#[arg(long, default_value = "")]
|
||||
case: String,
|
||||
/// E01: examinador
|
||||
/// E01: examiner
|
||||
#[arg(long, default_value = "")]
|
||||
examiner: String,
|
||||
/// E01: descrição da evidência
|
||||
/// E01: evidence description
|
||||
#[arg(long, default_value = "")]
|
||||
description: String,
|
||||
/// E01: número da evidência
|
||||
/// E01: evidence number
|
||||
#[arg(long, default_value = "")]
|
||||
evidence: String,
|
||||
/// E01: observações
|
||||
/// E01: notes
|
||||
#[arg(long, default_value = "")]
|
||||
notes: String,
|
||||
/// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só)
|
||||
/// E01: maximum segment size in MiB (0 = single file)
|
||||
#[arg(long, default_value_t = 1500)]
|
||||
segment_mib: u64,
|
||||
/// E01: compressão 0 (nenhuma) a 9
|
||||
/// E01: compression level 0 (none) to 9
|
||||
#[arg(long, default_value_t = 1)]
|
||||
compression: u32,
|
||||
},
|
||||
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
|
||||
/// Verify an E01/Ex01 image: recomputes MD5 and compares against the embedded hash
|
||||
Verify { path: String },
|
||||
/// Assina e confere arquivos (manifestos, relatórios) e pastas de recuperação com Ed25519
|
||||
/// Sign and verify files (manifests, reports) and recovery folders with Ed25519
|
||||
#[command(subcommand)]
|
||||
Sign(SignCmd),
|
||||
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
|
||||
/// Show the audit trail (who did what and when) or verify its hash chain
|
||||
Audit {
|
||||
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
|
||||
/// Audit log file (default: auditoria.jsonl in the config dir or GUARA_AUDIT_LOG)
|
||||
#[arg(long)]
|
||||
log: Option<String>,
|
||||
/// Só confere a cadeia de hashes; sai com erro se ela estiver quebrada
|
||||
/// Only verify the hash chain; exits with an error if broken
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
/// Mostra só as últimas N entradas
|
||||
/// Show only the last N entries
|
||||
#[arg(long)]
|
||||
tail: Option<usize>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Exporta os achados de uma sessão para CSV (abre no Excel)
|
||||
/// Export session findings to CSV (Excel-compatible)
|
||||
Export {
|
||||
session: String,
|
||||
/// Arquivo CSV de saída
|
||||
/// Output CSV file
|
||||
csv: String,
|
||||
#[command(flatten)]
|
||||
filter: FilterArgs,
|
||||
},
|
||||
/// Gera o relatório de recuperação (identificação do caso, origem, achados, hashes): PDF se o arquivo terminar em .pdf, senão HTML
|
||||
/// Generate the recovery report (case info, source, findings, hashes): PDF if output ends in .pdf, otherwise HTML
|
||||
Report {
|
||||
session: String,
|
||||
/// Arquivo de saída (.pdf ou .html)
|
||||
/// Output file (.pdf or .html)
|
||||
out: String,
|
||||
#[arg(long, default_value = "")]
|
||||
case: String,
|
||||
@@ -165,20 +165,20 @@ enum Cmd {
|
||||
org: String,
|
||||
#[arg(long, default_value = "")]
|
||||
notes: String,
|
||||
/// Calcula MD5/SHA-1/SHA-256 da origem e inclui no relatório (pode demorar)
|
||||
/// Compute source MD5/SHA-1/SHA-256 and include in the report (may take time)
|
||||
#[arg(long)]
|
||||
hash_source: bool,
|
||||
},
|
||||
/// Procura texto em todo o disco, inclusive espaço livre (UTF-8 e UTF-16)
|
||||
/// Search for text across the entire disk, including free space (UTF-8 and UTF-16)
|
||||
Search {
|
||||
path: String,
|
||||
/// Termos a procurar
|
||||
/// Search terms
|
||||
#[arg(required = true)]
|
||||
terms: Vec<String>,
|
||||
/// Diferencia maiúsculas de minúsculas
|
||||
/// Case-sensitive search
|
||||
#[arg(long)]
|
||||
case_sensitive: bool,
|
||||
/// Não procura em UTF-16
|
||||
/// Skip UTF-16 search
|
||||
#[arg(long)]
|
||||
no_utf16: bool,
|
||||
#[arg(long, default_value_t = 200)]
|
||||
@@ -186,11 +186,16 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
|
||||
/// Compute MD5, SHA-1, and SHA-256 of the device or image (integrity check)
|
||||
Hash { path: String },
|
||||
/// Lista as sessões salvas automaticamente
|
||||
/// List autosaved sessions
|
||||
Sessions,
|
||||
/// Abre a interface gráfica no navegador (modo local)
|
||||
/// Check for and apply `guara` CLI updates
|
||||
Update {
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
},
|
||||
/// Open the web UI in the browser (local mode)
|
||||
Ui {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
port: u16,
|
||||
@@ -201,72 +206,72 @@ enum Cmd {
|
||||
|
||||
#[derive(clap::Args, Clone)]
|
||||
struct FilterArgs {
|
||||
/// Categorias: image, document, video, audio, archive, database, other
|
||||
/// Categories: image, document, video, audio, archive, database, other
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
category: Vec<String>,
|
||||
/// Confiança mínima: low, medium, high
|
||||
/// Minimum confidence: low, medium, high
|
||||
#[arg(long, default_value = "low")]
|
||||
min_confidence: String,
|
||||
/// Só nomes/caminhos que contenham este texto
|
||||
/// Only match names/paths containing this substring
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
|
||||
/// How to organize recovered files: folders (original paths), type, date, or flat
|
||||
#[arg(long, default_value = "folders")]
|
||||
organize: String,
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Clone)]
|
||||
struct OutArgs {
|
||||
/// Salva a sessão (necessária para recover/resume)
|
||||
/// Save session file (required for recover/resume)
|
||||
#[arg(long)]
|
||||
session: Option<String>,
|
||||
/// Lista os arquivos encontrados
|
||||
/// List found files
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
/// Montagem do disco: volumes cifrados, RAID e LVM.
|
||||
/// Disk assembly: encrypted volumes, RAID, and LVM.
|
||||
#[derive(clap::Args, Clone, Default)]
|
||||
struct AsmArgs {
|
||||
/// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD
|
||||
/// Encrypted volume password (BitLocker, LUKS, FileVault 2). Prefer --ask-password or GUARA_PASSWORD
|
||||
#[arg(long = "password")]
|
||||
passwords: Vec<String>,
|
||||
/// Pergunta a senha no terminal, sem mostrar o que é digitado
|
||||
/// Prompt for the password in the terminal without echoing
|
||||
#[arg(long)]
|
||||
ask_password: bool,
|
||||
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos)
|
||||
/// BitLocker numeric recovery password (8 groups of 6 digits)
|
||||
#[arg(long = "recovery-key")]
|
||||
recovery_keys: Vec<String>,
|
||||
/// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS)
|
||||
/// Key file (BitLocker .BEK or LUKS keyfile)
|
||||
#[arg(long = "key-file")]
|
||||
key_files: Vec<String>,
|
||||
/// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume)
|
||||
/// Hex-encoded volume master key (shown by Guará when unlocking a volume)
|
||||
#[arg(long = "volume-key")]
|
||||
volume_keys: Vec<String>,
|
||||
/// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente
|
||||
/// Additional disk or image from the same set (RAID/LVM). Repeatable; "-" marks a missing disk
|
||||
#[arg(long = "member")]
|
||||
members: Vec<String>,
|
||||
/// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout
|
||||
/// Treat disks (target + --member, in order) as a metadata-less RAID and auto-detect level, chunk size, and layout
|
||||
#[arg(long)]
|
||||
raid: bool,
|
||||
/// Fixa o nível do RAID: 0, 5, 6, 1
|
||||
/// Override RAID level: 0, 5, 6, 1
|
||||
#[arg(long)]
|
||||
raid_level: Option<String>,
|
||||
/// Fixa o tamanho da faixa do RAID em KiB
|
||||
/// Override RAID chunk size in KiB
|
||||
#[arg(long)]
|
||||
raid_chunk_kb: Option<u64>,
|
||||
/// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
|
||||
/// Override RAID parity layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
|
||||
#[arg(long)]
|
||||
raid_layout: Option<String>,
|
||||
/// Ordem dos discos no RAID (índices; 0 = alvo)
|
||||
/// Disk order in the RAID array (indices; 0 = target)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
raid_order: Vec<usize>,
|
||||
/// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada)
|
||||
/// Store derived volume keys in the session file (passwords are never stored)
|
||||
#[arg(long)]
|
||||
remember_keys: bool,
|
||||
/// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir)
|
||||
/// Intact reference video from the same camera/drone to rebuild headerless MP4 moov atoms (repeatable)
|
||||
#[arg(long = "reference")]
|
||||
references: Vec<String>,
|
||||
}
|
||||
@@ -343,15 +348,15 @@ impl AsmArgs {
|
||||
|
||||
#[derive(clap::Args)]
|
||||
struct ScanArgs {
|
||||
/// Dispositivo ou imagem
|
||||
/// Device or image path
|
||||
path: String,
|
||||
/// Varredura rápida: só metadados dos sistemas de arquivos
|
||||
/// Quick scan: filesystem metadata only
|
||||
#[arg(long)]
|
||||
quick: bool,
|
||||
/// Também varre áreas ainda alocadas (mais lento)
|
||||
/// Also carve currently allocated space (slower)
|
||||
#[arg(long)]
|
||||
include_allocated: bool,
|
||||
/// Recupera tudo que casar com os filtros para esta pasta ao terminar
|
||||
/// Recover all files matching the filters into this folder when finished
|
||||
#[arg(long)]
|
||||
recover: Option<String>,
|
||||
#[command(flatten)]
|
||||
@@ -666,6 +671,26 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Update { apply } => {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
if apply {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let version = guara_core::update::apply(&exe).map_err(|e| e.to_string())?;
|
||||
println!("atualizado para {version}");
|
||||
} else {
|
||||
let status = guara_core::update::check(current).map_err(|e| e.to_string())?;
|
||||
if status.update_available {
|
||||
println!(
|
||||
"atualização disponível: {} -> {} (rode `guara update --apply`)",
|
||||
status.current_version,
|
||||
status.latest_version.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("já na última versão ({})", status.current_version);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
||||
}
|
||||
}
|
||||
@@ -684,7 +709,7 @@ fn query_from(f: &FilterArgs) -> Result<Query, String> {
|
||||
"high" | "alta" => Confidence::High,
|
||||
o => return Err(format!("confiança inválida: {o}")),
|
||||
};
|
||||
// Query aceita uma categoria; várias categorias são tratadas filtrando depois.
|
||||
// Query accepts a single category; multiple categories are filtered afterwards.
|
||||
Ok(Query {
|
||||
category: if cats.len() == 1 { Some(cats[0]) } else { None },
|
||||
min_confidence: Some(min),
|
||||
@@ -756,7 +781,7 @@ fn recover_job(
|
||||
filter: &FilterArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut q = query_from(filter)?;
|
||||
// Várias categorias: seleciona por ids.
|
||||
// Multiple categories: select by IDs.
|
||||
let cats = parse_cats(&filter.category)?;
|
||||
let mut ids: Option<Vec<u64>> = None;
|
||||
if cats.len() > 1 {
|
||||
@@ -815,7 +840,7 @@ fn recover_job(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confere o hash gravado numa imagem E01/Ex01 ou AFF4. `Some(true)` = confere.
|
||||
/// Verifies the embedded hash of an E01/Ex01 or AFF4 image. `Some(true)` = matches.
|
||||
fn verify_image(path: &str) -> Result<Option<bool>, String> {
|
||||
if path.to_ascii_lowercase().ends_with(".aff4") {
|
||||
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(path)).map_err(s)?;
|
||||
@@ -869,7 +894,7 @@ fn verify_image(path: &str) -> Result<Option<bool>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `guara audit`: lista a trilha ou confere a cadeia de hashes.
|
||||
/// `guara audit`: lists the audit trail or verifies its hash chain.
|
||||
fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) -> Result<(), String> {
|
||||
let log = match log {
|
||||
Some(p) => guara_core::audit::Log::at(p),
|
||||
@@ -924,7 +949,7 @@ fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `guara sign`: chaves, assinatura e conferência.
|
||||
/// `guara sign`: key generation, signing, and verification.
|
||||
fn sign_cmd(c: SignCmd) -> Result<(), String> {
|
||||
use guara_core::sign;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Servidor local da interface (127.0.0.1 + token): mesma UI do app Tauri.
|
||||
//! Local UI server (127.0.0.1 + token): serves the same UI as the Tauri app.
|
||||
|
||||
use guara_core::service::Engine;
|
||||
use serde_json::json;
|
||||
@@ -43,7 +43,7 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
|
||||
]);
|
||||
for req in server.incoming_requests() {
|
||||
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
|
||||
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
|
||||
// One thread per request: streaming video/audio must not block the UI.
|
||||
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "guara-core"
|
||||
description = "Núcleo de recuperação de arquivos do Guará (somente leitura)"
|
||||
description = "Guará read-only file and volume recovery core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! AFF4 (Advanced Forensic Format 4, padrão v1.0): contêiner ZIP com metadados em Turtle.
|
||||
//! AFF4 (Advanced Forensic Format 4, v1.0 standard): ZIP container with Turtle RDF metadata.
|
||||
//!
|
||||
//! Leitura de imagens lineares e mapeadas (inclusive esparsas), com blocos comprimidos por
|
||||
//! snappy, deflate, LZ4 ou sem compressão. Gravação de uma imagem linear com blocos snappy.
|
||||
//! Conjuntos em vários volumes (spanned/striped) não são suportados.
|
||||
//! Reads linear and mapped images (including sparse ranges), with chunks compressed via
|
||||
//! snappy, deflate, LZ4, or stored uncompressed. Writes linear images with snappy chunks.
|
||||
//! Multi-volume sets (spanned/striped) are not supported.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::hashing::MultiHasher;
|
||||
@@ -15,7 +15,7 @@ use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn is_aff4(head: &[u8]) -> bool {
|
||||
// ZIP local file header + primeiro nome "container.description" ou "version.txt".
|
||||
// ZIP local file header + first entry named "container.description" or "version.txt".
|
||||
head.len() > 50
|
||||
&& head.starts_with(b"PK\x03\x04")
|
||||
&& (find(&head[30..head.len().min(80)], b"container.description")
|
||||
@@ -80,7 +80,7 @@ impl Turtle<'_> {
|
||||
}
|
||||
tok.to_string()
|
||||
}
|
||||
/// Um termo: <iri>, "literal"[^^tipo], nome:prefixado ou `a`.
|
||||
/// A Turtle term: <iri>, "literal"[^^type], prefixed:name, or `a`.
|
||||
fn term(&mut self) -> Option<String> {
|
||||
self.ws();
|
||||
let c = *self.b.get(self.i)?;
|
||||
@@ -123,7 +123,7 @@ impl Turtle<'_> {
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
// ponto final de sentença colado ao termo
|
||||
// trailing statement period attached to the term
|
||||
let mut e = self.i;
|
||||
if e > s && self.b[e - 1] == b'.' {
|
||||
e -= 1;
|
||||
@@ -165,7 +165,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
|
||||
t.prefixes.insert(name, iri);
|
||||
continue;
|
||||
}
|
||||
// sujeito
|
||||
// subject
|
||||
let Some(subj) =
|
||||
(if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) {
|
||||
t.i += 1;
|
||||
@@ -200,7 +200,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
|
||||
Some(b';') => {
|
||||
t.i += 1;
|
||||
t.ws();
|
||||
// ";" final antes de "."
|
||||
// trailing ";" before "."
|
||||
if t.b.get(t.i) == Some(&b'.') {
|
||||
t.i += 1;
|
||||
break;
|
||||
@@ -217,7 +217,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ leitor
|
||||
// ------------------------------------------------------------------ reader
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Method {
|
||||
@@ -265,7 +265,7 @@ pub struct Aff4Source {
|
||||
targets: Vec<Target>,
|
||||
map: Vec<MapEntry>,
|
||||
size: u64,
|
||||
/// Metadados legíveis: caso, examinador, notas, hashes.
|
||||
/// Human-readable metadata: case, examiner, notes, hashes.
|
||||
pub info: HashMap<String, String>,
|
||||
cache: Mutex<Option<(usize, u64, Vec<u8>)>>,
|
||||
index_cache: Mutex<HashMap<(usize, u64), std::sync::Arc<Vec<u8>>>>,
|
||||
@@ -344,7 +344,7 @@ impl Aff4Source {
|
||||
}
|
||||
}
|
||||
let data_urn = image.get(&ds).unwrap().to_string();
|
||||
// Hashes gravados no fluxo de dados (ou no mapa).
|
||||
// Hashes recorded on the data stream (or map).
|
||||
for r in res
|
||||
.values()
|
||||
.filter(|r| r.get(&format!("{NS}hash")).is_some())
|
||||
@@ -443,7 +443,7 @@ impl Aff4Source {
|
||||
})
|
||||
}
|
||||
|
||||
/// MD5 do primeiro fluxo de blocos (o que o AFF4 grava em `aff4:hash`), para conferir a integridade.
|
||||
/// MD5 of the first bevy stream (stored by AFF4 in `aff4:hash`) for integrity verification.
|
||||
pub fn verify_stream_md5(&self) -> Result<Option<bool>> {
|
||||
let Some(want) = self.info.get("md5").cloned() else {
|
||||
return Ok(None);
|
||||
@@ -604,7 +604,7 @@ impl Source for Aff4Source {
|
||||
let pos = offset + done as u64;
|
||||
let i = self.map.partition_point(|e| e.start + e.len <= pos);
|
||||
let Some(e) = self.map.get(i).filter(|e| e.start <= pos) else {
|
||||
// vão do mapa: zeros até a próxima entrada
|
||||
// map gap: zeros up to the next entry
|
||||
let next = self.map.get(i).map(|e| e.start).unwrap_or(self.size);
|
||||
let n = ((next - pos) as usize).min(want - done);
|
||||
buf[done..done + n].fill(0);
|
||||
@@ -626,7 +626,7 @@ impl Source for Aff4Source {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ gravação
|
||||
// ------------------------------------------------------------------ writer
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Aff4Options {
|
||||
@@ -703,7 +703,7 @@ fn esc(s: &str) -> String {
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// Grava uma imagem AFF4 linear (um fluxo de blocos snappy) de `src`.
|
||||
/// Writes a linear AFF4 image (single snappy bevy stream) from `src`.
|
||||
pub fn write_aff4(
|
||||
src: &dyn Source,
|
||||
dest: &Path,
|
||||
@@ -776,11 +776,11 @@ pub fn write_aff4(
|
||||
}
|
||||
}
|
||||
hasher.update(b);
|
||||
// bloco curto (último) é guardado como está; comprimido só se ficar menor
|
||||
// short (final) chunk is stored as-is; compressed only if smaller
|
||||
let comp = enc_snap
|
||||
.compress_vec(b)
|
||||
.map_err(|e| Error::msg(e.to_string()))?;
|
||||
// Só um bloco cheio pode ficar sem compressão (comprimento == tamanho do bloco).
|
||||
// Only a full chunk can be stored uncompressed (length == chunk_size).
|
||||
let data: &[u8] = if want as u64 == chunk && comp.len() >= want {
|
||||
b
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Monta o disco virtual a partir do que o usuário informou: membros extras (RAID/LVM) e
|
||||
//! senhas ou chaves para volumes cifrados. É a única porta de entrada para varrer e recuperar
|
||||
//! discos que não são "simples".
|
||||
//! Assembles a virtual disk from user-supplied inputs: extra members (RAID/LVM) and
|
||||
//! passwords or keys for encrypted volumes. Single entry point for scanning and recovering
|
||||
//! non-trivial disk layouts.
|
||||
|
||||
use crate::crypt::{self, Secret, UnlockError};
|
||||
use crate::error::{Error, Result};
|
||||
@@ -21,17 +21,17 @@ pub enum SecretInput {
|
||||
Recovery {
|
||||
value: String,
|
||||
},
|
||||
/// Caminho de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
|
||||
/// Path to a key file (BitLocker .BEK, LUKS keyfile).
|
||||
Keyfile {
|
||||
path: String,
|
||||
},
|
||||
/// Chave do volume em hexadecimal.
|
||||
/// Hex-encoded volume master key.
|
||||
Key {
|
||||
hex: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Chave de volume guardada na sessão (só quando o usuário pede para lembrar).
|
||||
/// Volume key saved in the session (only when the user opts in to remember keys).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SavedKey {
|
||||
pub start: u64,
|
||||
@@ -39,29 +39,29 @@ pub struct SavedKey {
|
||||
pub key_hex: String,
|
||||
}
|
||||
|
||||
/// RAID sem metadados: o que o usuário sabe; o resto é descoberto.
|
||||
/// Metadata-less RAID parameters: user-supplied overrides; missing fields are auto-detected.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RaidSpec {
|
||||
pub level: Option<crate::raid::Level>,
|
||||
/// Tamanho da faixa em KiB.
|
||||
/// Chunk (stripe) size in KiB.
|
||||
pub chunk_kb: Option<u64>,
|
||||
pub layout: Option<crate::raid::Layout>,
|
||||
/// Ordem dos discos: índice 0 é o alvo principal, depois `members` na ordem dada.
|
||||
/// Disk order: index 0 is the primary target, followed by `members` in the given order.
|
||||
pub order: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Assembly {
|
||||
/// Trata o alvo e `members` como discos de um RAID sem metadados (parâmetros descobertos).
|
||||
/// Treat target and `members` as a metadata-less RAID array (parameters auto-detected).
|
||||
#[serde(default)]
|
||||
pub raid: Option<RaidSpec>,
|
||||
/// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal.
|
||||
/// Additional disks or images belonging to the same set (RAID/LVM) besides the primary target.
|
||||
#[serde(default)]
|
||||
pub members: Vec<String>,
|
||||
/// Senhas e chaves: o `snapshot` da sessão as descarta antes de gravar.
|
||||
/// Passwords and keys: stripped by session `snapshot` before saving to disk.
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<SecretInput>,
|
||||
/// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo.
|
||||
/// Persist derived volume keys in the session so it can be reopened without re-prompting.
|
||||
#[serde(default)]
|
||||
pub remember_keys: bool,
|
||||
#[serde(default)]
|
||||
@@ -96,13 +96,13 @@ pub struct Assembled {
|
||||
pub source: Arc<dyn Source>,
|
||||
pub unlocked: Vec<UnlockInfo>,
|
||||
pub locked: Vec<LockedInfo>,
|
||||
/// Chaves derivadas, para guardar na sessão se o usuário permitir.
|
||||
/// Derived keys to persist in the session when permitted by the user.
|
||||
pub keys: Vec<SavedKey>,
|
||||
/// Volumes lógicos / partições extras que não estão na tabela do disco.
|
||||
/// Logical volumes / extra partitions not present in the physical partition table.
|
||||
pub extra_parts: Vec<PartInfo>,
|
||||
/// Partições reais que são só contêineres (membros de RAID/LVM já remontados).
|
||||
/// Physical partitions that act solely as containers (reassembled RAID/LVM members).
|
||||
pub covered: Vec<u64>,
|
||||
/// O disco principal é só um membro de RAID: não varrer o disco cru.
|
||||
/// Primary disk is merely a RAID member: skip scanning the raw disk directly.
|
||||
pub skip_primary: bool,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
@@ -145,7 +145,7 @@ fn try_unlock(window: Arc<dyn Source>, fs: FsKind, secrets: &[Secret]) -> crypt:
|
||||
}
|
||||
}
|
||||
|
||||
/// Abre `path` e aplica a montagem pedida.
|
||||
/// Opens `path` and applies the requested assembly.
|
||||
pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
|
||||
let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?;
|
||||
assemble_source(base, asm)
|
||||
@@ -153,7 +153,7 @@ pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
|
||||
|
||||
const MIB: u64 = 1 << 20;
|
||||
|
||||
/// Coloca `src` em `start`; se cair dentro de uma região virtual, a substitui por dentro.
|
||||
/// Maps `src` at `start`; if it falls inside an existing virtual region, replaces it nestedly.
|
||||
fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: String) {
|
||||
let len = src.size();
|
||||
if let Some(r) = regions
|
||||
@@ -181,10 +181,10 @@ fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: Str
|
||||
});
|
||||
}
|
||||
|
||||
/// Candidato a membro de RAID/LVM: uma janela de disco (inteiro ou partição).
|
||||
/// Candidate RAID/LVM member: a disk window (whole disk or partition).
|
||||
struct Window {
|
||||
src: Arc<dyn Source>,
|
||||
/// Início no espaço do disco virtual, se a janela faz parte dele.
|
||||
/// Start offset in the virtual disk address space, if part of it.
|
||||
primary_start: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
|
||||
}
|
||||
let mut done_containers: std::collections::BTreeSet<String> = Default::default();
|
||||
let mut tried_pool = false;
|
||||
// Rodadas: o que foi aberto/montado pode conter outro volume cifrado, RAID ou LVM.
|
||||
// Passes: newly unlocked/assembled volumes may themselves contain encrypted volumes, RAID, or LVM.
|
||||
for round in 0..4 {
|
||||
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
|
||||
let layout = partition::analyze(current.as_ref(), false);
|
||||
@@ -260,7 +260,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
|
||||
}
|
||||
let mut progress = false;
|
||||
|
||||
// 1) RAID e LVM: reúne janelas do disco principal e dos membros extras.
|
||||
// 1) RAID and LVM: gather windows from the primary disk and extra members.
|
||||
if round == 0 || !tried_pool || !out.extra_parts.is_empty() {
|
||||
let mut pool: Vec<Window> = vec![];
|
||||
pool.push(Window {
|
||||
@@ -381,7 +381,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Desbloqueio de volumes cifrados.
|
||||
// 2) Unlock encrypted volumes.
|
||||
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
|
||||
let layout = partition::analyze(current.as_ref(), false);
|
||||
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
|
||||
@@ -438,7 +438,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
|
||||
}
|
||||
if !regions.is_empty() {
|
||||
out.source = Arc::new(Overlay::new(base, regions));
|
||||
// Atualiza o que se sabe sobre as partições virtuais (algumas foram desbloqueadas depois).
|
||||
// Refresh virtual partition metadata (some volumes may have been unlocked in a later pass).
|
||||
for p in &mut out.extra_parts {
|
||||
if let Some((fs, len)) = partition::detect_fs(out.source.as_ref(), p.start) {
|
||||
p.fs = fs;
|
||||
@@ -454,7 +454,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Anexa um volume virtual depois do fim do disco e registra as partições que ele contém.
|
||||
/// Appends a virtual volume past the end of the disk and registers its inner partitions.
|
||||
fn add_virtual(
|
||||
regions: &mut Vec<Region>,
|
||||
next: &mut u64,
|
||||
@@ -496,7 +496,7 @@ fn add_virtual(
|
||||
});
|
||||
}
|
||||
|
||||
/// Escolhe os parâmetros do RAID: o que o usuário deu manda; o resto vem da detecção.
|
||||
/// Resolves RAID parameters: user overrides take precedence; remaining fields come from detection.
|
||||
fn pick_raid(
|
||||
disks: &[Arc<dyn Source>],
|
||||
spec: &RaidSpec,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Trilha de auditoria: cada ação do examinador (abrir uma origem, varrer, recuperar, criar
|
||||
//! imagem, exportar) vira uma linha JSON com quem, quando e o quê.
|
||||
//! Audit trail: each examiner action (opening a source, scanning, recovering, imaging,
|
||||
//! exporting) is recorded as a JSON line with who, when, and what.
|
||||
//!
|
||||
//! As linhas são encadeadas por hash: o `hash` de cada uma é o SHA-256 do `hash` da anterior
|
||||
//! seguido do corpo da própria linha. Alterar ou apagar uma linha do meio quebra a cadeia e
|
||||
//! `guara audit --verify` aponta onde. Cortar o fim do arquivo não é detectável sozinho; para
|
||||
//! isso, guarde o hash da última entrada (a "cabeça") fora da máquina, por exemplo no relatório.
|
||||
//! Entries are hash-chained: each entry's `hash` is the SHA-256 of the previous `hash`
|
||||
//! followed by the entry body itself. Modifying or deleting an intermediate line breaks the chain
|
||||
//! and `guara audit --verify` pinpoints where. Tail truncation cannot be detected from the file
|
||||
//! alone; store the last entry hash (the "head") off-machine, e.g. in the forensic report.
|
||||
//!
|
||||
//! O arquivo fica na pasta de configuração (`auditoria.jsonl`) ou onde `GUARA_AUDIT_LOG`
|
||||
//! apontar. Segredos (senhas, chaves) nunca entram no registro.
|
||||
//! Stored in the config directory (`auditoria.jsonl`) or at `GUARA_AUDIT_LOG`.
|
||||
//! Secrets (passwords, keys) are never written to the log.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,11 +32,11 @@ struct Body<'a> {
|
||||
prev: &'a str,
|
||||
}
|
||||
|
||||
/// Uma linha da trilha.
|
||||
/// A single audit log entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub seq: u64,
|
||||
/// UTC, ISO 8601.
|
||||
/// UTC timestamp in ISO 8601 format.
|
||||
pub time: String,
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
@@ -49,14 +49,14 @@ pub struct Entry {
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Resultado de conferir a cadeia.
|
||||
/// Result of verifying the hash chain.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Verification {
|
||||
pub entries: usize,
|
||||
pub ok: bool,
|
||||
/// Entrada (1, 2, ...) onde a cadeia quebra e o motivo.
|
||||
/// 1-based entry index where the chain breaks and the reason.
|
||||
pub broken_at: Option<(u64, String)>,
|
||||
/// Hash da última entrada; guarde-o fora da máquina para detectar truncamento.
|
||||
/// Hash of the last entry; store off-machine to detect tail truncation.
|
||||
pub head: String,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ fn sha_hex(prev: &str, body: &str) -> String {
|
||||
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// UTC em ISO 8601 a partir de segundos Unix (conversão civil de Howard Hinnant).
|
||||
/// Formats Unix seconds as ISO 8601 UTC (Howard Hinnant civil calendar algorithm).
|
||||
pub fn utc_iso(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
@@ -106,7 +106,7 @@ fn env(k: &str) -> Option<String> {
|
||||
std::env::var(k).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Usuário do sistema por trás da ação (o que chamou sudo, quando houver).
|
||||
/// Underlying OS user performing the action (the user who invoked sudo, if applicable).
|
||||
fn current_user() -> String {
|
||||
env("SUDO_USER")
|
||||
.or_else(|| env("USER"))
|
||||
@@ -127,7 +127,7 @@ fn host_name() -> String {
|
||||
.unwrap_or_else(|| "?".into())
|
||||
}
|
||||
|
||||
/// Última linha não vazia do arquivo, lendo só o fim.
|
||||
/// Reads the last non-empty line of the file by seeking near the end.
|
||||
fn last_line(path: &Path) -> std::io::Result<Option<String>> {
|
||||
let mut f = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
@@ -158,7 +158,7 @@ impl Log {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// `GUARA_AUDIT_LOG` ou `auditoria.jsonl` na pasta de configuração.
|
||||
/// `GUARA_AUDIT_LOG` or `auditoria.jsonl` in the configuration directory.
|
||||
pub fn default_path() -> PathBuf {
|
||||
match env("GUARA_AUDIT_LOG") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
@@ -174,7 +174,7 @@ impl Log {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Acrescenta uma entrada ao fim da cadeia.
|
||||
/// Appends a new entry to the end of the hash chain.
|
||||
pub fn append(&self, action: &str, detail: Value) -> Result<Entry> {
|
||||
let (seq, prev) = match last_line(&self.path)? {
|
||||
None => (1, GENESIS.to_string()),
|
||||
@@ -227,7 +227,7 @@ impl Log {
|
||||
})
|
||||
}
|
||||
|
||||
/// Todas as entradas, na ordem. Linha ilegível vira erro (use [`Log::verify`] para saber onde).
|
||||
/// Returns all entries in order. Malformed lines return an error (use [`Log::verify`] to locate).
|
||||
pub fn entries(&self) -> Result<Vec<Entry>> {
|
||||
let text = match std::fs::read_to_string(&self.path) {
|
||||
Ok(t) => t,
|
||||
@@ -244,7 +244,7 @@ impl Log {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Confere a numeração, o encadeamento e o hash de cada linha.
|
||||
/// Verifies sequence numbering, hash chaining, and each entry's digest.
|
||||
pub fn verify(&self) -> Result<Verification> {
|
||||
let text = match std::fs::read_to_string(&self.path) {
|
||||
Ok(t) => t,
|
||||
@@ -298,15 +298,15 @@ impl Log {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registra uma ação na trilha padrão. Falha ao gravar não interrompe a operação, mas é
|
||||
/// avisada no stderr: uma ação sem registro precisa ser notada.
|
||||
/// Records an action in the default audit trail. Write failures do not abort the operation
|
||||
/// but are logged to stderr so unrecorded actions do not go unnoticed.
|
||||
pub fn record(action: &str, detail: Value) {
|
||||
if let Err(e) = Log::open_default().append(action, detail) {
|
||||
eprintln!("aviso: não foi possível gravar a trilha de auditoria ({action}): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Atalho para detalhes de um caminho de origem.
|
||||
/// Helper to build details JSON for a source path.
|
||||
pub fn path_detail(path: &str) -> Value {
|
||||
json!({ "path": path })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Carving por assinatura/estrutura sobre intervalos de bytes.
|
||||
//! Signature- and structure-based file carving over byte ranges.
|
||||
|
||||
use crate::signatures::{category_for_ext, detect, might_start, Hit};
|
||||
use crate::source::Source;
|
||||
@@ -14,9 +14,9 @@ pub struct CarveCfg<'a> {
|
||||
pub categories: &'a [Category],
|
||||
}
|
||||
|
||||
/// Varre [start, end) a partir de `*next`. Chama `on_hit(offset, hit)` e retorna
|
||||
/// `true` se terminou o intervalo (`false` se cancelado).
|
||||
/// `advance(done_bytes)` informa o progresso dentro do intervalo.
|
||||
/// Scans `[start, end)` starting from `*next`. Calls `on_hit(offset, hit)` and returns
|
||||
/// `true` if the range completed (`false` if cancelled).
|
||||
/// `advance(done_bytes)` reports progress within the range.
|
||||
pub fn carve_range(
|
||||
src: &dyn Source,
|
||||
start: u64,
|
||||
@@ -28,7 +28,7 @@ pub fn carve_range(
|
||||
) -> CarveEnd {
|
||||
let end = end.min(src.size());
|
||||
let mut pos = (*next).max(start);
|
||||
// Alinha ao setor.
|
||||
// Align to sector boundary.
|
||||
pos = pos.div_ceil(SECTOR) * SECTOR;
|
||||
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
|
||||
let mut skip_until = 0u64;
|
||||
@@ -45,13 +45,13 @@ pub fn carve_range(
|
||||
n
|
||||
}
|
||||
Err(_) => {
|
||||
// Várias falhas seguidas = dispositivo sumiu: para com o ponto de retomada salvo.
|
||||
// Multiple consecutive read failures = device disconnected: stop with resume point saved.
|
||||
fails += 1;
|
||||
if fails >= 4 {
|
||||
*next = pos - (fails as u64 - 1) * CHUNK as u64;
|
||||
return CarveEnd::IoLost;
|
||||
}
|
||||
// Setor ruim isolado: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
|
||||
// Isolated bad sector: skip a full block and continue (forensic imaging handles bad sectors better).
|
||||
pos += CHUNK as u64;
|
||||
ev.advance(CHUNK as u64);
|
||||
*next = pos;
|
||||
@@ -100,30 +100,30 @@ pub fn carve_range(
|
||||
CarveEnd::Done
|
||||
}
|
||||
|
||||
/// Como o carving de um intervalo terminou.
|
||||
/// Outcome of carving a byte range.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarveEnd {
|
||||
Done,
|
||||
Cancelled,
|
||||
/// Várias leituras seguidas falharam: o dispositivo provavelmente foi desconectado.
|
||||
/// Multiple consecutive reads failed: the device was likely disconnected.
|
||||
IoLost,
|
||||
}
|
||||
|
||||
/// Receptor de eventos do carving.
|
||||
/// Receiver for file-carving events.
|
||||
pub trait CarveEvents {
|
||||
fn advance(&mut self, bytes: u64);
|
||||
/// Cada trecho lido, antes de procurar assinaturas (para indexar o que serve a recomposição).
|
||||
/// Called for each chunk read before signature scanning (used to index candidate defrag clusters).
|
||||
fn chunk(&mut self, _abs: u64, _data: &[u8]) {}
|
||||
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction;
|
||||
}
|
||||
|
||||
/// O que fazer depois de um acerto.
|
||||
/// Action to take after a signature hit.
|
||||
pub enum HitAction {
|
||||
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão).
|
||||
/// Skip past the recognized file content (default behavior).
|
||||
Skip,
|
||||
/// Pula só estes bytes (arquivo cujo resto não é confiável).
|
||||
/// Skip only this many bytes (when the remainder of the file is unreliable).
|
||||
SkipLen(u64),
|
||||
/// Segue setor a setor (acerto descartado).
|
||||
/// Continue sector by sector (hit discarded).
|
||||
Continue,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! BitLocker (Windows 7 em diante, inclusive BitLocker To Go): AES-CBC com ou sem difusor
|
||||
//! Elephant, e AES-XTS. Protetores: senha, chave de recuperação, chave de inicialização (.BEK),
|
||||
//! chave limpa (proteção suspensa) e chave do volume já conhecida. TPM não é suportado.
|
||||
//! BitLocker (Windows 7+, including BitLocker To Go): AES-CBC with or without Elephant
|
||||
//! diffuser, and AES-XTS. Protectors: password, recovery password, startup key (.BEK),
|
||||
//! clear key (suspended protection), and raw volume master key. TPM is not supported.
|
||||
|
||||
use super::*;
|
||||
use crate::source::{read_vec, SubSource};
|
||||
@@ -23,12 +23,12 @@ const GUID_EOW: [u8; 16] = [
|
||||
0x3b, 0x4d, 0xa8, 0x92, 0x80, 0xdd, 0x0e, 0x4d, 0x9e, 0x4e, 0xb1, 0xe3, 0x28, 0x4e, 0xae, 0xd8,
|
||||
];
|
||||
|
||||
/// Posição da tabela de deslocamentos FVE, se for BitLocker (normal ou To Go).
|
||||
/// Offset of the FVE metadata table if the volume is BitLocker (standard or To Go).
|
||||
fn offsets_pos(vbr: &[u8]) -> Option<usize> {
|
||||
if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" {
|
||||
return Some(176);
|
||||
}
|
||||
// BitLocker To Go: parece FAT ("MSWIN4.1"), mas guarda o GUID do BitLocker no setor de boot.
|
||||
// BitLocker To Go: looks like FAT ("MSWIN4.1") but stores the BitLocker GUID in the boot sector.
|
||||
if vbr.len() >= 464 && (vbr[424..440] == GUID_NORMAL || vbr[424..440] == GUID_EOW) {
|
||||
return Some(440);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ fn words(buf: &[u8]) -> Vec<u32> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Decifragem (ordem do dm-crypt do Linux): somas em ordem crescente.
|
||||
// Decryption (Linux dm-crypt order): additions in ascending order.
|
||||
fn diffuser_a_decrypt(d: &mut [u32]) {
|
||||
let n = d.len();
|
||||
for _ in 0..5 {
|
||||
@@ -77,7 +77,7 @@ fn diffuser_b_decrypt(d: &mut [u32]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decifra um setor AES-CBC + difusor Elephant (BitLocker Vista/7 sem XTS).
|
||||
/// Decrypts an AES-CBC + Elephant diffuser sector (BitLocker Vista/7 pre-XTS).
|
||||
pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off: u64) {
|
||||
let mut iv = [0u8; 16];
|
||||
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
|
||||
@@ -89,7 +89,7 @@ pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off:
|
||||
for (i, w) in d.iter().enumerate() {
|
||||
buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
// Chave do setor: duas cifragens do deslocamento com a chave de ajuste.
|
||||
// Sector key: two encryptions of the byte offset with the tweak key.
|
||||
let mut k0 = [0u8; 16];
|
||||
k0[..8].copy_from_slice(&byte_off.to_le_bytes());
|
||||
let mut k1 = k0;
|
||||
@@ -106,7 +106,7 @@ pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off:
|
||||
|
||||
// ------------------------------------------------------------------ CCM
|
||||
|
||||
/// AES-CCM (nonce de 12 bytes, tag de 16): `blob` = tag cifrada (16) + dados cifrados.
|
||||
/// AES-CCM (12-byte nonce, 16-byte tag): `blob` = encrypted tag (16) + ciphertext.
|
||||
fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
|
||||
if nonce.len() != 12 || blob.len() < 16 {
|
||||
return None;
|
||||
@@ -130,7 +130,7 @@ fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
|
||||
let s = ctr(bi as u32 + 1);
|
||||
pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b));
|
||||
}
|
||||
// CBC-MAC sobre B0 + dados.
|
||||
// CBC-MAC over B0 + plaintext.
|
||||
let mut b0 = [0u8; 16];
|
||||
b0[0] = 0x3A;
|
||||
b0[1..13].copy_from_slice(nonce);
|
||||
@@ -146,7 +146,7 @@ fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
|
||||
(x == tag).then_some(pt)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- chaves
|
||||
// --------------------------------------------------------------- keys
|
||||
|
||||
fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] {
|
||||
let mut last = [0u8; 32];
|
||||
@@ -169,7 +169,7 @@ fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] {
|
||||
stretch(&init, salt)
|
||||
}
|
||||
|
||||
/// Chave de recuperação: 8 blocos de 6 dígitos, cada um múltiplo de 11.
|
||||
/// Recovery password: 8 groups of 6 digits, each divisible by 11.
|
||||
fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
|
||||
let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if digits.len() != 48 {
|
||||
@@ -188,7 +188,7 @@ fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
|
||||
Some(stretch(&init, salt))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- metadados
|
||||
// ----------------------------------------------------------- metadata
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
@@ -250,7 +250,7 @@ fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
|
||||
}
|
||||
let version = le16(&h, 10);
|
||||
if version != 2 {
|
||||
return None; // Vista (versão 1) não suportado
|
||||
return None; // Vista (version 1) not supported
|
||||
}
|
||||
let encrypted_size = le64(&h, 16);
|
||||
let hdr_sectors = le32(&h, 28) as u64;
|
||||
@@ -293,7 +293,7 @@ fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Chave de um arquivo .BEK (chave de inicialização).
|
||||
/// Extracts the key from a .BEK startup key file.
|
||||
fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
|
||||
if file.len() < 48 {
|
||||
return None;
|
||||
@@ -313,7 +313,7 @@ fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
|
||||
|
||||
fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> {
|
||||
let mut out: Vec<Vec<u8>> = vec![];
|
||||
// Chave limpa: o próprio protetor guarda a chave em claro.
|
||||
// Clear key: the protector itself stores the unencrypted key.
|
||||
if v.protection == 0 {
|
||||
for n in &v.nested {
|
||||
if n.vtype == 1 && n.data.len() >= 36 {
|
||||
@@ -350,7 +350,7 @@ fn decrypt_key_entry(key: &[u8], e: &Entry) -> Option<Vec<u8>> {
|
||||
return None;
|
||||
}
|
||||
let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?;
|
||||
// payload = cabeçalho de entrada (8) + método (4) + chave
|
||||
// payload = entry header (8) + method (4) + key
|
||||
(pt.len() > 12).then(|| pt[12..].to_vec())
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
}
|
||||
let bps = le16(&vbr, 11).max(512) as u64;
|
||||
let pos = offsets_pos(&vbr).unwrap_or(176);
|
||||
// Três cópias dos metadados; usa a primeira legível.
|
||||
// Three metadata copies exist; use the first valid one.
|
||||
let mut fve = None;
|
||||
let mut meta_offsets = vec![];
|
||||
for i in 0..3 {
|
||||
@@ -481,7 +481,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
let len = src.size();
|
||||
let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps);
|
||||
let mut patches = vec![];
|
||||
// Áreas de metadados FVE: o Windows não as expõe como dados.
|
||||
// FVE metadata regions: Windows masks them out as zeros.
|
||||
for off in meta_offsets {
|
||||
patches.push(Patch {
|
||||
at: off,
|
||||
@@ -492,7 +492,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
});
|
||||
}
|
||||
if fve.volume_header_size > 0 && fve.volume_header_offset > 0 {
|
||||
// O início do volume original foi movido para outro lugar e cifrado lá.
|
||||
// The original volume header was relocated and encrypted elsewhere.
|
||||
patches.push(Patch {
|
||||
at: fve.volume_header_offset,
|
||||
len: fve.volume_header_size,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! FileVault 2 sobre Core Storage (volumes HFS+ cifrados do macOS 10.7 a 10.12 e Fusion Drives).
|
||||
//! Senha ou chave do volume; chave de recuperação institucional não é suportada.
|
||||
//! FileVault 2 on Core Storage (encrypted HFS+ volumes from macOS 10.7–10.12 and Fusion Drives).
|
||||
//! Supports user password or volume key; institutional recovery keys are not supported.
|
||||
|
||||
use super::*;
|
||||
use crate::source::{read_vec, SubSource};
|
||||
@@ -18,7 +18,7 @@ pub fn detect(head: &[u8]) -> bool {
|
||||
head.len() >= 512 && head[88] == 0x43 && head[89] == 0x53 && head[10] == 0x10
|
||||
}
|
||||
|
||||
/// Desembrulha uma chave AES-128 de 24 bytes (RFC 3394).
|
||||
/// Unwraps a 24-byte AES-128 key (RFC 3394).
|
||||
fn unwrap_key(kek: &[u8], wrapped: &[u8]) -> Option<[u8; 16]> {
|
||||
let aes = AesAny::new(kek)?;
|
||||
if wrapped.len() != 24 {
|
||||
@@ -73,7 +73,7 @@ fn b64(s: &str) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Valor de `<key>NOME</key><tipo ...>VALOR</tipo>` num plist simples (primeira ocorrência com valor).
|
||||
/// Extracts the value of `<key>NAME</key><type ...>VALUE</type>` from a simple XML plist.
|
||||
fn plist_value(xml: &str, key: &str) -> Option<String> {
|
||||
let k = format!("<key>{key}</key>");
|
||||
let mut from = 0;
|
||||
@@ -163,7 +163,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) {
|
||||
break;
|
||||
}
|
||||
// XTS sobre o bloco inteiro, com o número do bloco como ajuste inicial.
|
||||
// XTS across the full block, using the block number as the initial tweak.
|
||||
md_cipher.decrypt_sector(&mut b, i, 0);
|
||||
match u16::from_le_bytes([b[10], b[11]]) {
|
||||
0x0019 => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! LUKS1 e LUKS2 (cifras AES; PBKDF2 e Argon2; hashes SHA-1/256/512 e RIPEMD-160).
|
||||
//! LUKS1 and LUKS2 (AES ciphers; PBKDF2 and Argon2; SHA-1/256/512 and RIPEMD-160 hashes).
|
||||
|
||||
use super::*;
|
||||
use crate::source::{read_vec, SubSource};
|
||||
@@ -60,7 +60,7 @@ impl Hash {
|
||||
}
|
||||
}
|
||||
|
||||
/// Difusão do "anti-forensic splitter".
|
||||
/// Anti-forensic splitter diffusion step.
|
||||
fn diffuse(block: &mut [u8], h: Hash) {
|
||||
let ds = h.size();
|
||||
let mut i = 0u32;
|
||||
@@ -74,7 +74,7 @@ fn diffuse(block: &mut [u8], h: Hash) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompõe a chave a partir das `stripes` faixas (AFmerge).
|
||||
/// Merges key material across `stripes` anti-forensic stripes (AFmerge).
|
||||
pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; block];
|
||||
for i in 0..stripes.saturating_sub(1) {
|
||||
@@ -87,7 +87,7 @@ pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
|
||||
buf.iter().zip(last).map(|(a, b)| a ^ b).collect()
|
||||
}
|
||||
|
||||
/// Cifra descrita por texto como "aes-xts-plain64" ou "aes-cbc-essiv:sha256".
|
||||
/// Parses cipher spec strings such as "aes-xts-plain64" or "aes-cbc-essiv:sha256".
|
||||
pub fn parse_cipher(spec: &str, key: &[u8]) -> std::result::Result<SectorCipher, UnlockError> {
|
||||
let (alg, rest) = spec
|
||||
.split_once('-')
|
||||
@@ -279,7 +279,7 @@ fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> {
|
||||
v.get(k)?.as_str()
|
||||
}
|
||||
|
||||
/// Números no JSON do LUKS2 vêm como texto (inteiros grandes) ou número.
|
||||
/// Numbers in LUKS2 JSON metadata appear either as strings (for 64-bit ints) or numbers.
|
||||
fn jnum(v: &serde_json::Value, k: &str) -> Option<u64> {
|
||||
let x = v.get(k)?;
|
||||
x.as_u64().or_else(|| x.as_str()?.parse().ok())
|
||||
@@ -463,7 +463,7 @@ fn unlock_v2(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Atalho: abre uma janela da origem como LUKS.
|
||||
/// Helper: opens a byte window of the source as a LUKS volume.
|
||||
pub fn unlock_window(
|
||||
base: Arc<dyn Source>,
|
||||
start: u64,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Cifras de disco (somente leitura) e desbloqueio de volumes cifrados.
|
||||
//! Read-only disk ciphers and encrypted volume unlocking.
|
||||
//!
|
||||
//! An unlocked volume is exposed as a standard `Source`: the rest of Guará
|
||||
//! (scanning, recovery) sees the plaintext filesystem transparently.
|
||||
//!
|
||||
//! O volume desbloqueado é exposto como uma `Source` comum: o restante do Guará
|
||||
//! (varredura, recuperação) enxerga o sistema de arquivos em claro, sem saber
|
||||
//! que existe cifra por baixo.
|
||||
|
||||
pub mod bitlocker;
|
||||
pub mod filevault;
|
||||
@@ -14,7 +14,7 @@ use aes::{Aes128, Aes192, Aes256};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// AES com tamanho de chave escolhido em tempo de execução.
|
||||
/// AES cipher with runtime key size selection.
|
||||
#[derive(Clone)]
|
||||
pub enum AesAny {
|
||||
A128(Aes128),
|
||||
@@ -55,21 +55,21 @@ fn xor16(a: &mut [u8; 16], b: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Como o vetor de inicialização de cada setor é calculado.
|
||||
/// Sector initialization vector (IV) generation mode.
|
||||
#[derive(Clone)]
|
||||
pub enum Iv {
|
||||
/// Número do setor (de 512 bytes) em 64 bits little-endian.
|
||||
/// 512-byte sector number as 64-bit little-endian.
|
||||
Plain64,
|
||||
/// Número do setor em 32 bits little-endian.
|
||||
/// Sector number as 32-bit little-endian.
|
||||
Plain32,
|
||||
Plain64Be,
|
||||
/// ESSIV: AES(SHA-256(chave), setor).
|
||||
/// ESSIV: AES(SHA-256(key), sector).
|
||||
Essiv(AesAny),
|
||||
/// Número da unidade de dados (setor de `usize` bytes) em 64 bits little-endian.
|
||||
/// Data unit number (`usize`-byte sector) as 64-bit little-endian.
|
||||
Unit(u64),
|
||||
/// BitLocker (Windows 10 1511+): AES(chave, offset em bytes).
|
||||
/// BitLocker (Windows 10 1511+): AES(key, byte offset).
|
||||
Eboiv(AesAny),
|
||||
/// BitLocker CBC com difusor Elephant: IV = AES(chave, offset em bytes).
|
||||
/// BitLocker CBC with Elephant diffuser: IV = AES(key, byte offset).
|
||||
Bitlocker(AesAny),
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ impl Iv {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cifra aplicada setor a setor.
|
||||
/// Sector-level disk cipher.
|
||||
#[derive(Clone)]
|
||||
pub enum SectorCipher {
|
||||
Xts {
|
||||
@@ -106,7 +106,7 @@ pub enum SectorCipher {
|
||||
key: AesAny,
|
||||
iv: Iv,
|
||||
},
|
||||
/// BitLocker AES-CBC com difusor Elephant (chave de dados + chave de ajuste).
|
||||
/// BitLocker AES-CBC with Elephant diffuser (data key + tweak key).
|
||||
Elephant {
|
||||
key: AesAny,
|
||||
tweak: AesAny,
|
||||
@@ -126,8 +126,8 @@ fn gf_double(t: &mut [u8; 16]) {
|
||||
}
|
||||
|
||||
impl SectorCipher {
|
||||
/// Decifra um setor no lugar. `sector_512` conta setores de 512 bytes; `byte_off` é a posição
|
||||
/// em bytes relativa ao início dos dados cifrados.
|
||||
/// Decrypts a sector in place. `sector_512` is the 512-byte sector index; `byte_off` is the
|
||||
/// byte offset relative to the start of the encrypted payload.
|
||||
pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) {
|
||||
match self {
|
||||
SectorCipher::Xts { k1, k2, iv } => {
|
||||
@@ -178,33 +178,33 @@ pub fn cbc_encrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume cifrado visto em claro: `len` bytes que começam em `data_offset` na origem.
|
||||
/// Plaintext view of an encrypted volume: `len` bytes starting at `data_offset` in the source.
|
||||
pub struct CryptSource {
|
||||
inner: Arc<dyn Source>,
|
||||
cipher: SectorCipher,
|
||||
data_offset: u64,
|
||||
len: u64,
|
||||
sector: u64,
|
||||
/// Setores antes deste limite estão em claro (BitLocker parcialmente cifrado) ou
|
||||
/// são cobertos por uma cópia (os primeiros setores do BitLocker).
|
||||
/// Sectors before this threshold are either unencrypted (partially encrypted BitLocker) or
|
||||
/// redirected to a backup copy (BitLocker relocated boot sectors).
|
||||
patches: Vec<Patch>,
|
||||
/// A partir deste byte (relativo aos dados) o conteúdo ainda não foi cifrado.
|
||||
/// Bytes at or past this payload-relative offset are still unencrypted.
|
||||
plain_from: u64,
|
||||
/// Somado ao número do setor no cálculo do IV (iv_tweak do LUKS2).
|
||||
/// Added to the sector index when computing the IV (LUKS2 `iv_tweak`).
|
||||
iv_tweak: u64,
|
||||
}
|
||||
|
||||
/// Trecho do volume lido de outro lugar da origem (BitLocker move o início do volume).
|
||||
/// Volume range redirected to another offset on the source (BitLocker relocated header sectors).
|
||||
#[derive(Clone)]
|
||||
pub struct Patch {
|
||||
/// Início no volume em claro.
|
||||
/// Start offset in the logical plaintext volume.
|
||||
pub at: u64,
|
||||
pub len: u64,
|
||||
/// Onde estão os dados, na origem, já em claro.
|
||||
/// Source offset where the data resides.
|
||||
pub from: u64,
|
||||
/// Se os bytes de origem precisam ser decifrados (usando a posição `from_crypt`).
|
||||
/// Whether the source bytes must be decrypted (using cipher offset `from_crypt`).
|
||||
pub decrypt_from: Option<u64>,
|
||||
/// Devolve zeros (áreas de metadados do BitLocker).
|
||||
/// Returns zeros (BitLocker metadata regions masked out by the OS).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ impl CryptSource {
|
||||
}
|
||||
|
||||
fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> {
|
||||
// `rel` alinhado ao setor; `out.len() == sector`.
|
||||
// `rel` is sector-aligned; `out.len() == sector`.
|
||||
for p in &self.patches {
|
||||
if rel >= p.at && rel < p.at + p.len {
|
||||
if p.zero {
|
||||
@@ -292,15 +292,15 @@ impl Source for CryptSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Algo que o usuário fornece para abrir um volume.
|
||||
/// User-supplied credential for unlocking an encrypted volume.
|
||||
#[derive(Clone)]
|
||||
pub enum Secret {
|
||||
Password(String),
|
||||
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos).
|
||||
/// BitLocker numeric recovery password (8 groups of 6 digits).
|
||||
RecoveryKey(String),
|
||||
/// Conteúdo de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
|
||||
/// Raw contents of a key file (BitLocker .BEK, LUKS keyfile).
|
||||
KeyFile(Vec<u8>),
|
||||
/// Chave do volume já conhecida (FVEK/master key).
|
||||
/// Known volume master key (FVEK / LUKS master key).
|
||||
VolumeKey(Vec<u8>),
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ impl std::fmt::Debug for Secret {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UnlockError {
|
||||
/// Nenhuma das senhas/chaves abriu o volume.
|
||||
/// None of the supplied passwords or keys unlocked the volume.
|
||||
WrongSecret,
|
||||
Unsupported(String),
|
||||
Corrupt(String),
|
||||
@@ -339,11 +339,11 @@ impl From<UnlockError> for crate::Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume aberto.
|
||||
/// Unlocked encrypted volume.
|
||||
pub struct Unlocked {
|
||||
pub source: Arc<dyn Source>,
|
||||
pub description: String,
|
||||
/// Chave do volume (para reabrir sem a senha, se o usuário permitir guardá-la).
|
||||
/// Volume master key (can be saved in the session to reopen without a password if permitted).
|
||||
pub volume_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
//! Recomposição de arquivos fragmentados no carving.
|
||||
//! Fragmented file reassembly during carving.
|
||||
//!
|
||||
//! Para JPEG: valida os bytes contíguos com o decodificador de entropia; se a imagem quebra,
|
||||
//! procura entre os clusters "com cara de JPEG" próximos a continuação cujo DC de luminância
|
||||
//! emenda sem salto, até três fragmentos. É o que dá para fazer sem tabela de alocação, e só
|
||||
//! vale quando o sistema de arquivos não deixou metadados para o arquivo.
|
||||
//! For JPEG: validates contiguous bytes with the entropy decoder; if the stream breaks,
|
||||
//! searches nearby JPEG-like entropy clusters for a continuation whose luminance DC
|
||||
//! splices smoothly across up to three fragments. Best-effort without an allocation table,
|
||||
//! used when filesystem metadata is no longer available.
|
||||
|
||||
use crate::jpegscan::{self, Header, State, Stop};
|
||||
use crate::source::{read_vec, Source};
|
||||
use crate::types::Extent;
|
||||
|
||||
const MAX_FILE: usize = 48 << 20;
|
||||
/// Janela de busca da continuação, em bytes, depois e antes do primeiro fragmento.
|
||||
/// Search window in bytes (after and before the first fragment) for candidate continuations.
|
||||
const WINDOW_AFTER: u64 = 512 << 20;
|
||||
const WINDOW_BEFORE: u64 = 64 << 20;
|
||||
const PROBE: usize = 8192;
|
||||
pub const CLUSTER: u64 = 4096;
|
||||
const MAX_PROBES: usize = 20_000;
|
||||
|
||||
/// Marca os clusters (de 4 KiB, alinhados ao início da origem) que parecem dados entropy-coded
|
||||
/// de JPEG: sem marcadores no meio e com bytes bem distribuídos.
|
||||
/// Bitmap of 4 KiB source-aligned clusters that look like JPEG entropy-coded data
|
||||
/// (no embedded unescaped markers and high byte entropy).
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ClusterIndex {
|
||||
bits: Vec<u64>,
|
||||
@@ -43,7 +43,7 @@ impl ClusterIndex {
|
||||
.map(|w| w >> (c % 64) & 1 == 1)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Examina um trecho lido e marca os clusters candidatos.
|
||||
/// Scans a read buffer and marks candidate JPEG entropy clusters.
|
||||
pub fn scan_chunk(&mut self, abs: u64, buf: &[u8]) {
|
||||
let first = abs.div_ceil(CLUSTER) * CLUSTER;
|
||||
let mut o = first;
|
||||
@@ -90,7 +90,7 @@ pub fn jpeg_like(c: &[u8]) -> bool {
|
||||
|
||||
pub struct Pool<'a> {
|
||||
pub index: &'a ClusterIndex,
|
||||
/// Extents já atribuídos a outros arquivos.
|
||||
/// Extents already claimed by other recovered files.
|
||||
pub used: &'a [Extent],
|
||||
}
|
||||
|
||||
@@ -105,17 +105,17 @@ impl Pool<'_> {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Analysis {
|
||||
/// Contíguo e consistente; `len` inclui o EOI.
|
||||
/// Contiguous and valid; `len` includes the EOI marker.
|
||||
Intact { len: u64 },
|
||||
/// Remontado a partir de fragmentos.
|
||||
/// Reassembled from multiple non-contiguous fragments.
|
||||
Rebuilt { extents: Vec<Extent>, size: u64 },
|
||||
/// Só o início é confiável.
|
||||
/// Only the leading prefix is reliable.
|
||||
Broken { good_len: u64 },
|
||||
/// Formato que não sabemos validar (progressivo etc.).
|
||||
/// JPEG variant we do not entropy-validate (progressive, arithmetic, etc.).
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Procura FF D9 logo depois do último MCU (até 64 bytes de preenchimento).
|
||||
/// Looks for FF D9 immediately after the final MCU (allowing up to 64 padding bytes).
|
||||
fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
|
||||
let end = (pos + 64).min(data.len().saturating_sub(1));
|
||||
(pos.saturating_sub(2)..end)
|
||||
@@ -123,7 +123,7 @@ fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
|
||||
.map(|i| i + 2)
|
||||
}
|
||||
|
||||
/// Valida o JPEG que começa em `off` e, se estiver quebrado, tenta recompô-lo.
|
||||
/// Validates the JPEG starting at `off` and attempts fragment reassembly if truncated/broken.
|
||||
pub fn analyze_jpeg(
|
||||
src: &dyn Source,
|
||||
off: u64,
|
||||
@@ -168,8 +168,8 @@ pub fn analyze_jpeg(
|
||||
|
||||
let mut pieces: Vec<(u64, usize)> = vec![];
|
||||
let mut joined = buf;
|
||||
let mut cur_base = off; // início absoluto do fragmento atual
|
||||
let mut frag_start = 0usize; // posição em `joined` onde o fragmento atual começa
|
||||
let mut cur_base = off; // absolute start offset of the current fragment
|
||||
let mut frag_start = 0usize; // byte offset in `joined` where the current fragment starts
|
||||
let mut snaps = d.snaps;
|
||||
let mut suspect = stop_pos;
|
||||
|
||||
@@ -223,7 +223,7 @@ pub fn analyze_jpeg(
|
||||
}
|
||||
ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
ranked.truncate(120);
|
||||
// Etapa 2: a continuação certa fecha a imagem exatamente no último MCU.
|
||||
// Stage 2: the true continuation finishes the image right at the final MCU.
|
||||
let mut longest: Option<(usize, u64, f64)> = None;
|
||||
for (sc, q) in ranked {
|
||||
let avail = (src.size() - q) as usize;
|
||||
@@ -269,7 +269,7 @@ pub fn analyze_jpeg(
|
||||
return finish(pieces);
|
||||
}
|
||||
}
|
||||
// Quebrou de novo: onde?
|
||||
// Broke again: find the new break point.
|
||||
let mut sp = match d2.stop {
|
||||
Stop::Marker(_, p) | Stop::Error(p) => p,
|
||||
_ => joined.len(),
|
||||
@@ -314,8 +314,8 @@ fn finish(pieces: Vec<(u64, usize)>) -> Analysis {
|
||||
Analysis::Rebuilt { extents, size }
|
||||
}
|
||||
|
||||
/// Pontuação (menor é melhor) de `q` como continuação de `prefix`: diferença média de pixels
|
||||
/// nas bordas entre os MCUs novos e seus vizinhos. Natural: poucos níveis de cinza; lixo: dezenas.
|
||||
/// Score (lower is better) for `q` as a continuation of `prefix`: mean pixel difference
|
||||
/// along MCU borders between new blocks and their neighbors (smooth in natural images, high on noise).
|
||||
fn probe_candidate(
|
||||
src: &dyn Source,
|
||||
h: &Header,
|
||||
@@ -385,7 +385,7 @@ fn probe_candidate(
|
||||
if n < 40 {
|
||||
return None;
|
||||
}
|
||||
// Bytes por MCU depois da emenda, comparados com os do trecho anterior.
|
||||
// Bytes per MCU after the splice compared against the preceding prefix.
|
||||
let after = d.dc.len() as f64 - (seam.saturating_sub(snap.mcu)) as f64;
|
||||
let used = d.state.pos.saturating_sub(prefix.len()) as f64;
|
||||
let ratio = if after > 4.0 && used > 0.0 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Enumeração de dispositivos de armazenamento (melhor esforço por plataforma).
|
||||
//! Storage device enumeration (best-effort per platform).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -7,18 +7,18 @@ pub struct DeviceInfo {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
/// "HD", "SSD", "NVMe", "USB", "Disco" ou "Imagem".
|
||||
/// Device kind label (e.g. "HD", "SSD", "NVMe", "USB").
|
||||
pub kind: String,
|
||||
pub removable: bool,
|
||||
pub model: Option<String>,
|
||||
pub mounted_at: Vec<String>,
|
||||
/// O processo atual consegue abrir o dispositivo para leitura bruta.
|
||||
/// Whether the current process has permission to open the device for raw reading.
|
||||
pub readable: bool,
|
||||
/// SSD com descarte (TRIM) ativo: arquivos apagados tendem a sumir de verdade.
|
||||
/// SSD with TRIM/discard enabled: deleted file blocks are typically zeroed by the controller.
|
||||
pub trim: Option<bool>,
|
||||
}
|
||||
|
||||
/// (dispositivo, ponto de montagem)
|
||||
/// `(device_path, mount_point)`
|
||||
pub fn mount_table() -> Vec<(String, String)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Imagens forenses Expert Witness (EWF): E01 (EnCase 1 a 6, SMART) e Ex01 (EnCase 7, v2).
|
||||
//! Expert Witness Format (EWF) forensic images: E01 (EnCase 1–6, SMART) and Ex01 (EnCase 7, v2).
|
||||
//!
|
||||
//! Leitura de conjuntos com vários segmentos (`.E01`, `.E02`, ...), com chunks
|
||||
//! deflate, bzip2, sem compressão ou com preenchimento por padrão. Escrita de
|
||||
//! E01 no formato EnCase 6. A origem nunca é alterada.
|
||||
//! Reads multi-segment sets (`.E01`, `.E02`, ...) with deflate, bzip2, uncompressed,
|
||||
//! or pattern-filled chunks. Writes EnCase 6 compatible `.E01` images.
|
||||
//! The source is never modified.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::hashing::MultiHasher;
|
||||
@@ -17,7 +17,7 @@ use std::sync::Mutex;
|
||||
pub const SIG_V1: [u8; 8] = [0x45, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00];
|
||||
pub const SIG_V2: [u8; 8] = [0x45, 0x56, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00];
|
||||
|
||||
/// Reconhece o cabeçalho de um segmento EWF.
|
||||
/// Checks whether `buf` starts with an EWF segment signature.
|
||||
pub fn is_ewf_header(head: &[u8]) -> bool {
|
||||
head.len() >= 8 && (head[..8] == SIG_V1 || head[..8] == SIG_V2)
|
||||
}
|
||||
@@ -85,12 +85,12 @@ enum Codec {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Kind {
|
||||
/// Dados guardados como estão; `checksum` indica 4 bytes de Adler-32 no fim.
|
||||
/// Uncompressed chunk data; `checksum` indicates a trailing 4-byte Adler-32.
|
||||
Stored {
|
||||
checksum: bool,
|
||||
},
|
||||
Packed(Codec),
|
||||
/// Chunk inteiro repetindo estes 8 bytes (EnCase 7 "empty block").
|
||||
/// Full chunk filled by repeating these 8 bytes (EnCase 7 pattern/empty block).
|
||||
Pattern([u8; 8]),
|
||||
}
|
||||
|
||||
@@ -102,16 +102,16 @@ struct Chunk {
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// Metadados lidos do conjunto de segmentos.
|
||||
/// Metadata extracted from the EWF segment set.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EwfInfo {
|
||||
/// 1 para E01, 2 para Ex01.
|
||||
/// Format version: 1 for E01, 2 for Ex01.
|
||||
pub version: u8,
|
||||
pub segments: usize,
|
||||
pub chunk_size: u64,
|
||||
pub bytes_per_sector: u32,
|
||||
pub sectors: u64,
|
||||
/// Caso, examinador, notas etc. (chaves legíveis).
|
||||
/// Case, examiner, notes, etc. (human-readable key-value pairs).
|
||||
pub fields: BTreeMap<String, String>,
|
||||
pub md5: Option<String>,
|
||||
pub sha1: Option<String>,
|
||||
@@ -130,7 +130,7 @@ fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Próximo nome de segmento: E01..E99, EAA..EZZ, FAA... (a mesma letra inicial vira Ex).
|
||||
/// Computes the next segment extension: E01..E99, EAA..EZZ, FAA... (preserving prefix case).
|
||||
pub fn next_segment_ext(ext: &str) -> Option<String> {
|
||||
let b = ext.as_bytes();
|
||||
if b.len() != 3 {
|
||||
@@ -175,7 +175,7 @@ pub fn next_segment_ext(ext: &str) -> Option<String> {
|
||||
Some(if upper { out } else { out.to_ascii_lowercase() })
|
||||
}
|
||||
|
||||
/// Nome do segmento seguinte para E01 (`.E01` -> `.E02`) e Ex01 (`.Ex01` -> `.Ex02`).
|
||||
/// Path of the next segment for E01 (`.E01` -> `.E02`) and Ex01 (`.Ex01` -> `.Ex02`).
|
||||
fn next_segment_path(p: &Path) -> Option<PathBuf> {
|
||||
let ext = p.extension()?.to_str()?;
|
||||
let new = if ext.len() == 4 && ext[..2].eq_ignore_ascii_case("ex") {
|
||||
@@ -375,7 +375,7 @@ impl EwfSource {
|
||||
if flen < 96 {
|
||||
continue;
|
||||
}
|
||||
// Os descritores formam uma lista encadeada para trás, a partir do fim do arquivo.
|
||||
// Section descriptors form a backward-linked list starting from the end of the file.
|
||||
let mut pos = flen - 64;
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
@@ -387,7 +387,7 @@ impl EwfSource {
|
||||
let dsize = le64(&d, 16);
|
||||
let pad = le32(&d, 28) as u64;
|
||||
let dstart = pos.saturating_sub(dsize);
|
||||
let data_len = dsize; // inclui o preenchimento; os decodificadores param no fim do fluxo
|
||||
let data_len = dsize; // includes padding; decoders stop at the end of the compressed stream
|
||||
let _ = pad;
|
||||
let read_data = |max: u64| -> io::Result<Vec<u8>> {
|
||||
let n = data_len.min(max) as usize;
|
||||
@@ -515,7 +515,7 @@ impl EwfSource {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Verifica o hash MD5 do conteúdo contra o guardado na imagem (`None` se a imagem não o guarda).
|
||||
/// Verifies the content MD5 against the hash stored in the image (`None` if not present).
|
||||
pub fn verify(&self) -> Result<Option<bool>> {
|
||||
let Some(stored) = self.info.md5.clone() else {
|
||||
return Ok(None);
|
||||
@@ -559,7 +559,7 @@ impl Source for EwfSource {
|
||||
let data = &cache.as_ref().unwrap().1;
|
||||
let within = (pos % self.chunk_size) as usize;
|
||||
if within >= data.len() {
|
||||
// Chunk curto (último): o resto é zero até o fim lógico.
|
||||
// Short (final) chunk: pad remaining bytes with zeros up to the logical end.
|
||||
let n = (self.chunk_size as usize - within).min(want - done);
|
||||
buf[done..done + n].fill(0);
|
||||
done += n;
|
||||
@@ -611,7 +611,7 @@ fn header_key_name(k: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Texto do "header" (E01): linhas "1 / main / chaves / valores", separados por TAB.
|
||||
/// Parses E01 `header` text: tab-separated lines (`1` / `main` / keys / values).
|
||||
fn parse_header_text(raw: &[u8], out: &mut BTreeMap<String, String>) {
|
||||
let text = if raw.len() >= 2 && (raw[0] == 0xFF && raw[1] == 0xFE || raw.get(1) == Some(&0)) {
|
||||
utf16_text(raw)
|
||||
@@ -674,16 +674,16 @@ fn parse_v2_text(text: &str, info: &mut EwfInfo, total_bytes: &mut u64, ty: u32)
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Escrita de E01 (formato EnCase 6)
|
||||
// E01 writer (EnCase 6 format)
|
||||
// ======================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct E01Options {
|
||||
pub chunk_sectors: u32,
|
||||
pub bytes_per_sector: u32,
|
||||
/// 0 = sem compressão, 1..=9 = nível deflate.
|
||||
/// 0 = uncompressed, 1..=9 = deflate compression level.
|
||||
pub compression: u32,
|
||||
/// Tamanho máximo de cada segmento (0 = um segmento só).
|
||||
/// Maximum size of each segment file in bytes (0 = single segment).
|
||||
pub segment_size: u64,
|
||||
pub case_number: String,
|
||||
pub description: String,
|
||||
@@ -735,7 +735,7 @@ impl SegWriter {
|
||||
self.pos += b.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
/// Seção com dados completos (cabeçalho, volume, hash...).
|
||||
/// Writes a complete EWF section (header, volume, hash, etc.).
|
||||
fn section(&mut self, ty: &str, data: &[u8], last: bool) -> io::Result<()> {
|
||||
let size = 76 + data.len() as u64;
|
||||
let next = if last { self.pos } else { self.pos + size };
|
||||
@@ -760,12 +760,12 @@ fn header_text(o: &E01Options, now: &str) -> Vec<u8> {
|
||||
|
||||
fn volume_data(o: &E01Options, chunks: u32, sectors: u64) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 1052];
|
||||
v[0] = 1; // disco fixo
|
||||
v[0] = 1; // fixed disk
|
||||
v[4..8].copy_from_slice(&chunks.to_le_bytes());
|
||||
v[8..12].copy_from_slice(&o.chunk_sectors.to_le_bytes());
|
||||
v[12..16].copy_from_slice(&o.bytes_per_sector.to_le_bytes());
|
||||
v[16..24].copy_from_slice(§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[56..60].copy_from_slice(&o.chunk_sectors.to_le_bytes());
|
||||
let c = adler32(&v[..1048]);
|
||||
@@ -780,7 +780,7 @@ fn utc_now() -> String {
|
||||
.unwrap_or(0);
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
// Conversão civil (Howard Hinnant).
|
||||
// Civil calendar conversion (Howard Hinnant).
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z.rem_euclid(146097);
|
||||
@@ -799,8 +799,8 @@ fn utc_now() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Cria `base.E01` (e `.E02`... se houver divisão) a partir de `src`.
|
||||
/// Devolve o relatório com os hashes do conteúdo lido.
|
||||
/// Creates `base.E01` (and `.E02`... when split across segments) from `src`.
|
||||
/// Returns the imaging report with hashes of the imaged content.
|
||||
pub fn write_e01(
|
||||
src: &dyn Source,
|
||||
base: &Path,
|
||||
@@ -838,7 +838,7 @@ pub fn write_e01(
|
||||
base.with_extension(ext)
|
||||
};
|
||||
|
||||
// O segmento está cheio? (reserva espaço para as tabelas e os hashes finais)
|
||||
// Is the current segment full? (reserves room for tables and trailing hash sections)
|
||||
let full = |pos: u64, entries: u64| {
|
||||
opts.segment_size > 0
|
||||
&& pos + chunk_size + 2 * (entries * 4 + 110) + 1024 >= opts.segment_size
|
||||
@@ -875,14 +875,14 @@ pub fn write_e01(
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// Grupos "sectors" + "table" + "table2" até estourar o limite do segmento.
|
||||
// Emit "sectors" + "table" + "table2" groups until the segment size limit is reached.
|
||||
loop {
|
||||
let remaining = chunks_total - chunk_idx;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let sec_start = w.pos;
|
||||
// Espaço reservado para a descrição da seção "sectors" (preenchida depois).
|
||||
// Placeholder for the "sectors" section descriptor (backfilled later).
|
||||
w.put(&[0u8; 76])?;
|
||||
let base_off = w.pos;
|
||||
let mut entries: Vec<u32> = vec![];
|
||||
@@ -894,7 +894,7 @@ pub fn write_e01(
|
||||
}
|
||||
let off = chunk_idx * chunk_size;
|
||||
let want = (total - off).min(chunk_size) as usize;
|
||||
// Leitura tolerante: setor ruim vira zeros e entra no mapa de erros.
|
||||
// Fault-tolerant read: bad sectors are zero-filled and recorded in the error map.
|
||||
let b = &mut buf[..want];
|
||||
if crate::source::read_exact_at(src, off, b).is_err() {
|
||||
let mut o = 0;
|
||||
@@ -930,21 +930,21 @@ pub fn write_e01(
|
||||
chunk_idx += 1;
|
||||
group_chunks += 1;
|
||||
progress(chunk_idx * chunk_size.min(total), total);
|
||||
// Mantém cada tabela dentro de 31 bits e o segmento perto do limite.
|
||||
// Keep each table offset within 31 bits and the segment within its size limit.
|
||||
if rel > 0x7000_0000 || full(w.pos, entries.len() as u64) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Preenche a seção "sectors".
|
||||
// Backfill the "sectors" section descriptor.
|
||||
let sec_end = w.pos;
|
||||
let table_size = 76 + 24 + entries.len() as u64 * 4 + 4;
|
||||
let sectors_desc = section_desc("sectors", sec_end, sec_end - sec_start);
|
||||
// Descrição "sectors" aponta para a tabela que vem logo depois.
|
||||
// The "sectors" descriptor points to the table immediately following it.
|
||||
use std::io::{Seek, SeekFrom};
|
||||
w.f.seek(SeekFrom::Start(sec_start))?;
|
||||
w.f.write_all(§ors_desc)?;
|
||||
w.f.seek(SeekFrom::Start(sec_end))?;
|
||||
// table e table2 idênticas.
|
||||
// `table` and `table2` are identical.
|
||||
for ty in ["table", "table2"] {
|
||||
let start = w.pos;
|
||||
w.put(§ion_desc(ty, start + table_size, table_size))?;
|
||||
@@ -971,7 +971,7 @@ pub fn write_e01(
|
||||
}
|
||||
}
|
||||
if chunk_idx >= chunks_total {
|
||||
// Segmento final: hashes e "done".
|
||||
// Final segment: hash sections and "done".
|
||||
let h = std::mem::take(&mut hasher).finalize();
|
||||
let md5: Vec<u8> = (0..16)
|
||||
.map(|i| u8::from_str_radix(&h.md5[i * 2..i * 2 + 2], 16).unwrap())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Navegação estilo gerenciador de arquivos: filtros, ordenação, facetas
|
||||
//! (tipos, datas, pastas, câmeras) e cache de miniaturas.
|
||||
//! File-manager style browsing: filtering, sorting, facets
|
||||
//! (types, dates, folders, cameras), and thumbnail caching.
|
||||
|
||||
use crate::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,14 +21,14 @@ pub struct Query {
|
||||
pub offset: usize,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
/// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz".
|
||||
/// Folder path prefix, "@carved" (carved findings), or "@raiz" (root).
|
||||
#[serde(default)]
|
||||
pub folder: Option<String>,
|
||||
#[serde(default)]
|
||||
pub year: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub month: Option<u32>,
|
||||
/// Só arquivos sem data conhecida.
|
||||
/// Match only files without a known date.
|
||||
#[serde(default)]
|
||||
pub undated: Option<bool>,
|
||||
#[serde(default)]
|
||||
@@ -39,12 +39,12 @@ pub struct Query {
|
||||
pub min_size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_size: Option<u64>,
|
||||
/// Megapixels mínimos.
|
||||
/// Minimum resolution in megapixels.
|
||||
#[serde(default)]
|
||||
pub min_mp: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub has_gps: Option<bool>,
|
||||
/// Mostra só a primeira ocorrência de cada conteúdo repetido.
|
||||
/// Deduplicate: show only the first occurrence of identical content.
|
||||
#[serde(default)]
|
||||
pub dedupe: Option<bool>,
|
||||
}
|
||||
@@ -67,7 +67,7 @@ impl Query {
|
||||
.filter(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
/// Itens que atendem ao filtro (na ordem de achado), já sem duplicadas se pedido.
|
||||
/// Returns items matching the filter (in discovery order), deduplicated if requested.
|
||||
pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> {
|
||||
let needle = self.needle();
|
||||
let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect();
|
||||
@@ -170,7 +170,7 @@ pub fn item_json(f: &Found) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Contagens para a navegação lateral.
|
||||
/// Facet counts for sidebar navigation.
|
||||
pub fn facets(found: &[Found]) -> Value {
|
||||
let mut cats: HashMap<&'static str, usize> = HashMap::new();
|
||||
let mut years: BTreeMap<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
|
||||
@@ -266,7 +266,7 @@ pub fn cat_key(c: Category) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ cache de miniaturas
|
||||
// ------------------------------------------------------------------ thumbnail cache
|
||||
|
||||
type Key = (String, u64, u32);
|
||||
|
||||
@@ -310,7 +310,7 @@ impl Default for ThumbCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Limita quantas miniaturas são decodificadas ao mesmo tempo (memória/CPU).
|
||||
/// Bounds concurrent thumbnail decoding (memory/CPU cap).
|
||||
pub struct Gate {
|
||||
n: Mutex<usize>,
|
||||
cv: Condvar,
|
||||
@@ -344,7 +344,7 @@ impl Drop for Permit<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Assinatura rápida: tamanho + FNV-1a do início (32 KiB) e do fim (32 KiB).
|
||||
/// Fast content fingerprint: file size + FNV-1a of the first 32 KiB and last 32 KiB.
|
||||
pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
|
||||
use crate::recover::read_found_at;
|
||||
if f.size == 0 {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
//! Abre um arquivo achado no aplicativo padrão do sistema (player de vídeo, leitor
|
||||
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia:
|
||||
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e
|
||||
//! o processo da página morre ao criar um `<video>`.
|
||||
//! Opens a recovered file in the OS default application (video player, PDF
|
||||
//! reader, etc.). Used when the embedded WebView cannot play media directly:
|
||||
//! WebKitGTK bundled in the AppImage lacks GStreamer media plugins and
|
||||
//! crashes the web process when instantiating a `<video>` element.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fs::sanitize_name;
|
||||
use crate::types::Found;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pasta temporária própria do usuário (nunca compartilhada).
|
||||
/// Per-user private temporary directory (never shared).
|
||||
fn temp_dir() -> PathBuf {
|
||||
let uid = crate::owner::invoking_user()
|
||||
.map(|u| u.uid.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: getuid não tem pré-condições.
|
||||
// SAFETY: `getuid` has no preconditions.
|
||||
unsafe { libc::getuid() }.to_string()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
@@ -26,7 +26,7 @@ fn temp_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
|
||||
}
|
||||
|
||||
/// Remove cópias com mais de um dia.
|
||||
/// Removes temporary copies older than one day.
|
||||
fn cleanup(dir: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
@@ -45,7 +45,7 @@ fn cleanup(dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copia o arquivo achado para uma pasta temporária do usuário e o abre no aplicativo padrão.
|
||||
/// Materializes the recovered file into a user temp folder and opens it with the default app.
|
||||
pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
|
||||
const MAX: u64 = 8 * 1024 * 1024 * 1024;
|
||||
if f.size > MAX {
|
||||
@@ -62,7 +62,7 @@ pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf>
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Abre o caminho com o aplicativo padrão, na sessão do usuário real mesmo se formos root.
|
||||
/// Launches the default application for `path`, running in the real user session even under root.
|
||||
pub fn open_path(path: &Path) -> std::io::Result<()> {
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Mais formatos reconhecidos pela estrutura (cabeçalhos, blocos e tamanhos internos), para o
|
||||
//! carving devolver o arquivo inteiro e não só o começo. Cada detector devolve o tamanho exato
|
||||
//! quando a estrutura permite e uma confiança proporcional ao que foi conferido.
|
||||
//! Additional file formats recognized by internal structure (headers, chunks, and embedded sizes)
|
||||
//! so file carving recovers complete files rather than prefixes. Each detector returns the exact
|
||||
//! byte length when determinable and a confidence level proportional to structural validation.
|
||||
|
||||
use crate::signatures::Hit;
|
||||
use crate::source::{read_vec, Source};
|
||||
@@ -40,7 +40,7 @@ fn hit(len: u64, ext: &'static str, confidence: Confidence) -> Option<Hit> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Primeiros bytes que disparam os detectores deste módulo.
|
||||
/// Leading byte values that trigger detectors in this module.
|
||||
pub fn might_start(b0: u8) -> bool {
|
||||
matches!(
|
||||
b0,
|
||||
@@ -67,7 +67,7 @@ pub fn might_start(b0: u8) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Tenta os formatos adicionais. `h` são os primeiros bytes (>= 12) lidos em `off`.
|
||||
/// Probes additional formats. `h` contains the leading bytes (>= 12) read at `off`.
|
||||
pub fn detect(src: &dyn Source, off: u64, h: &[u8], max: u64) -> Option<Hit> {
|
||||
if h.starts_with(&[0x1F, 0x8B, 0x08]) {
|
||||
return gzip(src, off, max);
|
||||
@@ -153,9 +153,9 @@ pub fn detect(src: &dyn Source, off: u64, h: &[u8], max: u64) -> Option<Hit> {
|
||||
None
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- compactadores
|
||||
// -------------------------------------------------------------- compressed streams
|
||||
|
||||
/// Alimenta um descompactador com blocos de `src` até o fim do fluxo; devolve (bytes lidos, saída crc32 opcional).
|
||||
/// Feeds chunks from `src` into a decompressor until end-of-stream; returns `(compressed_bytes, optional_crc32)`.
|
||||
fn inflate_len(
|
||||
src: &dyn Source,
|
||||
start: u64,
|
||||
@@ -311,7 +311,7 @@ fn zstd(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
if btype == 3 {
|
||||
return None;
|
||||
}
|
||||
// RLE: o conteúdo é 1 byte; os outros ocupam `bsize`.
|
||||
// RLE block: 1 byte in stream, expands to `bsize`.
|
||||
let body = if btype == 1 { 1 } else { bsize };
|
||||
pos += 3 + body;
|
||||
if pos - off > max {
|
||||
@@ -328,7 +328,7 @@ fn zstd(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
hit(p as u64, "zst", Confidence::Medium)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ mídia
|
||||
// ------------------------------------------------------------------ media
|
||||
|
||||
fn vint(b: &[u8]) -> Option<(u64, usize, bool)> {
|
||||
let first = *b.first()?;
|
||||
@@ -367,7 +367,7 @@ fn matroska(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
return hit(total, ext, Confidence::High);
|
||||
}
|
||||
}
|
||||
// Tamanho desconhecido (gravação ao vivo): caminha pelos elementos de topo.
|
||||
// Unknown segment size (live recording): walk top-level EBML elements.
|
||||
let mut pos = off + (p + sl) as u64;
|
||||
let mut last_good = pos;
|
||||
for _ in 0..2_000_000 {
|
||||
@@ -394,7 +394,7 @@ fn matroska(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
hit(last_good - off, ext, Confidence::Low)
|
||||
}
|
||||
|
||||
/// ID de elemento EBML (mantém os bits de marcação).
|
||||
/// Reads an EBML element ID (preserving marker bits).
|
||||
fn vint_id(b: &[u8]) -> Option<(u64, usize, bool)> {
|
||||
let first = *b.first()?;
|
||||
let len = first.leading_zeros() as usize + 1;
|
||||
@@ -456,7 +456,7 @@ fn ogg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
|
||||
fn mpegts(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
// 188 bytes por pacote (ou 192 com carimbo de 4 bytes antes do sync).
|
||||
// 188 bytes per MPEG-TS packet (or 192 with a 4-byte timecode prefix before the sync byte).
|
||||
let probe = read_vec(src, off, 188 * 8).ok()?;
|
||||
if probe.len() < 188 * 8 || !(0..8).all(|i| probe[i * 188] == 0x47) {
|
||||
return None;
|
||||
@@ -492,7 +492,7 @@ fn flv(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
if t.len() < 15 {
|
||||
break;
|
||||
}
|
||||
// PreviousTagSize(4) + tipo(1) + tamanho(3)
|
||||
// PreviousTagSize(4) + tag_type(1) + data_size(3)
|
||||
let ty = t[4];
|
||||
if !matches!(ty, 8 | 9 | 18) {
|
||||
break;
|
||||
@@ -559,7 +559,7 @@ fn jp2(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
};
|
||||
if ty == b"jp2c" {
|
||||
if size == 0 {
|
||||
// até o fim do arquivo: procura EOC FF D9 depois
|
||||
// codestream extends to EOF: scan for EOC marker FF D9
|
||||
return hit((pos - off) + 8 + (4 << 20).min(max), "jp2", Confidence::Low);
|
||||
}
|
||||
return hit(pos - off + size, "jp2", Confidence::High);
|
||||
@@ -589,7 +589,7 @@ fn midi(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
hit(pos - off, "mid", Confidence::High)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- arquivos compactados
|
||||
// -------------------------------------------------------- archives
|
||||
|
||||
fn rar(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let h = read_vec(src, off, 8).ok()?;
|
||||
@@ -703,7 +703,7 @@ fn swf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
.then_some(())
|
||||
.and_then(|_| hit(len, "swf", Confidence::High));
|
||||
}
|
||||
// CWS: corpo em zlib a partir do byte 8
|
||||
// CWS: zlib-compressed body starting at byte 8
|
||||
let (consumed, _, out) = inflate_len(src, off + 8, max, false, true)?;
|
||||
hit(
|
||||
8 + consumed,
|
||||
@@ -716,7 +716,7 @@ fn swf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- binários e dados
|
||||
// ---------------------------------------------------------- binaries and data
|
||||
|
||||
fn elf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let h = read_vec(src, off, 64).ok()?;
|
||||
@@ -757,7 +757,7 @@ fn elf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
)
|
||||
};
|
||||
let mut end = shoff + shentsize * shnum;
|
||||
// Segmentos de programa podem ir além (binários sem tabela de seções).
|
||||
// Program segments may extend further (stripped binaries without a section header table).
|
||||
if phnum > 0 && phnum < 256 && phentsize >= 32 {
|
||||
let ph = read_vec(src, off + phoff, (phnum * phentsize) as usize).ok()?;
|
||||
for i in 0..phnum as usize {
|
||||
@@ -947,7 +947,7 @@ fn ico(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
end = end.max(o + size);
|
||||
}
|
||||
// a imagem de cada entrada: BMP/DIB (tamanho do cabeçalho 40) ou PNG
|
||||
// each icon entry payload is either a BMP/DIB (40-byte header) or a PNG
|
||||
let first_off = u32le(&d, 12);
|
||||
let probe = read_vec(src, off + first_off, 8).ok()?;
|
||||
if !(probe.starts_with(&[0x89, b'P', b'N', b'G']) || u32le(&probe, 0) == 40) {
|
||||
@@ -961,7 +961,7 @@ fn regf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
if h.len() < 4096 {
|
||||
return None;
|
||||
}
|
||||
// soma XOR dos 127 primeiros u32 = valor em 0x1FC
|
||||
// XOR checksum of the first 127 u32 words must match the word at 0x1FC
|
||||
let mut x = 0u32;
|
||||
for i in 0..127 {
|
||||
x ^= u32le(&h, i * 4) as u32;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! APFS (volumes sem cifragem): lê o contêiner, o mapa de objetos e a árvore de arquivos de cada
|
||||
//! volume; arquivos apagados vêm de duas fontes: as árvores dos instantâneos (snapshots) e os nós
|
||||
//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são
|
||||
//! detectados e deixados para a varredura por assinatura.
|
||||
//! APFS (unencrypted volumes): parses the container, object map, and file system tree of each
|
||||
//! volume; deleted files are recovered from two sources: snapshot trees and older copy-on-write
|
||||
//! B-tree nodes still resident on disk. Encrypted volumes (FileVault) are detected and left for
|
||||
//! signature carving unless unlocked.
|
||||
|
||||
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
|
||||
use crate::source::{read_vec, Source};
|
||||
@@ -32,13 +32,13 @@ const T_CIB: u32 = 7;
|
||||
const T_CHECKPOINT_MAP: u32 = 0x0C;
|
||||
const SUBTYPE_FSTREE: u32 = 0x0E;
|
||||
|
||||
// tipos de registro da árvore de arquivos
|
||||
// filesystem B-tree record types
|
||||
const R_SNAP_METADATA: u8 = 1;
|
||||
const R_INODE: u8 = 3;
|
||||
const R_FILE_EXTENT: u8 = 8;
|
||||
const R_DIR_REC: u8 = 9;
|
||||
|
||||
/// Soma de verificação Fletcher-64 do APFS (cobre o bloco menos os 8 bytes iniciais).
|
||||
/// APFS Fletcher-64 checksum (covers the block excluding the leading 8 bytes).
|
||||
pub fn fletcher64(data: &[u8]) -> u64 {
|
||||
let (mut s1, mut s2) = (0u64, 0u64);
|
||||
for w in data.chunks_exact(4) {
|
||||
@@ -60,7 +60,7 @@ struct Apfs<'a> {
|
||||
struct BNode {
|
||||
flags: u16,
|
||||
level: u16,
|
||||
/// (chave, valor) por entrada
|
||||
/// `(key, value)` slice pair for each node entry
|
||||
entries: Vec<(Vec<u8>, Vec<u8>)>,
|
||||
oid: u64,
|
||||
xid: u64,
|
||||
@@ -124,7 +124,7 @@ impl<'a> Apfs<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Todas as entradas de uma árvore física (mapa de objetos): filhos são endereços físicos.
|
||||
/// Collects all entries from a physical B-tree (object map): child pointers are physical block addresses.
|
||||
fn walk_physical(
|
||||
&self,
|
||||
root: u64,
|
||||
@@ -156,7 +156,7 @@ impl<'a> Apfs<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapa de objetos: oid -> [(xid, paddr)] ordenado por xid.
|
||||
/// Object map: `oid -> [(xid, paddr)]` sorted by transaction ID (`xid`).
|
||||
fn omap(&self, omap_paddr: u64) -> HashMap<u64, Vec<(u64, u64)>> {
|
||||
let mut m: HashMap<u64, Vec<(u64, u64)>> = HashMap::new();
|
||||
let Some(ob) = self.checked_block(omap_paddr) else {
|
||||
@@ -174,7 +174,7 @@ impl<'a> Apfs<'a> {
|
||||
m
|
||||
}
|
||||
|
||||
/// Endereço físico de um objeto ephemeral (spaceman), achado no mapa de checkpoint mais novo.
|
||||
/// Physical block address of an ephemeral object (spaceman) from the latest checkpoint map.
|
||||
fn ephemeral_paddr(&self, nx: &[u8], oid: u64) -> Option<u64> {
|
||||
let desc_blocks = le32(nx, 104) as u64;
|
||||
let desc_base = le64(nx, 112);
|
||||
@@ -203,8 +203,8 @@ impl<'a> Apfs<'a> {
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// Mapa de espaço livre do contêiner: percorre o spaceman, os chunk-info blocks e os bitmaps
|
||||
/// (bit 1 = bloco em uso).
|
||||
/// Container free-space map: walks the spaceman, chunk-info blocks (CIBs), and allocation bitmaps
|
||||
/// (bit 1 = allocated block).
|
||||
fn free_map(&self, nx: &[u8]) -> Option<Vec<Extent>> {
|
||||
let sm_oid = le64(nx, 152);
|
||||
if sm_oid == 0 {
|
||||
@@ -214,7 +214,7 @@ impl<'a> Apfs<'a> {
|
||||
if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN {
|
||||
return None;
|
||||
}
|
||||
// Com CABs (contêineres grandes) os endereços dos CIBs não ficam no spaceman.
|
||||
// With CABs (large containers), CIB addresses live in chunk-info address blocks rather than inline in the spaceman.
|
||||
if le32(&sm, 68) != 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -289,7 +289,7 @@ fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u
|
||||
.map(|(_, p)| *p)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- registros
|
||||
// ---------------------------------------------------------------- records
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Inode {
|
||||
@@ -308,7 +308,7 @@ struct Inode {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FileRec {
|
||||
inode: Inode,
|
||||
names: BTreeMap<u64, String>, // pai -> nome
|
||||
names: BTreeMap<u64, String>, // parent_id -> name
|
||||
}
|
||||
|
||||
type Files = HashMap<u64, FileRec>;
|
||||
@@ -327,7 +327,7 @@ fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
|
||||
let mut ino = Inode {
|
||||
parent: le64(v, 0),
|
||||
private_id: le64(v, 8),
|
||||
ctime: ns(le64(v, 16)), // criação
|
||||
ctime: ns(le64(v, 16)), // creation timestamp
|
||||
mtime: ns(le64(v, 24)),
|
||||
atime: ns(le64(v, 40)),
|
||||
mode: le16(v, 80),
|
||||
@@ -338,7 +338,7 @@ fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
|
||||
let flags = le64(v, 48);
|
||||
ino.compressed = le32(v, 68) & 0x20 != 0;
|
||||
let _ = flags;
|
||||
// campos estendidos
|
||||
// xfields (extended fields)
|
||||
let xf = &v[92..];
|
||||
if xf.len() >= 4 {
|
||||
let n = le16(xf, 0) as usize;
|
||||
@@ -383,7 +383,7 @@ fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
|
||||
}
|
||||
|
||||
impl Apfs<'_> {
|
||||
/// Percorre a árvore de arquivos a partir de uma raiz virtual.
|
||||
/// Walks the filesystem B-tree starting from a virtual root OID.
|
||||
fn walk_fstree(
|
||||
&self,
|
||||
root_oid: u64,
|
||||
@@ -484,7 +484,7 @@ fn open_container<'a>(src: &'a dyn Source, base: u64) -> Option<(Apfs<'a>, Vec<u
|
||||
bs,
|
||||
blocks,
|
||||
};
|
||||
// O superbloco mais recente está na área de descritores de checkpoint.
|
||||
// Find the latest valid container superblock in the checkpoint descriptor area.
|
||||
let desc_blocks = le32(&b0, 104) as u64;
|
||||
let desc_base = le64(&b0, 112);
|
||||
let mut best = b0.clone();
|
||||
@@ -548,7 +548,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res: &mut VolumeScan) {
|
||||
let omap = a.omap(v.omap_paddr);
|
||||
let current_xid = u64::MAX;
|
||||
// Árvore atual.
|
||||
// Current live filesystem tree.
|
||||
let mut live: Files = HashMap::new();
|
||||
let mut live_ext: Extents = HashMap::new();
|
||||
let mut live_nodes: HashSet<u64> = HashSet::new();
|
||||
@@ -556,7 +556,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
live_nodes.insert(node);
|
||||
add_record(&mut live, &mut live_ext, k, val);
|
||||
});
|
||||
// Instantâneos.
|
||||
// Volume snapshots.
|
||||
let mut stale: Files = HashMap::new();
|
||||
let mut stale_ext: Extents = HashMap::new();
|
||||
if v.snap_tree_oid != 0 {
|
||||
@@ -583,7 +583,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
}
|
||||
} else {
|
||||
for (_, val) in &n.entries {
|
||||
// árvore de metadados de snapshot: filhos físicos ou virtuais
|
||||
// snapshot metadata tree: physical or virtual child references
|
||||
let c = le64(val, 0);
|
||||
stack.push(lookup(&omap, c, current_xid).unwrap_or(c));
|
||||
}
|
||||
@@ -602,7 +602,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nós antigos da árvore de arquivos.
|
||||
// Older evicted copy-on-write filesystem tree leaf nodes.
|
||||
if allow_stale_scan {
|
||||
let total = a.blocks;
|
||||
for p in 0..total {
|
||||
@@ -631,7 +631,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arquivos que só existem nas versões antigas.
|
||||
// Files present only in older tree versions or snapshots.
|
||||
let mut ids: Vec<&u64> = stale.keys().collect();
|
||||
ids.sort();
|
||||
let mut seen_first: HashSet<u64> = HashSet::new();
|
||||
@@ -648,9 +648,9 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
continue;
|
||||
};
|
||||
if ex.values().any(|e| e.2 != 0) {
|
||||
continue; // extensão cifrada
|
||||
continue; // encrypted extent
|
||||
}
|
||||
// nome e pasta: da entrada de diretório, senão do próprio inode
|
||||
// file name and parent folder: prefer directory entry, fallback to inode xfield
|
||||
let (name, parent) = match rec.names.iter().next() {
|
||||
Some((p, n)) => (n.clone(), *p),
|
||||
None => match &ino.name {
|
||||
@@ -726,13 +726,13 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
let _ = res;
|
||||
}
|
||||
|
||||
/// Trechos livres (absolutos) do contêiner, segundo o spaceman.
|
||||
/// Absolute free byte extents in the container according to the spaceman.
|
||||
pub fn free_extents(src: &dyn Source, base: u64) -> Option<Vec<Extent>> {
|
||||
let (a, nx) = open_container(src, base)?;
|
||||
a.free_map(&nx)
|
||||
}
|
||||
|
||||
/// Arquivos e pastas atuais do primeiro volume sem cifragem (caminho, tamanho, é pasta).
|
||||
/// Live files and folders in the first unencrypted volume `(path, size, is_dir)`.
|
||||
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
|
||||
let Some((a, nx)) = open_container(src, base) else {
|
||||
return vec![];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Btrfs (um dispositivo; perfis single, DUP e RAID 1): árvores atuais para nomes e mapa de
|
||||
//! espaço livre, e arquivos apagados achados em nós antigos das árvores. Como o Btrfs nunca
|
||||
//! sobrescreve metadados no lugar (copy-on-write), as versões anteriores dos nós continuam no
|
||||
//! disco até o espaço ser reaproveitado: varremos os blocos de metadata em busca delas.
|
||||
//! Extensões comprimidas (zlib, zstd, lzo) são descompactadas na recuperação.
|
||||
//! Btrfs (single device; single, DUP, and RAID 1 profiles): reads live trees for paths and
|
||||
//! free-space mapping, and recovers deleted files from historical copy-on-write B-tree nodes.
|
||||
//! Because Btrfs never overwrites metadata in place, prior leaf generations remain on disk
|
||||
//! until reallocated: we scan metadata chunks for those older leaves.
|
||||
//! Compressed extents (zlib, zstd, LZO) are decompressed on recovery.
|
||||
|
||||
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
|
||||
use crate::source::{read_vec, Source};
|
||||
@@ -12,7 +12,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
const MAGIC: &[u8; 8] = b"_BHRfS_M";
|
||||
const SB_OFF: u64 = 0x10000;
|
||||
|
||||
// chaves
|
||||
// key item types
|
||||
const INODE_ITEM: u8 = 1;
|
||||
const INODE_REF: u8 = 12;
|
||||
const EXTENT_DATA: u8 = 108;
|
||||
@@ -81,7 +81,7 @@ struct Chunk {
|
||||
logical: u64,
|
||||
len: u64,
|
||||
ty: u64,
|
||||
/// (offset físico do primeiro stripe) por cópia
|
||||
/// Physical byte offsets of each stripe replica.
|
||||
phys: Vec<u64>,
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ impl<'a> Fs<'a> {
|
||||
fs.chunks.push(ch);
|
||||
p += 17 + used;
|
||||
}
|
||||
// chunk tree completa
|
||||
// full chunk tree
|
||||
let mut more = vec![];
|
||||
let cr = fs.chunk_root;
|
||||
fs.walk(cr, &mut |k, d| {
|
||||
@@ -241,7 +241,7 @@ impl<'a> Fs<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Percorre uma árvore em ordem, chamando `f` para cada item de folha.
|
||||
/// Walks a B-tree in order, invoking `f` for each leaf item.
|
||||
fn walk(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8])) {
|
||||
let mut seen = HashSet::new();
|
||||
self.walk_inner(root, f, &mut seen, &mut |_| {}, 0);
|
||||
@@ -314,7 +314,7 @@ struct InodeData {
|
||||
atime: i64,
|
||||
has_inode: bool,
|
||||
refs: HashMap<u64, String>,
|
||||
/// offset no arquivo -> (geração do nó, item)
|
||||
/// `file_offset -> (node_generation, extent_item)`
|
||||
extents: BTreeMap<u64, (u64, Vec<u8>)>,
|
||||
}
|
||||
|
||||
@@ -377,8 +377,8 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
return res;
|
||||
}
|
||||
|
||||
// 1) Árvores atuais: raízes das subvolumes e extents alocados.
|
||||
let mut roots: Vec<(u64, u64)> = vec![]; // (id da árvore, bytenr)
|
||||
// 1) Live trees: subvolume roots and allocated extents.
|
||||
let mut roots: Vec<(u64, u64)> = vec![]; // (tree_id, root_bytenr)
|
||||
let mut extent_root = 0u64;
|
||||
let rr = fs.root;
|
||||
fs.walk(rr, &mut |k, d| {
|
||||
@@ -416,7 +416,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
}
|
||||
}
|
||||
|
||||
// Extents alocados (árvore de extents) e mapa livre dos blocos de dados.
|
||||
// Allocated extents (from the extent tree) and free-space map across data chunks.
|
||||
let mut allocated: Vec<Extent> = vec![];
|
||||
if extent_root != 0 {
|
||||
let nodesize = fs.nodesize;
|
||||
@@ -431,7 +431,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let allocated = merge_extents(allocated);
|
||||
let mut free: Vec<Extent> = vec![];
|
||||
for c in fs.chunks.iter().filter(|c| c.ty & 0x1 != 0) {
|
||||
// blocos de dados: livres = chunk - alocado
|
||||
// data chunks: free = chunk_range - allocated_extents
|
||||
let mut cur = c.logical;
|
||||
let i = allocated.partition_point(|e| e.end() <= c.logical);
|
||||
for e in &allocated[i..] {
|
||||
@@ -453,7 +453,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
}
|
||||
res.free = Some(merge_extents(free));
|
||||
|
||||
// 2) Varre os blocos de metadata atrás de versões antigas de folhas de árvores de arquivos.
|
||||
// 2) Scan metadata chunks for older copy-on-write leaves of filesystem trees.
|
||||
let mut stale: Files = HashMap::new();
|
||||
let ns = fs.nodesize as usize;
|
||||
let step = fs.sectorsize as usize;
|
||||
@@ -482,7 +482,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let mut i = 0usize;
|
||||
while i + ns <= buf.len() && (off + i as u64) < c.len {
|
||||
let logical = c.logical + off + i as u64;
|
||||
// cabeçalho barato antes do CRC
|
||||
// cheap header sanity check before computing CRC32C
|
||||
if buf[i + 32..i + 48] == fs.fsid
|
||||
&& le64(&buf[i..], 48) == logical
|
||||
&& buf[i + 100] == 0
|
||||
@@ -505,7 +505,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Arquivos que só existem nas folhas antigas.
|
||||
// 3) Files that only exist in historical leaves.
|
||||
let path_of = |owner: u64, mut ino: u64| -> String {
|
||||
let mut parts: Vec<String> = vec![];
|
||||
for _ in 0..64 {
|
||||
@@ -584,7 +584,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
res
|
||||
}
|
||||
|
||||
/// Converte os itens EXTENT_DATA em extents físicos ou partes (inline/comprimidas).
|
||||
/// Converts `EXTENT_DATA` items into physical extents or composite parts (inline/compressed).
|
||||
fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc, bool, bool)> {
|
||||
let mut parts: Vec<Part> = vec![];
|
||||
let mut pos = 0u64;
|
||||
@@ -594,7 +594,7 @@ fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc,
|
||||
let mut hole = |parts: &mut Vec<Part>, len: u64| {
|
||||
if len > 0 {
|
||||
parts.push(Part::Run { offset: 0, len });
|
||||
// marcador: offset 0 + sparse tratado abaixo
|
||||
// placeholder: offset 0 + sparse hole handled below
|
||||
}
|
||||
};
|
||||
let _ = &mut hole;
|
||||
@@ -697,7 +697,7 @@ fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc,
|
||||
.collect();
|
||||
return Some((DataLoc::Extents { extents: ex }, any_alloc, all_ok));
|
||||
}
|
||||
// Buracos viram trechos esparsos: Part::Run com offset 0 não serve; usa Bytes de zeros pequenos.
|
||||
// Sparse holes: `Part::Run` with offset 0 is not used here; represent holes accordingly.
|
||||
let mut out: Vec<Part> = vec![];
|
||||
for (i, p) in parts.into_iter().enumerate() {
|
||||
if sparse_marks.contains(&i) {
|
||||
@@ -732,8 +732,8 @@ fn decode(codec: u8, raw: &[u8], ram: usize) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Formato de extensão LZO do Btrfs: tamanho total (u32) + segmentos [tamanho (u32)][dados LZO1X]
|
||||
/// de até um setor de 4 KiB descomprimido cada, sem cruzar fronteiras de setor.
|
||||
/// Btrfs LZO extent format: total length (`u32`) + `[seg_len (u32)][LZO1X payload]` segments
|
||||
/// of up to one 4 KiB uncompressed sector each, padded at sector boundaries.
|
||||
pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
|
||||
if raw.len() < 4 {
|
||||
return None;
|
||||
@@ -742,7 +742,7 @@ pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(ram);
|
||||
let mut p = 4usize;
|
||||
while p + 4 <= raw.len().min(total) && out.len() < ram {
|
||||
// pula o preenchimento até o próximo setor se sobrar menos de 4 bytes
|
||||
// skip sector-boundary padding when fewer than 4 bytes remain in the sector
|
||||
let in_sector = 4096 - (p % 4096);
|
||||
if in_sector < 4 {
|
||||
p += in_sector;
|
||||
@@ -758,7 +758,7 @@ pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// LZO1X (descompressão segura, no mesmo esquema de estados do kernel Linux).
|
||||
/// Safe LZO1X decompressor (following the Linux kernel state machine).
|
||||
pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(max_out);
|
||||
let mut ip = 0usize;
|
||||
@@ -778,7 +778,7 @@ pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
|
||||
*ip += 1;
|
||||
Some(v)
|
||||
};
|
||||
// estado: literais ao fim da instrução anterior (0..3) ou 4 depois de uma corrida de literais
|
||||
// state: trailing literals from the previous instruction (0..3), or 4 after a literal run
|
||||
let mut state;
|
||||
if byte(0)? > 17 {
|
||||
let t = byte(0)? - 17;
|
||||
@@ -859,7 +859,7 @@ fn copy_match(out: &mut Vec<u8>, dist: usize, len: usize) -> Option<()> {
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Arquivos e pastas atuais com caminho completo e tamanho (usa só as árvores vivas).
|
||||
/// Lists live files and directories with full path and size from current subvolume trees.
|
||||
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
|
||||
let Some(fs) = Fs::open(src, base) else {
|
||||
return vec![];
|
||||
@@ -900,18 +900,18 @@ pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Posições das árvores principais, para ferramentas e testes que precisam editar a imagem.
|
||||
/// Root locations of core Btrfs trees, used by tests that simulate COW deletions.
|
||||
pub struct TreeLocations {
|
||||
pub nodesize: u64,
|
||||
/// Endereço físico (absoluto na origem) do primeiro nó de cada árvore.
|
||||
/// Physical byte offset (in the source) of each tree root node.
|
||||
pub root_tree: u64,
|
||||
pub fs_tree: u64,
|
||||
pub extent_tree: u64,
|
||||
/// Endereços lógicos correspondentes.
|
||||
/// Corresponding logical `bytenr` addresses.
|
||||
pub root_tree_logical: u64,
|
||||
pub fs_tree_logical: u64,
|
||||
pub extent_tree_logical: u64,
|
||||
/// Converte endereço lógico em físico.
|
||||
/// Translates a logical `bytenr` to a physical source offset.
|
||||
pub chunks: Vec<(u64, u64, u64)>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! exFAT: conjuntos de entradas apagados (0x05/0x40/0x41) e bitmap de alocação.
|
||||
//! exFAT: deleted directory entry sets (`0x05`/`0x40`/`0x41`) and allocation bitmap.
|
||||
|
||||
use super::{
|
||||
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
|
||||
@@ -92,7 +92,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||
bitmap: vec![],
|
||||
};
|
||||
|
||||
// Diretório raiz: procura a entrada do bitmap (0x81).
|
||||
// Root directory: locate the allocation bitmap entry (0x81).
|
||||
let root_chain = vol.chain(vol.root);
|
||||
let root_data = vol.read(&root_chain, 64 << 20);
|
||||
let mut bm: Option<(u32, u64)> = None;
|
||||
@@ -115,7 +115,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||
.push("exFAT: bitmap de alocação não encontrado; confiança será menor.".into());
|
||||
}
|
||||
|
||||
// Extents vivos: metadados (antes do heap) + clusters alocados no bitmap.
|
||||
// Live extents: metadata region (before cluster heap) + clusters marked used in the bitmap.
|
||||
let mut live = vec![Extent::new(base, heap_off - base)];
|
||||
let mut run_start: Option<u32> = None;
|
||||
for c in 2..clusters + 2 {
|
||||
@@ -163,7 +163,7 @@ fn walk(
|
||||
break;
|
||||
}
|
||||
let ty = e[0];
|
||||
// 0x85 = arquivo/pasta vivo; 0x05 = apagado.
|
||||
// 0x85 = live file/dir entry; 0x05 = deleted file/dir entry.
|
||||
if ty != 0x85 && ty != 0x05 {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -214,7 +214,7 @@ fn walk(
|
||||
}
|
||||
let need = size.div_ceil(vol.cbytes) as u32;
|
||||
let cl: Vec<u32> = if no_chain || deleted {
|
||||
// Apagados: a cadeia na FAT pode ter sido preservada; senão presume contíguo.
|
||||
// Deleted files: follow the FAT chain if preserved, otherwise assume contiguous clusters.
|
||||
let chain = if !no_chain { vol.chain(first) } else { vec![] };
|
||||
if chain.len() as u32 >= need.max(1) {
|
||||
chain
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! ext2/3/4: arquivos apagados com nome, por três caminhos que se completam:
|
||||
//! ext2/3/4: recovers deleted files with names via three complementary techniques:
|
||||
//!
|
||||
//! 1. inodes com `dtime` cujo mapa de extents ainda tem sobras legíveis;
|
||||
//! 2. cópias antigas do inode guardadas no diário (jbd2), de antes da exclusão;
|
||||
//! 3. entradas de diretório "escondidas" na folga do `rec_len` (dão o nome).
|
||||
//! 1. deleted inodes (`dtime != 0`) whose extent tree or block pointers still have remnants;
|
||||
//! 2. pre-deletion inode copies preserved in the jbd2 journal;
|
||||
//! 3. unlinked directory entries hidden inside `rec_len` slack space (recovering filenames).
|
||||
//!
|
||||
//! Também devolve o mapa de blocos livres, para o carving varrer só o que está livre.
|
||||
//! Also returns the free-block map so file carving only scans unallocated blocks.
|
||||
|
||||
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
|
||||
use crate::signatures;
|
||||
@@ -103,7 +103,7 @@ struct Ext<'a> {
|
||||
journal_inum: u32,
|
||||
}
|
||||
|
||||
/// Sequência de (bloco lógico, bloco físico, quantidade, não-inicializado).
|
||||
/// Sequence of `(logical_block, physical_block, block_count, is_uninitialized)`.
|
||||
type ExtentList = Vec<(u64, u64, u64, bool)>;
|
||||
|
||||
impl<'a> Ext<'a> {
|
||||
@@ -201,7 +201,7 @@ impl<'a> Ext<'a> {
|
||||
Inode::parse(&self.inode_bytes(ino)?)
|
||||
}
|
||||
|
||||
/// Extents de uma árvore ext4 (raiz em `node`, 60 bytes ou um bloco).
|
||||
/// Collects extents from an ext4 extent tree (`node` is the 60-byte `i_block` root or a block).
|
||||
fn walk_extent_node(
|
||||
&self,
|
||||
node: &[u8],
|
||||
@@ -248,7 +248,7 @@ impl<'a> Ext<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Blocos de um inode ext2/3 (mapa de blocos clássico).
|
||||
/// Collects data blocks from an ext2/3 indirect block map.
|
||||
fn block_map(&self, blk: &[u8; 60], max_blocks: u64) -> ExtentList {
|
||||
let mut v: Vec<u64> = vec![];
|
||||
let ptr = |i: usize| le32(blk, i * 4) as u64;
|
||||
@@ -308,7 +308,7 @@ impl<'a> Ext<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Extents do arquivo (ordenados pelo bloco lógico), cortados em `size` bytes.
|
||||
/// Returns file byte extents (ordered by logical block) truncated to `size` bytes.
|
||||
fn file_extents(&self, ino: &Inode, size: u64) -> Vec<Extent> {
|
||||
let need = size.div_ceil(self.bs);
|
||||
let mut list: ExtentList = vec![];
|
||||
@@ -351,7 +351,7 @@ impl<'a> Ext<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Mapa de blocos livres (bitmap), em extents absolutos.
|
||||
/// Free-block map (from block bitmaps) as absolute byte extents.
|
||||
fn free_map(&self, ctx: &Ctx) -> (Vec<Extent>, Vec<bool>) {
|
||||
let mut free: Vec<Extent> = vec![];
|
||||
let mut allocated_all: Vec<bool> = vec![];
|
||||
@@ -418,7 +418,7 @@ struct DirEnt {
|
||||
live: bool,
|
||||
}
|
||||
|
||||
/// Lê as entradas de um bloco de diretório, inclusive as escondidas na folga.
|
||||
/// Parses directory entries in a directory block, including unlinked entries in `rec_len` slack.
|
||||
fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
|
||||
let mut off = 0usize;
|
||||
while off + 8 <= b.len() {
|
||||
@@ -437,11 +437,11 @@ fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
|
||||
live: true,
|
||||
});
|
||||
}
|
||||
// Folga: entradas apagadas ficam coladas depois do fim "real" desta.
|
||||
// Slack space: unlinked entries remain tucked right after the active entry's true length.
|
||||
let mut s = off + if ino != 0 || nlen > 0 { real } else { 8 };
|
||||
let end = off + rec;
|
||||
if ino == 0 {
|
||||
s = end; // entrada nula: sem folga confiável
|
||||
s = end; // zeroed entry: no reliable slack to scan
|
||||
}
|
||||
while s + 8 <= end {
|
||||
let dino = le32(b, s) as u64;
|
||||
@@ -472,9 +472,9 @@ fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
|
||||
}
|
||||
|
||||
struct Names {
|
||||
/// inode -> (pasta, nome) de entradas vivas.
|
||||
/// `inode -> (parent_dir, name)` from live directory entries.
|
||||
live: HashMap<u64, (String, String)>,
|
||||
/// inode -> (pasta, nome) de entradas apagadas (mais recente por varredura).
|
||||
/// `inode -> (parent_dir, name)` from unlinked slack directory entries.
|
||||
dead: HashMap<u64, (String, String)>,
|
||||
}
|
||||
|
||||
@@ -531,9 +531,9 @@ fn walk_dirs(ext: &Ext, ctx: &Ctx) -> Names {
|
||||
names
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- diário
|
||||
// ---------------------------------------------------------------- journal
|
||||
|
||||
/// Cópias de inodes guardadas no diário: inode -> lista (ordem do diário) de registros brutos.
|
||||
/// Inode copies found in the jbd2 journal: `inode_num -> Vec<raw_inode_bytes>` in journal order.
|
||||
fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
|
||||
let mut out: HashMap<u64, Vec<Vec<u8>>> = HashMap::new();
|
||||
if ext.journal_inum == 0 {
|
||||
@@ -543,7 +543,7 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
|
||||
return out;
|
||||
};
|
||||
let jext = ext.file_extents(&jino, jino.size.min(1 << 30));
|
||||
// Blocos físicos do diário em ordem lógica.
|
||||
// Physical blocks of the journal in logical sequence.
|
||||
let mut jblocks: Vec<u64> = vec![];
|
||||
for e in &jext {
|
||||
if e.sparse {
|
||||
@@ -576,9 +576,9 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
|
||||
8 + if is64 { 4 } else { 0 }
|
||||
};
|
||||
let have_csum = csum_v2 || csum_v3;
|
||||
// Tabela de inodes: faixa de blocos de cada grupo.
|
||||
// Inode table block ranges for each block group.
|
||||
let itable_blocks = (ext.ipg * ext.inode_size).div_ceil(ext.bs);
|
||||
let mut tables: Vec<(u64, u64, u64)> = vec![]; // (início, fim, primeiro inode)
|
||||
let mut tables: Vec<(u64, u64, u64)> = vec![]; // (start_block, end_block, first_inode_num)
|
||||
for (g, gr) in ext.groups.iter().enumerate() {
|
||||
tables.push((
|
||||
gr.inode_table,
|
||||
@@ -588,9 +588,9 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
|
||||
}
|
||||
let n = jblocks.len();
|
||||
let first = (be32(&jsb, 20) as usize).clamp(1, n);
|
||||
let mut seqs: Vec<(u32, usize)> = vec![]; // (sequência, índice do descritor)
|
||||
let mut seqs: Vec<(u32, usize)> = vec![]; // (commit_sequence, descriptor_index)
|
||||
let mut idx = first;
|
||||
// Varredura física: qualquer bloco que seja descritor, em ordem do diário.
|
||||
// Scan journal blocks for descriptor blocks in sequence order.
|
||||
while idx < n {
|
||||
if idx % 4096 == 4095 && (ctx.cancelled)() {
|
||||
break;
|
||||
@@ -664,7 +664,7 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
|
||||
out
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- varredura
|
||||
// -------------------------------------------------------------- scan
|
||||
|
||||
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let mut res = VolumeScan::default();
|
||||
@@ -680,7 +680,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let mut emitted: HashSet<u64> = HashSet::new();
|
||||
let mut seen_extents: HashSet<(u64, u64)> = HashSet::new();
|
||||
|
||||
// 1) Inodes apagados na tabela atual.
|
||||
// 1) Deleted inodes in the current inode table.
|
||||
let total = ext.groups.len() as u64;
|
||||
for (g, gr) in ext.groups.iter().enumerate() {
|
||||
if g % 16 == 15 && (ctx.cancelled)() {
|
||||
@@ -711,7 +711,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
if !deleted || !ino.is_reg() {
|
||||
continue;
|
||||
}
|
||||
// Melhor fonte: a cópia do diário anterior à exclusão; senão o que sobrou no inode.
|
||||
// Best source: pre-deletion copy from the journal; fallback to remnants in the current inode.
|
||||
let mut cand: Option<(Inode, &str)> = None;
|
||||
if let Some(snaps) = journal.get(&ino_no) {
|
||||
for raw in snaps.iter().rev() {
|
||||
@@ -749,13 +749,13 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Inodes que só existem no diário (liberados e já reaproveitados/zerados).
|
||||
// 2) Inodes only preserved in the journal (already zeroed in the main inode table).
|
||||
for (ino_no, snaps) in &journal {
|
||||
if emitted.contains(ino_no) || names.live.contains_key(ino_no) || *ino_no < 11 {
|
||||
continue;
|
||||
}
|
||||
let cur = ext.read_inode(*ino_no);
|
||||
// Se o inode atual está vivo e é outro arquivo, não mexe.
|
||||
// Skip if the inode is currently live in the filesystem.
|
||||
if cur
|
||||
.as_ref()
|
||||
.map(|c| c.dtime == 0 && c.links > 0 && c.mode != 0)
|
||||
@@ -805,7 +805,7 @@ fn emit_inode(
|
||||
if ino.flags & EXT4_EXTENTS_FL != 0 {
|
||||
let mut list: ExtentList = vec![];
|
||||
if !ext.walk_extent_node(&ino.block, 5, &mut list, true) {
|
||||
// Sobras de extent em inode truncado: cabeçalho zerado, registros ainda lá.
|
||||
// Extent remnants in a truncated inode: header count was zeroed, but extent entries remain.
|
||||
list = remnant_extents(ext, &ino.block);
|
||||
}
|
||||
if list.is_empty() {
|
||||
@@ -834,7 +834,7 @@ fn emit_inode(
|
||||
if !seen.insert((first.0, first.1)) {
|
||||
return false;
|
||||
}
|
||||
// Quantos blocos de dados continuam livres? Reaproveitados => baixa confiança.
|
||||
// Check how many data blocks are still unallocated; reallocated blocks lower confidence.
|
||||
let mut reused = 0u64;
|
||||
let mut total_blocks = 0u64;
|
||||
for e in extents.iter().filter(|e| !e.sparse) {
|
||||
@@ -859,7 +859,7 @@ fn emit_inode(
|
||||
));
|
||||
}
|
||||
if estimated {
|
||||
// Tamanho desconhecido: tenta achar o fim real pelo conteúdo.
|
||||
// Unknown exact size (zeroed on deletion): try to refine size via format structure.
|
||||
if let Some(h) = extents
|
||||
.iter()
|
||||
.find(|e| !e.sparse)
|
||||
@@ -932,7 +932,7 @@ fn layout_from_list(ext: &Ext, list: &ExtentList, size: u64) -> Vec<Extent> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Lê registros de extent deixados em i_block depois que o cabeçalho foi zerado.
|
||||
/// Reads leftover extent records from `i_block` after `eh_entries` was zeroed on deletion.
|
||||
fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList {
|
||||
let mut out: ExtentList = vec![];
|
||||
if le16(block, 0) != EXT_MAGIC || le16(block, 6) != 0 {
|
||||
@@ -958,8 +958,8 @@ fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList {
|
||||
out
|
||||
}
|
||||
|
||||
/// Quão coerente é a estrutura do volume: fração das entradas de diretório cujo inode confere
|
||||
/// com o tipo declarado. Serve para escolher parâmetros de RAID sem metadados.
|
||||
/// Measures filesystem structural coherence: fraction of directory entries whose target inode
|
||||
/// matches the declared file type. Used to score candidate metadata-less RAID parameters.
|
||||
pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> {
|
||||
let ext = Ext::open(src, base)?;
|
||||
let mut queue = vec![2u64];
|
||||
@@ -988,8 +988,8 @@ pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> {
|
||||
o += ext.bs;
|
||||
}
|
||||
}
|
||||
// A entrada "." de um diretório aponta para ele mesmo: só confere se inode e bloco
|
||||
// vieram do lugar certo.
|
||||
// A directory's "." entry points to itself: only matches if both the inode table and
|
||||
// directory data block were mapped from the right RAID stripe.
|
||||
dots_total += 1;
|
||||
if ents.iter().any(|e| e.live && e.name == "." && e.ino == d) {
|
||||
dots_ok += 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! FAT12/16/32: entradas de diretório apagadas (inclusive nomes longos) e
|
||||
//! reconstrução dos clusters pela regra "próximos clusters livres".
|
||||
//! FAT12/16/32: deleted directory entries (including VFAT Long File Names) and
|
||||
//! cluster chain reconstruction using contiguous free clusters.
|
||||
|
||||
use super::{make_found, merge_extents, sanitize_name, utf16le, Ctx, NewFound, VolumeScan};
|
||||
use crate::source::{read_vec, Source};
|
||||
@@ -118,7 +118,7 @@ impl<'a> Fat<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cadeia de clusters viva a partir de `start` (com limite contra ciclos).
|
||||
/// Follows a live FAT cluster chain from `start` (bounded against cycles).
|
||||
fn chain(&self, start: u32) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
let mut c = start;
|
||||
@@ -172,7 +172,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, kind: FsKind) -> VolumeScan {
|
||||
.push("FAT: setor de boot ilegível; usando apenas assinatura.".into());
|
||||
return res;
|
||||
};
|
||||
// Estruturas do sistema (reservado + FATs + raiz fixa) são "vivas".
|
||||
// Filesystem structures (reserved sectors + FATs + fixed root dir) count as allocated.
|
||||
let mut live = vec![Extent::new(base, fat.data_off - base)];
|
||||
let mut w = Walk {
|
||||
fat: &fat,
|
||||
@@ -257,7 +257,7 @@ impl Walk<'_, '_, '_, '_> {
|
||||
continue;
|
||||
}
|
||||
if deleted || in_deleted {
|
||||
// Diretório apagado: lê clusters contíguos livres enquanto parecerem entradas.
|
||||
// Deleted directory: read contiguous free clusters while they look like directory entries.
|
||||
let cl = self.contiguous_free(start, 16);
|
||||
let data = self
|
||||
.fat
|
||||
@@ -359,7 +359,7 @@ impl Walk<'_, '_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Corta a lista de extents para exatamente `size` bytes.
|
||||
/// Truncates an extent list to cover at most `size` bytes.
|
||||
pub fn trim_extents(mut ex: Vec<Extent>, size: u64) -> Vec<Extent> {
|
||||
let mut left = size;
|
||||
let mut out = vec![];
|
||||
@@ -416,15 +416,15 @@ fn long_name(parts: &[(u8, [u16; 13])], short: &[u8; 11]) -> Option<String> {
|
||||
for &b in short {
|
||||
sum = (sum >> 1).wrapping_add((sum & 1) << 7).wrapping_add(b);
|
||||
}
|
||||
// Entradas LFN de um arquivo apagado preservam o checksum do nome curto original,
|
||||
// porém o primeiro byte do nome curto virou 0xE5; tentamos as duas variantes.
|
||||
// Deleted LFN entries preserve the checksum of the original 8.3 short name,
|
||||
// whereas the short entry's first byte became 0xE5; check both variants.
|
||||
let ok = parts.iter().all(|p| p.0 == parts[0].0);
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
let target = parts[0].0;
|
||||
if sum != target {
|
||||
// Nome curto apagado perdeu o 1º caractere: aceita se o resto bater por tentativa.
|
||||
// Deleted 8.3 name lost its first byte: accept LFN checksum if any valid first byte matches.
|
||||
let mut hit = false;
|
||||
for c in 0u8..=255 {
|
||||
let mut s2 = *short;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! HFS+ / HFSX (inclusive volumes embutidos num wrapper HFS clássico).
|
||||
//! HFS+ / HFSX (including volumes embedded inside a classic HFS wrapper).
|
||||
//!
|
||||
//! Lê o catálogo (árvore B+) para nomes e caminhos, o bitmap de alocação para o mapa de
|
||||
//! espaço livre e procura arquivos apagados em três lugares: sobras dentro dos nós folha do
|
||||
//! catálogo, cópias antigas de nós no diário (journal) e registros de arquivo "soltos".
|
||||
//! Reads the catalog B-tree for names and paths, the allocation bitmap for free-space
|
||||
//! mapping, and searches for deleted files in three places: slack space inside catalog leaf
|
||||
//! nodes, older leaf node copies in the journal, and orphaned file records.
|
||||
|
||||
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
|
||||
use crate::source::{read_vec, Source};
|
||||
@@ -49,7 +49,7 @@ fn parse_fork(b: &[u8]) -> Fork {
|
||||
f
|
||||
}
|
||||
|
||||
/// Posição do volume HFS+ dentro da partição (wrapper HFS clássico) ou `None` se não for HFS+.
|
||||
/// Byte offset of the HFS+ volume inside the partition (handles HFS wrapper), or `None`.
|
||||
pub fn locate(src: &dyn Source, base: u64) -> Option<u64> {
|
||||
let h = read_vec(src, base + 1024, 512).ok()?;
|
||||
if h.len() < 512 {
|
||||
@@ -106,7 +106,7 @@ impl<'a> Vol<'a> {
|
||||
self.base + b * self.bs
|
||||
}
|
||||
|
||||
/// Todas as extensões de um fork (inclusive as do arquivo de overflow).
|
||||
/// Returns all extents of a fork (including overflow extents from the extents B-tree).
|
||||
fn fork_extents(&self, f: &Fork, file_id: u32, fork_type: u8) -> Vec<Extent> {
|
||||
let mut ex: Vec<(u32, u32)> = f.extents.clone();
|
||||
let mut have: u64 = ex.iter().map(|e| e.1 as u64).sum();
|
||||
@@ -124,7 +124,7 @@ impl<'a> Vol<'a> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extensões além das oito iniciais, procuradas no arquivo de overflow (árvore B+).
|
||||
/// Looks up extents beyond the first eight in the extents overflow B-tree.
|
||||
fn overflow_extents(&self, file_id: u32, fork_type: u8, mut start: u32) -> Vec<(u32, u32)> {
|
||||
let mut out = vec![];
|
||||
let node_size = match self.tree_node_size(&self.extents_file) {
|
||||
@@ -140,7 +140,7 @@ impl<'a> Vol<'a> {
|
||||
continue;
|
||||
}
|
||||
for r in records(&node) {
|
||||
// chave: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32
|
||||
// key: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32
|
||||
if r.len() < 12 + 64 || be16(r, 0) != 10 {
|
||||
continue;
|
||||
}
|
||||
@@ -170,7 +170,7 @@ impl<'a> Vol<'a> {
|
||||
self.read_fork_bytes(f, n as u64 * ns as u64, ns)
|
||||
}
|
||||
|
||||
/// Lê bytes de um fork que tem só extensões iniciais ou overflow já resolvido (para árvores).
|
||||
/// Reads bytes from a fork using its resolved extent list (used for system B-trees).
|
||||
fn read_fork_bytes(&self, f: &Fork, off: u64, len: usize) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(len);
|
||||
let mut pos = 0u64;
|
||||
@@ -190,7 +190,7 @@ impl<'a> Vol<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registros de um nó B+ (fatias, da chave em diante).
|
||||
/// Returns the record slices of a B-tree node (each starting at its key).
|
||||
fn records(node: &[u8]) -> Vec<&[u8]> {
|
||||
let n = be16(node, 10) as usize;
|
||||
let mut offs = vec![];
|
||||
@@ -233,7 +233,7 @@ fn date(d: u32) -> Option<i64> {
|
||||
(d != 0).then(|| d as i64 - HFS_EPOCH)
|
||||
}
|
||||
|
||||
/// Decodifica um registro do catálogo (chave + dados). `max_id` descarta lixo.
|
||||
/// Decodes a catalog record (key + data). `max_id` filters out garbage CNIDs.
|
||||
fn decode_record(
|
||||
r: &[u8],
|
||||
max_id: u32,
|
||||
@@ -265,7 +265,7 @@ fn decode_record(
|
||||
}
|
||||
}
|
||||
3 if rec.len() >= 10 => {
|
||||
// thread de pasta: chave = (id da pasta, ""); dados = (pai, nome)
|
||||
// folder thread record: key = (folder_id, ""); data = (parent_id, folder_name)
|
||||
let pn = be16(rec, 8) as usize;
|
||||
if pn <= 255 {
|
||||
threads.entry(parent).or_insert((
|
||||
@@ -304,7 +304,7 @@ fn decode_record(
|
||||
}
|
||||
}
|
||||
|
||||
/// Procura registros de arquivo nas sobras de um nó (entre os dados e a tabela de offsets).
|
||||
/// Carves deleted catalog file records from B-tree node slack space (between active records and offset table).
|
||||
fn scan_slack(
|
||||
node: &[u8],
|
||||
max_id: u32,
|
||||
@@ -346,7 +346,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
return res;
|
||||
};
|
||||
|
||||
// Mapa de espaço livre.
|
||||
// Free-space map from the allocation file.
|
||||
if let Some(bm) = vol.read_fork_all(&vol.alloc, (vol.total / 8 + 1) as usize) {
|
||||
let mut free = vec![];
|
||||
let mut run: Option<u64> = None;
|
||||
@@ -409,7 +409,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
n = be32(&node, 0);
|
||||
}
|
||||
|
||||
// Diário: cópias antigas de nós folha do catálogo.
|
||||
// Journal: older copies of catalog leaf nodes.
|
||||
if vol.journal_info != 0 {
|
||||
scan_journal(
|
||||
&vol,
|
||||
@@ -422,7 +422,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
);
|
||||
}
|
||||
|
||||
// Caminho de cada pasta a partir dos threads.
|
||||
// Reconstruct folder paths from folder thread records.
|
||||
let path_of = |parent: u32| -> String {
|
||||
let mut parts = vec![];
|
||||
let mut cur = parent;
|
||||
@@ -462,9 +462,9 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
if !seen.insert((f.id, ex[0].offset)) {
|
||||
continue;
|
||||
}
|
||||
// Mantém só os bytes do arquivo.
|
||||
// Trim extents to the logical file size.
|
||||
let extents = super::fat::trim_extents(ex, f.data.size);
|
||||
// Blocos já reaproveitados?
|
||||
// Check whether any blocks have already been reallocated.
|
||||
let reused = extents.iter().filter(|e| !covered_by(&free_set, e)).count();
|
||||
let (conf, note) = if reused == 0 {
|
||||
(
|
||||
@@ -523,7 +523,7 @@ impl Vol<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Procura nós folha do catálogo nas transações guardadas no diário.
|
||||
/// Scans the HFS+ journal buffer for historical catalog leaf nodes.
|
||||
fn scan_journal(
|
||||
vol: &Vol,
|
||||
ns: usize,
|
||||
@@ -556,7 +556,7 @@ fn scan_journal(
|
||||
let mut i = 0;
|
||||
while i + ns <= buf.len().min(n + ns) && i < n {
|
||||
let nd = &buf[i..i + ns];
|
||||
// nó folha do catálogo: kind = -1, altura 1, registros plausíveis
|
||||
// catalog leaf node: kind = -1, height = 1, plausible record offsets
|
||||
if nd[8] == 0xFF && nd[9] == 1 && be16(nd, 10) > 0 && (be16(nd, 10) as usize) < ns / 8 {
|
||||
let table = ns - 2 * (be16(nd, 10) as usize + 1);
|
||||
if (be16(nd, ns - 2) as usize) == 14
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Leitores de metadados de sistemas de arquivos (somente leitura).
|
||||
//! Read-only filesystem metadata readers.
|
||||
//!
|
||||
//! Cada leitor percorre estruturas do volume atrás de arquivos apagados,
|
||||
//! devolve os extents ainda alocados ("live") para o carving pular e, quando
|
||||
//! possível, os trechos livres.
|
||||
//! Each reader walks volume metadata structures looking for deleted files,
|
||||
//! returns currently allocated ("live") extents so file carving can skip them,
|
||||
//! and, when available, returns the exact free-space map.
|
||||
|
||||
pub mod apfs;
|
||||
pub mod btrfs;
|
||||
@@ -18,12 +18,12 @@ use crate::source::{read_vec, Source};
|
||||
use crate::types::{Confidence, DataLoc, Extent, Found, Origin};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Contexto compartilhado por todos os leitores.
|
||||
/// Context shared across all filesystem metadata readers.
|
||||
pub struct Ctx<'a> {
|
||||
pub src: &'a dyn Source,
|
||||
pub next_id: &'a AtomicU64,
|
||||
pub cancelled: &'a (dyn Fn() -> bool + Sync),
|
||||
/// (feito, total) dentro da fase atual do leitor.
|
||||
/// Reports `(done, total)` progress within the current reader phase.
|
||||
pub progress: &'a (dyn Fn(u64, u64) + Sync),
|
||||
pub emit: &'a mut dyn FnMut(Found),
|
||||
}
|
||||
@@ -34,12 +34,12 @@ impl Ctx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resultado de varrer um volume.
|
||||
/// Output of scanning a volume's metadata.
|
||||
#[derive(Default)]
|
||||
pub struct VolumeScan {
|
||||
/// Extents (absolutos) ocupados por arquivos existentes e metadados.
|
||||
/// Absolute byte extents occupied by live files and filesystem metadata.
|
||||
pub live: Vec<Extent>,
|
||||
/// Se o leitor conhece o mapa de espaço livre (ext), os trechos livres (absolutos).
|
||||
/// Absolute free byte extents when the filesystem provides an explicit allocation bitmap.
|
||||
pub free: Option<Vec<Extent>>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -57,7 +57,7 @@ pub struct NewFound<'a> {
|
||||
pub note: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Monta um `Found`, ajustando a confiança pelo conteúdo real no disco.
|
||||
/// Builds a `Found` record, adjusting confidence based on actual on-disk header bytes.
|
||||
pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
|
||||
let ext = n
|
||||
.name
|
||||
@@ -116,7 +116,7 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordena e funde extents adjacentes/sobrepostos.
|
||||
/// Sorts and merges adjacent or overlapping byte extents.
|
||||
pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
|
||||
v.retain(|e| e.len > 0);
|
||||
v.sort_by_key(|e| e.offset);
|
||||
@@ -135,7 +135,7 @@ pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Há sobreposição entre [off, off+len) e algum extent de `sorted` (já mesclado)?
|
||||
/// Returns `true` if `[off, off + len)` overlaps any extent in `sorted` (must be pre-merged).
|
||||
pub fn overlaps(sorted: &[Extent], off: u64, len: u64) -> bool {
|
||||
let i = sorted.partition_point(|e| e.end() <= off);
|
||||
sorted.get(i).map(|e| e.offset < off + len).unwrap_or(false)
|
||||
@@ -159,7 +159,7 @@ pub fn dos_to_unix(date: u16, time: u16) -> Option<i64> {
|
||||
Some(days_from_civil(year, month, day) * 86400 + h * 3600 + m * 60 + s)
|
||||
}
|
||||
|
||||
/// Dias desde 1970-01-01 (algoritmo de Howard Hinnant).
|
||||
/// Days since 1970-01-01 UTC (Howard Hinnant civil calendar algorithm).
|
||||
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! NTFS: percorre a MFT atrás de registros apagados, resolve caminhos e
|
||||
//! reconstrói os data runs. Coleta também os extents vivos.
|
||||
//! NTFS: walks the MFT for deleted file records, reconstructs folder paths,
|
||||
//! and decodes data runs. Also collects live allocated extents.
|
||||
|
||||
use super::{
|
||||
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
|
||||
@@ -9,10 +9,10 @@ use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Run {
|
||||
/// Cluster lógico inicial no arquivo (VCN).
|
||||
/// Starting Virtual Cluster Number (VCN) within the file.
|
||||
vcn: u64,
|
||||
len: u64,
|
||||
/// None = esparso.
|
||||
/// `None` = sparse run.
|
||||
lcn: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Registro 0 ($MFT) define onde está toda a MFT.
|
||||
// MFT record 0 ($MFT) defines the data runs covering the entire MFT.
|
||||
let mut mft_runs: Option<(Vec<Run>, u64)> = None;
|
||||
for lcn in [mft_lcn, mirr_lcn] {
|
||||
if let Some(mut r) = read_vec(ctx.src, base + lcn * cl, rec_size as usize)
|
||||
@@ -96,7 +96,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||
let mut dirs: HashMap<u64, (u64, String)> = HashMap::new();
|
||||
let mut deleted: Vec<(u64, Rec)> = vec![];
|
||||
|
||||
// Lê a MFT em blocos grandes seguindo os runs.
|
||||
// Read the MFT in large chunks across its data runs.
|
||||
let chunk_recs = (1 << 20) / rec_size.max(1);
|
||||
let mut idx = 0u64;
|
||||
let mut buf: Vec<u8>;
|
||||
@@ -161,7 +161,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||
unsupported,
|
||||
} => {
|
||||
if unsupported {
|
||||
continue; // comprimido/cifrado (EFS): fora do escopo da v1
|
||||
continue; // compressed/encrypted (EFS) attributes are skipped
|
||||
}
|
||||
let mut ex = vec![];
|
||||
for r in &runs {
|
||||
@@ -291,7 +291,7 @@ fn le64(b: &[u8], o: usize) -> u64 {
|
||||
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Interpreta um registro de arquivo da MFT (aplica fixups no próprio buffer).
|
||||
/// Parses an MFT `FILE` record (applies USA fixups in place).
|
||||
fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
||||
if r.len() < 48 || &r[0..4] != b"FILE" {
|
||||
return None;
|
||||
@@ -308,7 +308,7 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
||||
break;
|
||||
}
|
||||
if r[end - 2..end] != usn {
|
||||
return None; // registro rasgado
|
||||
return None; // torn MFT record (fixup mismatch)
|
||||
}
|
||||
r[end - 2] = r[usa_off + i * 2];
|
||||
r[end - 1] = r[usa_off + i * 2 + 1];
|
||||
@@ -316,7 +316,7 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
||||
let flags = le16(r, 22);
|
||||
let base_ref = le64(r, 32) & 0xFFFF_FFFF_FFFF;
|
||||
if base_ref != 0 {
|
||||
return None; // registro de extensão: tratado só via lista de atributos (não suportado)
|
||||
return None; // extension MFT record (handled via $ATTRIBUTE_LIST, skipped as standalone)
|
||||
}
|
||||
let mut rec = Rec {
|
||||
in_use: flags & 1 != 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! XFS (v4 e v5): mapa de espaço livre pela árvore de blocos livres de cada grupo de alocação e
|
||||
//! arquivos apagados. Ao liberar um inode, o XFS zera o modo e o tamanho mas não apaga a lista de
|
||||
//! extents que ficou dentro do inode; e a entrada de diretório liberada perde só os 4 primeiros
|
||||
//! bytes (o número do inode), preservando o nome. Juntamos as duas sobras.
|
||||
//! XFS (v4 and v5): free-space map from each allocation group's free-block B+tree and deleted
|
||||
//! file recovery. When freeing an inode, XFS zeroes mode and size if not fully wiped, leaving
|
||||
//! inline extent records in the inode fork; freed directory entries only overwrite the first
|
||||
//! 4 bytes of the 8-byte inode number, preserving the low 32 bits and the filename.
|
||||
|
||||
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
|
||||
use crate::signatures;
|
||||
@@ -88,7 +88,7 @@ impl<'a> Xfs<'a> {
|
||||
self.base + agno * self.agblocks * self.bs
|
||||
}
|
||||
|
||||
/// Endereço físico de um bloco "fsblock" (agno << agblklog | agbno).
|
||||
/// Converts an XFS `fsblock` (`agno << agblklog | agbno`) to a physical byte offset.
|
||||
fn fsb_off(&self, fsb: u64) -> Option<u64> {
|
||||
let (agno, agbno) = (fsb >> self.agblklog, fsb & ((1 << self.agblklog) - 1));
|
||||
(agno < self.agcount && agbno < self.agblocks).then(|| self.ag_off(agno) + agbno * self.bs)
|
||||
@@ -116,7 +116,7 @@ impl<'a> Xfs<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extents de um inode com a lista no próprio inode (formato 2) ou em árvore (formato 3).
|
||||
/// Reads extents from an inode data fork (format 2 inline extent list or format 3 B+tree).
|
||||
fn extents_of(&self, ino: &[u8], max_blocks: u64) -> Vec<(u64, u64, u64)> {
|
||||
let fmt = ino[5];
|
||||
let start = self.core_size(ino);
|
||||
@@ -135,7 +135,7 @@ impl<'a> Xfs<'a> {
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// raiz da árvore de extents no inode: nível, nregistros, chaves e ponteiros
|
||||
// B+tree root inside the inode fork: level, numrecs, keys, and block pointers
|
||||
let level = be16(lit, 0) as usize;
|
||||
let nrecs = be16(lit, 2) as usize;
|
||||
let maxrecs = (lit.len() - 4) / 16;
|
||||
@@ -180,7 +180,7 @@ impl<'a> Xfs<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registro de extent empacotado em 128 bits: flag(1) | início no arquivo(54) | bloco(52) | quantidade(21).
|
||||
/// Unpacks a 128-bit XFS extent record: `flag(1) | startoff(54) | startblock(52) | blockcount(21)`.
|
||||
fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
|
||||
let hi = u64::from_be_bytes(r[0..8].try_into().unwrap());
|
||||
let lo = u64::from_be_bytes(r[8..16].try_into().unwrap());
|
||||
@@ -190,7 +190,7 @@ fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
|
||||
(startoff, startblock, count)
|
||||
}
|
||||
|
||||
/// Percorre as folhas de uma árvore B+ curta, entregando cada registro de 16 (inobt) ou 8 (bnobt) bytes.
|
||||
/// Walks the leaves of an AG B+tree, yielding each 16-byte (`inobt`) or 8-byte (`bnobt`) record.
|
||||
fn walk_short_btree(
|
||||
x: &Xfs,
|
||||
agno: u64,
|
||||
@@ -201,7 +201,7 @@ fn walk_short_btree(
|
||||
f: &mut dyn FnMut(&[u8]),
|
||||
) {
|
||||
let mut cur = root;
|
||||
// desce pela esquerda até a folha
|
||||
// descend leftmost branch down to the leaf level
|
||||
for lvl in (1..levels).rev() {
|
||||
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else {
|
||||
return;
|
||||
@@ -241,10 +241,10 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
return res;
|
||||
};
|
||||
|
||||
// Espaço livre por grupo e inodes livres.
|
||||
// Collect free-space ranges and free inode records across all allocation groups.
|
||||
let mut free: Vec<Extent> = vec![];
|
||||
let mut free_set: Vec<(u64, u64)> = vec![]; // (fsblock inicial, quantidade) para checar blocos
|
||||
let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, máscara livre)
|
||||
let mut free_set: Vec<(u64, u64)> = vec![]; // (start_fsblock, block_count) for free-block checks
|
||||
let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, free_bitmask)
|
||||
for agno in 0..x.agcount {
|
||||
if (ctx.cancelled)() {
|
||||
return res;
|
||||
@@ -278,10 +278,10 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
.is_some_and(|&(s, c)| s <= fsb && s + c >= fsb + count)
|
||||
};
|
||||
|
||||
// Nomes: entradas de diretório liberadas (guardam o nome e os 4 bytes baixos do inode).
|
||||
// Names: freed directory entries (preserve filename and low 32 bits of the inode number).
|
||||
let (live_dirs, stale_names) = collect_names(&x, ctx);
|
||||
|
||||
// Inodes livres com a lista de extents ainda dentro do inode.
|
||||
// Free inodes that still contain leftover extent records in their data fork.
|
||||
let max_blocks = x.dblocks;
|
||||
let mut seen_first: HashSet<u64> = HashSet::new();
|
||||
for (agno, startino, mask) in &chunks {
|
||||
@@ -290,7 +290,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
}
|
||||
for i in 0..64u64 {
|
||||
if mask >> i & 1 == 0 {
|
||||
continue; // em uso
|
||||
continue; // allocated inode
|
||||
}
|
||||
let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i);
|
||||
let Some(raw) = x.read_ino(ino) else { continue };
|
||||
@@ -298,13 +298,13 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
if mode != 0 && be32(&raw, 16) != 0 {
|
||||
continue;
|
||||
}
|
||||
// Só inodes que foram arquivos: o formato de extents continua gravado e há sobras.
|
||||
// Check freed inodes that still have non-zero extent records in the data fork.
|
||||
if raw[5] != 2 {
|
||||
continue;
|
||||
}
|
||||
let start = x.core_size(&raw);
|
||||
let lit = &raw[start..];
|
||||
// lê registros plausíveis em sequência
|
||||
// read consecutive plausible extent records
|
||||
let mut ext: Vec<(u64, u64, u64)> = vec![];
|
||||
let mut next_off = 0u64;
|
||||
for k in 0..lit.len() / 16 {
|
||||
@@ -346,7 +346,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
} else {
|
||||
"blocos do arquivo já reaproveitados".to_string()
|
||||
};
|
||||
// tamanho exato: tenta achar o fim real pelo conteúdo
|
||||
// exact size was zeroed on deletion: refine size from file structure when possible
|
||||
let mut est = true;
|
||||
if let Some(h) = read_vec(ctx.src, extents[0].offset, 128)
|
||||
.ok()
|
||||
@@ -394,7 +394,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
res
|
||||
}
|
||||
|
||||
/// Percorre os diretórios vivos; devolve (caminho de cada diretório por inode, nome liberado por inode baixo).
|
||||
/// Walks live directories; returns `(dir_paths_by_inode, freed_names_by_low_inode_u32)`.
|
||||
fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64, String)>) {
|
||||
let mut paths: HashMap<u64, String> = HashMap::new();
|
||||
let mut stale: HashMap<u64, (u64, String)> = HashMap::new();
|
||||
@@ -414,7 +414,7 @@ fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64
|
||||
let start = x.core_size(&raw);
|
||||
match raw[5] {
|
||||
1 => {
|
||||
// pasta curta guardada no inode
|
||||
// shortform directory stored inline inside the inode
|
||||
let lit = &raw[start..];
|
||||
let count = lit[0] as usize;
|
||||
let i8 = lit[1] != 0;
|
||||
@@ -443,7 +443,7 @@ fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64
|
||||
let ex = x.extents_of(&raw, 1 << 20);
|
||||
let _ = size;
|
||||
for (off, fsb, cnt) in ex {
|
||||
// blocos de dados de diretório ficam antes do offset de 32 GiB (depois vêm folhas e índice)
|
||||
// directory data blocks live below the 32 GiB logical offset (leaf/freeindex blocks live above)
|
||||
if off * x.bs >= 32u64 << 30 {
|
||||
continue;
|
||||
}
|
||||
@@ -479,7 +479,7 @@ fn parse_dir_block(
|
||||
_ => return,
|
||||
};
|
||||
let is_block = magic == b"XD2B" || magic == b"XDB3";
|
||||
// em blocos únicos, o final tem a tabela de folhas
|
||||
// single-block directory: trim the trailing leaf/tail table
|
||||
let end = if is_block {
|
||||
let cnt = be32(b, b.len() - 8) as usize;
|
||||
b.len().saturating_sub(8 + cnt * 8)
|
||||
@@ -493,7 +493,7 @@ fn parse_dir_block(
|
||||
if len < 8 || p + len > end {
|
||||
break;
|
||||
}
|
||||
// entrada liberada: sobraram (inode baixo, nome); outras ainda podem estar dentro da região
|
||||
// freed entry region: extract `(low_inode_u32, name)` and any subsequent unlinked entries inside the region
|
||||
scan_free_region(x, &b[p..p + len], dino, stale);
|
||||
p += len;
|
||||
continue;
|
||||
@@ -517,7 +517,7 @@ fn parse_dir_block(
|
||||
}
|
||||
|
||||
fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u64, String)>) {
|
||||
// 1) a primeira entrada perdeu só os 4 primeiros bytes do número do inode
|
||||
// 1) the first unlinked entry at the start of the free tag lost only the upper 4 bytes of its inode number
|
||||
let try_at = |p: usize, low: u64, stale: &mut HashMap<u64, (u64, String)>| -> Option<usize> {
|
||||
let nl = *r.get(p + 8)? as usize;
|
||||
let name = r.get(p + 9..p + 9 + nl)?;
|
||||
@@ -532,7 +532,7 @@ fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u
|
||||
if r.len() >= 16 {
|
||||
let low = be32(r, 4) as u64;
|
||||
if let Some(sz) = try_at(0, low, stale) {
|
||||
// entradas seguintes dentro da mesma região, com o inode inteiro
|
||||
// subsequent coalesced entries inside the same free region still have their full 8-byte inode number
|
||||
let mut p = sz;
|
||||
while p + 16 <= r.len() {
|
||||
let ino = be64(r, p);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Hashes de integridade usados em perícia: MD5, SHA-1 e SHA-256 numa única passada.
|
||||
//! Forensic integrity hashes: computes MD5, SHA-1, and SHA-256 in a single pass.
|
||||
|
||||
use md5::Md5;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Saúde do disco antes de varrer: lê o SMART (via `smartctl`, quando instalado) e
|
||||
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma
|
||||
//! imagem primeiro: cada leitura extra pode piorar o estado dele.
|
||||
//! Pre-scan disk health assessment: reads SMART data (via `smartctl`, when installed) and
|
||||
//! kernel I/O error counters. A failing drive should be imaged first, as every additional
|
||||
//! read pass can accelerate media degradation.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -8,43 +8,43 @@ use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Health {
|
||||
/// Foi possível avaliar? (imagens de disco e dispositivos sem SMART ficam sem avaliação)
|
||||
/// Whether SMART assessment was possible (disk images and unsupported bridges return `false`).
|
||||
pub available: bool,
|
||||
/// "ok", "warning", "failing" ou "unknown".
|
||||
/// Overall status: `"ok"`, `"warning"`, `"failing"`, or `"unknown"`.
|
||||
pub status: String,
|
||||
pub summary: String,
|
||||
pub details: Vec<(String, String)>,
|
||||
pub advice: Vec<String>,
|
||||
/// Ficha do dispositivo, no estilo do CrystalDiskInfo (modelo, firmware, interface...).
|
||||
/// CrystalDiskInfo-style device summary (model, firmware, interface, etc.).
|
||||
pub info: Vec<(String, String)>,
|
||||
/// Saúde estimada em % (SSD: vida útil restante). `None` em HD, que não informa isso.
|
||||
/// Estimated health percentage (SSD remaining life). `None` for HDDs that do not report it.
|
||||
pub health_pct: Option<u8>,
|
||||
pub temperature_c: Option<i64>,
|
||||
pub power_on_hours: Option<i64>,
|
||||
pub power_cycles: Option<i64>,
|
||||
/// Total lido e gravado pelo computador, em bytes, quando o disco informa.
|
||||
/// Total host bytes read and written, when reported by the drive.
|
||||
pub host_reads: Option<u64>,
|
||||
pub host_writes: Option<u64>,
|
||||
/// Tabela completa de atributos SMART.
|
||||
/// Full table of SMART attributes.
|
||||
pub attributes: Vec<Attr>,
|
||||
/// Caminho avaliado de fato (o disco inteiro, quando `path` é uma partição).
|
||||
/// Actual device path queried (the containing whole disk when `path` is a partition).
|
||||
pub disk: String,
|
||||
/// Partição pedida, quando a avaliação foi feita no disco que a contém.
|
||||
/// Original partition path when the query was redirected to its parent disk.
|
||||
pub partition: Option<String>,
|
||||
}
|
||||
|
||||
/// Uma linha da tabela de atributos SMART.
|
||||
/// A single row in the SMART attribute table.
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Attr {
|
||||
/// Identificador em hexadecimal, como o CrystalDiskInfo mostra ("05", "C5").
|
||||
/// Two-digit hex attribute ID as displayed by CrystalDiskInfo (`"05"`, `"C5"`).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Valores normalizados (ATA): atual, pior já visto e limite de falha.
|
||||
/// Normalized ATA values: current, worst, and failure threshold.
|
||||
pub value: Option<i64>,
|
||||
pub worst: Option<i64>,
|
||||
pub thresh: Option<i64>,
|
||||
pub raw: String,
|
||||
/// "ok", "warning" ou "failing".
|
||||
/// Attribute status: `"ok"`, `"warning"`, or `"failing"`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Health {
|
||||
|
||||
const IMAGE_ADVICE: &str = "Crie a imagem do disco primeiro (botão \"Criar imagem\" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.";
|
||||
|
||||
/// Avalia o dispositivo `path`. Para arquivos comuns (imagens) devolve "sem avaliação".
|
||||
/// Evaluates disk health for `path`. Regular image files return an unassessed report.
|
||||
pub fn check(path: &str) -> Health {
|
||||
#[cfg(unix)]
|
||||
let is_dev = {
|
||||
@@ -88,8 +88,8 @@ pub fn check(path: &str) -> Health {
|
||||
h
|
||||
}
|
||||
|
||||
/// O SMART é do disco, não da partição: `/dev/sda1` vira `/dev/sda`,
|
||||
/// `/dev/nvme0n1p2` vira `/dev/nvme0n1` e `/dev/disk2s1` vira `/dev/disk2`.
|
||||
/// SMART belongs to the physical disk rather than a partition: `/dev/sda1` -> `/dev/sda`,
|
||||
/// `/dev/nvme0n1p2` -> `/dev/nvme0n1`, and `/dev/disk2s1` -> `/dev/disk2`.
|
||||
pub fn parent_disk(path: &str) -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -117,7 +117,7 @@ pub fn parent_disk(path: &str) -> String {
|
||||
}
|
||||
|
||||
fn smartctl_name(path: &str) -> String {
|
||||
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
|
||||
// Windows: \\.\PhysicalDriveN -> /dev/sdX (smartctl device naming convention)
|
||||
if let Some(n) = path
|
||||
.strip_prefix(r"\\.\PhysicalDrive")
|
||||
.and_then(|n| n.parse::<u8>().ok())
|
||||
@@ -139,9 +139,9 @@ fn run_smartctl(path: &str) -> Option<Value> {
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
// smartctl usa bits do código de saída para avisos; o JSON vale mesmo assim
|
||||
// smartctl encodes warnings in exit status bits; the JSON output is still valid
|
||||
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
// sem permissão ou dispositivo não suportado
|
||||
// insufficient permissions or unsupported device
|
||||
if v.pointer("/smartctl/messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error"))
|
||||
@@ -163,14 +163,14 @@ fn attr(v: &Value, id: u64) -> Option<i64> {
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// Interpreta a saída JSON do `smartctl` (testável sem disco).
|
||||
/// Parses `smartctl -j` JSON output (unit-testable without hardware).
|
||||
pub fn parse_smartctl(v: &Value) -> Health {
|
||||
let mut h = Health {
|
||||
available: true,
|
||||
status: "ok".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bad = 0u32; // 2 = falhando, 1 = atenção
|
||||
let mut bad = 0u32; // 2 = failing, 1 = warning
|
||||
let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
|
||||
bad = bad.max(sev);
|
||||
h.details.push((k.to_string(), val));
|
||||
@@ -204,7 +204,7 @@ pub fn parse_smartctl(v: &Value) -> Health {
|
||||
h.details
|
||||
.push(("Autoavaliação SMART".into(), "aprovado".into()));
|
||||
}
|
||||
// ATA (HD e SSD SATA)
|
||||
// ATA (SATA HDDs and SSDs)
|
||||
if let Some(n) = attr(v, 5) {
|
||||
if n > 0 {
|
||||
note(
|
||||
@@ -320,7 +320,7 @@ fn n(v: &Value, p: &str) -> Option<i64> {
|
||||
v.pointer(p).and_then(|x| x.as_i64())
|
||||
}
|
||||
|
||||
/// Ficha do dispositivo e métricas gerais (temperatura, horas, total lido/gravado, saúde %).
|
||||
/// Extracts device identity and summary metrics (temperature, power-on hours, host R/W, health %).
|
||||
fn fill_info(v: &Value, h: &mut Health) {
|
||||
let mut put = |k: &str, val: Option<String>| {
|
||||
if let Some(val) = val {
|
||||
@@ -395,7 +395,7 @@ fn fill_info(v: &Value, h: &mut Health) {
|
||||
h.power_on_hours = n(v, "/power_on_time/hours");
|
||||
h.power_cycles = n(v, "/power_cycle_count");
|
||||
if let Some(l) = v.get("nvme_smart_health_information_log") {
|
||||
// a unidade NVMe vale 1000 setores de 512 bytes
|
||||
// 1 NVMe data unit = 1,000 x 512-byte sectors
|
||||
h.host_reads = l["data_units_read"].as_u64().map(|u| u * 512_000);
|
||||
h.host_writes = l["data_units_written"].as_u64().map(|u| u * 512_000);
|
||||
h.health_pct = l["percentage_used"]
|
||||
@@ -406,7 +406,7 @@ fn fill_info(v: &Value, h: &mut Health) {
|
||||
let lba = n(v, "/logical_block_size").unwrap_or(512) as u64;
|
||||
h.host_reads = attr(v, 242).map(|x| x as u64 * lba);
|
||||
h.host_writes = attr(v, 241).map(|x| x as u64 * lba);
|
||||
// SSD SATA: vida útil restante no valor normalizado de um destes atributos
|
||||
// SATA SSD: remaining life percentage stored in the normalized value of one of these attributes
|
||||
// (231 SSD Life Left, 233 Media Wearout, 177 Wear Leveling, 202 Percent Lifetime Remain)
|
||||
h.health_pct = [231u64, 233, 177, 202, 169]
|
||||
.iter()
|
||||
@@ -416,7 +416,7 @@ fn fill_info(v: &Value, h: &mut Health) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capacidade como o fabricante anuncia (base 1000): "500,1 GB".
|
||||
/// Formats drive capacity in decimal manufacturer units (base 1000, e.g. `"500,1 GB"`).
|
||||
fn decimal_size(b: u64) -> String {
|
||||
let units = ["B", "kB", "MB", "GB", "TB", "PB"];
|
||||
let mut x = b as f64;
|
||||
@@ -437,7 +437,7 @@ fn attr_field(v: &Value, id: u64, f: &str) -> Option<i64> {
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// Atributos cujo valor bruto acima de zero já é motivo de atenção.
|
||||
/// Critical ATA attributes where any non-zero raw count warrants a warning.
|
||||
const RAW_WARN: [u64; 5] = [5, 196, 197, 198, 187];
|
||||
|
||||
fn fill_attributes(v: &Value, h: &mut Health) {
|
||||
@@ -484,7 +484,7 @@ fn fill_attributes(v: &Value, h: &mut Health) {
|
||||
}
|
||||
}
|
||||
if let Some(l) = v.get("nvme_smart_health_information_log") {
|
||||
// mesma ordem e nomes que o CrystalDiskInfo usa para NVMe
|
||||
// same order and labels used by CrystalDiskInfo for NVMe health logs
|
||||
let rows: [(u8, &str, &str); 15] = [
|
||||
(1, "Critical Warning", "critical_warning"),
|
||||
(2, "Composite Temperature", "temperature"),
|
||||
@@ -529,7 +529,7 @@ fn fill_attributes(v: &Value, h: &mut Health) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
|
||||
/// Linux kernel sysfs I/O error counters: supplement SMART warnings.
|
||||
fn kernel_errors(path: &str, h: &mut Health) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Rotas HTTP de mídia (`/file` com Range e `/thumb`) compartilhadas pelo modo `guara ui`.
|
||||
//! Media HTTP routes (`/file` with Range support and `/thumb`) shared by `guara ui` mode.
|
||||
|
||||
use crate::service::Engine;
|
||||
use tiny_http::{Header, Method, Request, Response, StatusCode};
|
||||
@@ -7,15 +7,15 @@ pub fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// O cabeçalho Host bate com um dos endereços esperados (defesa contra DNS rebinding)?
|
||||
/// Checks whether the HTTP `Host` header matches an allowed local address (DNS rebinding defense).
|
||||
pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
|
||||
req.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()))
|
||||
}
|
||||
|
||||
/// Responde `/file/<job>/<id>` (com Range) e `/thumb/<job>/<id>?s=NNN`.
|
||||
/// Devolve a requisição quando ela não é uma rota de mídia.
|
||||
/// Serves `/file/<job>/<id>` (with Range support) and `/thumb/<job>/<id>?s=NNN`.
|
||||
/// Returns `Err(req)` when the request is not a media route.
|
||||
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Imagem forense de um disco: leitura tolerante a setores ruins, com mapa de
|
||||
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
|
||||
//! Forensic disk imaging: fault-tolerant reading across bad sectors with an error
|
||||
//! map and integrity hashes so recovery always runs against a copy.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::hashing::MultiHasher;
|
||||
@@ -16,7 +16,7 @@ pub struct ImageReport {
|
||||
pub md5: String,
|
||||
#[serde(default)]
|
||||
pub sha1: String,
|
||||
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
|
||||
/// `(offset, length)` of unreadable byte ranges that were zero-filled in the output image.
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const SECTOR: usize = 512;
|
||||
pub enum ImageFormat {
|
||||
#[default]
|
||||
Raw,
|
||||
/// Expert Witness (EnCase 6): `.E01`, dividido em segmentos.
|
||||
/// Expert Witness Format (EnCase 6): segmented `.E01`.
|
||||
E01 {
|
||||
#[serde(default)]
|
||||
case_number: String,
|
||||
@@ -41,14 +41,14 @@ pub enum ImageFormat {
|
||||
examiner: String,
|
||||
#[serde(default)]
|
||||
notes: String,
|
||||
/// Tamanho máximo de cada segmento em MiB (0 = um arquivo só).
|
||||
/// Maximum size of each segment in MiB (0 = single segment).
|
||||
#[serde(default = "default_segment_mib")]
|
||||
segment_mib: u64,
|
||||
/// 0 = sem compressão, 1 a 9 = deflate.
|
||||
/// 0 = uncompressed, 1..=9 = deflate compression level.
|
||||
#[serde(default = "default_compression")]
|
||||
compression: u32,
|
||||
},
|
||||
/// AFF4 (padrão v1.0, blocos snappy).
|
||||
/// AFF4 (v1.0 standard, snappy-compressed chunks).
|
||||
Aff4 {
|
||||
#[serde(default)]
|
||||
case_number: String,
|
||||
@@ -119,7 +119,7 @@ pub fn create_image_as(
|
||||
compression: *compression,
|
||||
..Default::default()
|
||||
};
|
||||
// O destino pode vir com ou sem a extensão .E01.
|
||||
// Destination path may be passed with or without the `.E01` extension.
|
||||
let base = match dest.extension().and_then(|e| e.to_str()) {
|
||||
Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""),
|
||||
_ => dest.to_path_buf(),
|
||||
@@ -160,7 +160,7 @@ pub fn create_image(
|
||||
match read_full(src, pos, &mut buf[..want]) {
|
||||
Ok(()) => {}
|
||||
Err(_) => {
|
||||
// Refaz setor a setor para isolar os trechos ruins.
|
||||
// Retry sector by sector to isolate the exact bad sectors.
|
||||
let mut o = 0;
|
||||
while o < want {
|
||||
let n = SECTOR.min(want - o);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Decodificador de entropia de JPEG sequencial (Huffman, 8 bits), sem IDCT.
|
||||
//! Baseline sequential JPEG Huffman entropy decoder (8-bit), plus lightweight IDCT.
|
||||
//!
|
||||
//! Serve para validar se os bytes de um JPEG são realmente contínuos e para reconstruir arquivos
|
||||
//! fragmentados: o fluxo é decodificado MCU a MCU, guardando o DC de luminância (uma miniatura
|
||||
//! 1/8 da imagem) e pontos de retomada. Depois de uma quebra os bytes viram lixo, e a
|
||||
//! descontinuidade do DC (ou um erro de código) denuncia onde.
|
||||
//! Validates whether a JPEG stream is contiguous and assists in reassembling fragmented
|
||||
//! JPEGs: decodes MCU by MCU, tracking luminance DC values (a 1/8th thumbnail) and
|
||||
//! decoder checkpoints. After a fragmentation break, foreign clusters cause either
|
||||
//! a Huffman/marker error or a sharp luminance discontinuity.
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Huff {
|
||||
// decodificação canônica
|
||||
// canonical Huffman decoding tables
|
||||
mincode: [i32; 17],
|
||||
maxcode: [i32; 18],
|
||||
valptr: [i32; 17],
|
||||
@@ -54,9 +54,9 @@ pub struct Header {
|
||||
pub restart: u32,
|
||||
pub mcus_x: u32,
|
||||
pub mcus_y: u32,
|
||||
/// Onde começam os dados entropy-coded (depois do SOS).
|
||||
/// Byte offset where entropy-coded scan data begins (immediately after SOS).
|
||||
pub scan_start: usize,
|
||||
/// Tabelas de quantização (ordem zigzag).
|
||||
/// Quantization tables (in zigzag order).
|
||||
pub qt: [[u16; 64]; 4],
|
||||
dc: [Option<Huff>; 4],
|
||||
ac: [Option<Huff>; 4],
|
||||
@@ -68,7 +68,7 @@ impl Header {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lê os segmentos até o SOS. `None` se não for JPEG sequencial suportado.
|
||||
/// Parses JPEG marker segments up to SOS. Returns `None` if not a supported baseline JPEG.
|
||||
pub fn parse_header(d: &[u8]) -> Option<Header> {
|
||||
if d.len() < 4 || d[0] != 0xFF || d[1] != 0xD8 {
|
||||
return None;
|
||||
@@ -119,7 +119,7 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
|
||||
.collect();
|
||||
sof = Some((w, h, comps));
|
||||
}
|
||||
0xC2 | 0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return None, // progressivo, aritmético etc.
|
||||
0xC2 | 0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return None, // progressive, arithmetic coding, etc.
|
||||
0xC4 => {
|
||||
let mut q = 0;
|
||||
while q + 17 <= seg.len() {
|
||||
@@ -167,7 +167,7 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
|
||||
let (w, h, comps) = sof?;
|
||||
let ns = *seg.first()? as usize;
|
||||
if ns != comps.len() || seg.len() < 1 + ns * 2 + 3 {
|
||||
return None; // varredura não intercalada
|
||||
return None; // non-interleaved scan
|
||||
}
|
||||
let mut cs = vec![];
|
||||
for i in 0..ns {
|
||||
@@ -212,20 +212,20 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Stop {
|
||||
/// Todos os MCUs foram decodificados.
|
||||
/// All MCUs in the frame were decoded.
|
||||
Done,
|
||||
/// Marcador inesperado (inclusive EOI) na posição dada.
|
||||
/// Unexpected marker (including early EOI) at the given byte offset.
|
||||
Marker(u8, usize),
|
||||
/// Código inválido ou coeficiente fora do limite.
|
||||
/// Invalid Huffman code or DCT coefficient index out of bounds.
|
||||
Error(usize),
|
||||
/// Faltam bytes.
|
||||
/// Truncated bitstream (ran out of input bytes).
|
||||
Truncated,
|
||||
}
|
||||
|
||||
/// Estado completo do decodificador num limite de byte, para retomar com outros dados.
|
||||
/// Complete entropy decoder snapshot at a byte boundary, allowing resumption on candidate fragments.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct State {
|
||||
/// Próximo byte a buscar no fluxo.
|
||||
/// Next byte offset to read from the entropy stream.
|
||||
pub pos: usize,
|
||||
bitbuf: u32,
|
||||
bitcnt: u32,
|
||||
@@ -236,7 +236,7 @@ pub struct State {
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Último DC de luminância (preditor) no ponto de retomada.
|
||||
/// Last luminance DC predictor value at this checkpoint.
|
||||
pub fn luma_pred(&self) -> i32 {
|
||||
self.pred[0]
|
||||
}
|
||||
@@ -254,9 +254,9 @@ impl State {
|
||||
}
|
||||
|
||||
pub struct Decoded {
|
||||
/// DC de luminância (absoluto) por MCU decodificado.
|
||||
/// Absolute luminance DC coefficient for each decoded MCU.
|
||||
pub dc: Vec<i32>,
|
||||
/// Estados guardados em intervalos (para retomada): (MCU, estado ao fim dele).
|
||||
/// Periodic decoder checkpoints for resumption: `(mcu_index, state_after_mcu)`.
|
||||
pub snaps: Vec<State>,
|
||||
pub stop: Stop,
|
||||
pub state: State,
|
||||
@@ -341,13 +341,13 @@ fn extend(v: u32, t: u32) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodifica até `max_mcus` MCUs a partir de `state`, sobre `data` (fluxo bruto com FF00).
|
||||
/// `snap_every` > 0 guarda estados a cada tantos bytes consumidos.
|
||||
/// Decodes up to `max_mcus` MCUs starting from `state` over `data` (raw byte-stuffed stream).
|
||||
/// When `snap_every > 0`, records a resumption snapshot every `snap_every` consumed bytes.
|
||||
pub fn decode(h: &Header, data: &[u8], state: State, max_mcus: u32, snap_every: usize) -> Decoded {
|
||||
decode_hook(h, data, state, max_mcus, snap_every, None)
|
||||
}
|
||||
|
||||
/// Como `decode`, mas entrega os coeficientes (zigzag) dos blocos de luminância de cada MCU.
|
||||
/// Like `decode`, but also invokes `on_luma` with the zigzag DCT coefficients of each MCU's luma blocks.
|
||||
pub fn decode_hook(
|
||||
h: &Header,
|
||||
data: &[u8],
|
||||
@@ -369,9 +369,9 @@ pub fn decode_hook(
|
||||
let mut stop = Stop::Done;
|
||||
let mut decoded = 0u32;
|
||||
'mcu: while b.st.mcu < total && decoded < max_mcus {
|
||||
// Marcador de reinício entre MCUs.
|
||||
// Expected restart marker between MCUs.
|
||||
if h.restart > 0 && b.st.mcus_since_rst == h.restart {
|
||||
// descarta bits de preenchimento e espera RSTn
|
||||
// discard byte-align padding bits and verify RSTn marker
|
||||
b.st.bitbuf = 0;
|
||||
b.st.bitcnt = 0;
|
||||
let p = b.st.pos;
|
||||
@@ -486,8 +486,8 @@ fn fail(b: &Bits, data: &[u8]) -> Stop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Primeiro MCU suspeito: janela de `win` MCUs cuja diferença média de DC é anormalmente alta.
|
||||
/// Devolve o índice do MCU onde a janela começa.
|
||||
/// Detects the first suspicious MCU where a sliding window of `win` MCUs exhibits an abnormally
|
||||
/// high mean DC jump. Returns the starting MCU index of that window.
|
||||
pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
|
||||
let mx = h.mcus_x as usize;
|
||||
let d: Vec<f64> = (0..dc.len())
|
||||
@@ -505,7 +505,7 @@ pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
|
||||
if d.len() < win * 2 {
|
||||
return None;
|
||||
}
|
||||
// Referência: mediana das diferenças vistas até aqui (histograma, robusta a bordas).
|
||||
// Baseline reference: running median of row-to-row DC differences (robust to sharp edges).
|
||||
let mut hist = vec![0u32; 2048];
|
||||
let mut seen = 0u32;
|
||||
let mut sum: f64 = d[..win].iter().sum();
|
||||
@@ -529,7 +529,7 @@ pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
|
||||
return Some(i + 1 - win);
|
||||
}
|
||||
}
|
||||
// Só entra na referência o que ainda parece normal.
|
||||
// Only update the baseline histogram with rows that still look normal.
|
||||
if i < win * 3 || d[i] < 300.0 {
|
||||
hist[(d[i] as usize).min(2047)] += 1;
|
||||
seen += 1;
|
||||
@@ -566,7 +566,7 @@ fn cos_table() -> &'static [[f32; 8]; 8] {
|
||||
})
|
||||
}
|
||||
|
||||
/// IDCT de um bloco (coeficientes em zigzag, quantizados) para 8x8 pixels de luminância.
|
||||
/// Dequantizes and runs an 8x8 IDCT on a zigzag coefficient block into 8x8 luma pixels.
|
||||
pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
|
||||
let mut f = [[0f32; 8]; 8];
|
||||
for z in 0..64 {
|
||||
@@ -574,7 +574,7 @@ pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
|
||||
f[n / 8][n % 8] = (coef[z] * qt[z] as i32) as f32;
|
||||
}
|
||||
let t = cos_table();
|
||||
// linhas
|
||||
// rows
|
||||
let mut tmp = [[0f32; 8]; 8];
|
||||
for v in 0..8 {
|
||||
for x in 0..8 {
|
||||
@@ -598,7 +598,7 @@ pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
|
||||
out
|
||||
}
|
||||
|
||||
/// Luminância de um MCU (blocos `h` x `v` da primeira componente) como imagem (8h x 8v).
|
||||
/// Renders the luma blocks (`h` x `v`) of a single MCU into an `8h x 8v` pixel tile.
|
||||
pub fn mcu_luma(h: &Header, blocks: &[[i32; 64]]) -> Vec<u8> {
|
||||
let c = &h.comps[0];
|
||||
let (bw, bh) = (c.h as usize, c.v as usize);
|
||||
@@ -615,7 +615,7 @@ pub fn mcu_luma(h: &Header, blocks: &[[i32; 64]]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Plano de luminância do que já foi decodificado.
|
||||
/// Luma pixel plane of the decoded image prefix.
|
||||
pub struct Plane {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
@@ -648,7 +648,7 @@ impl Plane {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodifica e preenche o plano com as luminâncias.
|
||||
/// Decodes `data` from `state` and renders the luma plane.
|
||||
pub fn decode_plane(
|
||||
h: &Header,
|
||||
data: &[u8],
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
clippy::large_enum_variant,
|
||||
clippy::needless_range_loop
|
||||
)]
|
||||
//! Guará: núcleo de recuperação de arquivos.
|
||||
//! Guará: forensic file recovery core.
|
||||
//!
|
||||
//! Princípio central: a origem é aberta **somente para leitura**; nada neste
|
||||
//! crate escreve no dispositivo que está sendo recuperado.
|
||||
//! Core principle: the source is opened **strictly read-only**; nothing in this
|
||||
//! crate ever writes to the device being recovered.
|
||||
|
||||
pub mod aff4;
|
||||
pub mod assemble;
|
||||
@@ -50,6 +50,7 @@ pub mod source;
|
||||
pub mod sqlite;
|
||||
pub mod thumb;
|
||||
pub mod types;
|
||||
pub mod update;
|
||||
pub mod vdisk;
|
||||
pub mod volume;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! LVM2: lê o rótulo do volume físico (PV) e o texto de metadados, e remonta os volumes
|
||||
//! lógicos (lineares, em faixas, espelhados e RAID 0/1/5/6/10 do LVM).
|
||||
//! LVM2: parses Physical Volume (PV) labels and ASCII metadata rings to reassemble
|
||||
//! Logical Volumes (linear, striped, mirrored, and LVM RAID 0/1/5/6/10).
|
||||
|
||||
use crate::raid::{Layout, Level, Member, RaidParams, RaidSource};
|
||||
use crate::source::{read_vec, Source};
|
||||
@@ -11,7 +11,7 @@ const MDA_MAGIC: &[u8; 16] = b" LVM2 x[5A%r0N*>";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PvInfo {
|
||||
/// UUID de 32 caracteres, sem hífens.
|
||||
/// 32-character LVM UUID normalized without hyphens.
|
||||
pub uuid: String,
|
||||
pub size: u64,
|
||||
pub seqno: u64,
|
||||
@@ -19,7 +19,7 @@ pub struct PvInfo {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Lê rótulo + metadados de um PV.
|
||||
/// Reads the PV label and metadata text from a physical volume candidate.
|
||||
pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
|
||||
let head = read_vec(src, 0, 2048).ok()?;
|
||||
if head.len() < 2048 {
|
||||
@@ -34,7 +34,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
|
||||
let pv = lab.get(off..)?;
|
||||
let uuid = String::from_utf8_lossy(pv.get(..32)?).to_string();
|
||||
let size = u64::from_le_bytes(pv.get(32..40)?.try_into().ok()?);
|
||||
// Lista de áreas de dados, depois de áreas de metadados; cada lista termina em (0,0).
|
||||
// Null-terminated list of data area descriptors followed by metadata area descriptors.
|
||||
let mut o = 40;
|
||||
let rd = |o: usize| -> Option<(u64, u64)> {
|
||||
Some((
|
||||
@@ -78,7 +78,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
|
||||
text = t;
|
||||
}
|
||||
} else {
|
||||
// O texto dá a volta no anel.
|
||||
// Metadata text wraps around the circular ring buffer.
|
||||
let first = mda_size - loc_off;
|
||||
if let (Ok(a), Ok(b)) = (
|
||||
read_vec(src, mda_off + loc_off, first as usize),
|
||||
@@ -113,7 +113,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
|
||||
best
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ texto
|
||||
// ------------------------------------------------------------------ text parser
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Val {
|
||||
@@ -125,7 +125,7 @@ pub enum Val {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Section {
|
||||
pub values: BTreeMap<String, Val>,
|
||||
/// Subseções na ordem do arquivo.
|
||||
/// Child sections in declaration order.
|
||||
pub sections: Vec<(String, Section)>,
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ pub fn parse(text: &str) -> Option<Section> {
|
||||
.section(true)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- montagem
|
||||
// ------------------------------------------------------------- assembly
|
||||
|
||||
fn dashed(u: &str) -> String {
|
||||
if u.len() != 32 {
|
||||
@@ -297,7 +297,7 @@ fn dashed(u: &str) -> String {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Map {
|
||||
/// Lê de `src` a partir de `base` (linear).
|
||||
/// Linear segment: reads from `src` starting at `base`.
|
||||
Linear {
|
||||
src: Option<Arc<dyn Source>>,
|
||||
base: u64,
|
||||
@@ -413,7 +413,7 @@ fn mk_stripes(list: &[Val]) -> Vec<(String, u64)> {
|
||||
|
||||
struct Ctx<'a> {
|
||||
ext: u64,
|
||||
pvs: BTreeMap<String, (Option<Arc<dyn Source>>, u64)>, // nome -> (origem, pe_start em bytes)
|
||||
pvs: BTreeMap<String, (Option<Arc<dyn Source>>, u64)>, // pv_name -> (source, pe_start_in_bytes)
|
||||
lvs: &'a Section,
|
||||
cache: BTreeMap<String, Option<Arc<dyn Source>>>,
|
||||
warnings: Vec<String>,
|
||||
@@ -487,7 +487,7 @@ impl Ctx<'_> {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Pares (rimage, rmeta) ou lista só de imagens.
|
||||
// Pairs of `(rimage, rmeta)` or a list of image LVs.
|
||||
let data_lvs: Vec<&String> =
|
||||
imgs.iter().filter(|n| n.contains("_rimage_")).collect();
|
||||
let mut members: Vec<Option<Member>> = vec![];
|
||||
@@ -544,7 +544,7 @@ impl Ctx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Monta os volumes lógicos de cada grupo de volumes presente nos PVs fornecidos.
|
||||
/// Reassembles all user Logical Volumes across every Volume Group found in `pvs`.
|
||||
pub fn assemble_vgs(pvs: Vec<(Arc<dyn Source>, PvInfo)>) -> Vec<VgResult> {
|
||||
let mut by_vg: BTreeMap<String, Vec<(Arc<dyn Source>, PvInfo)>> = BTreeMap::new();
|
||||
for (s, p) in pvs {
|
||||
@@ -590,7 +590,7 @@ pub fn assemble_vgs(pvs: Vec<(Arc<dyn Source>, PvInfo)>) -> Vec<VgResult> {
|
||||
};
|
||||
let mut lvs = vec![];
|
||||
for (name, def) in &lvsec.sections {
|
||||
// Imagens internas do RAID e metadados não são volumes de usuário.
|
||||
// Internal RAID sub-LVs (`_rimage_*`, `_rmeta_*`) are not user-visible volumes.
|
||||
let hidden = def
|
||||
.list("status")
|
||||
.map(|l| !l.iter().any(|v| matches!(v, Val::Str(s) if s == "VISIBLE")))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Extração de metadados de arquivos recuperados: EXIF (câmera, datas, GPS),
|
||||
//! informações de PDF, propriedades de documentos Office/OpenDocument e
|
||||
//! dados de vídeo MP4/MOV. Tudo é leitura posicional, sem gravar em disco.
|
||||
//! Metadata extraction for recovered files: EXIF (camera, timestamps, GPS),
|
||||
//! PDF info dictionary, Office/OpenDocument properties, and MP4/MOV
|
||||
//! container headers. Performed entirely via positional reads without writing to disk.
|
||||
|
||||
use crate::recover::read_found_at;
|
||||
use crate::source::Source;
|
||||
@@ -12,16 +12,16 @@ use std::io::Read;
|
||||
pub struct Meta {
|
||||
pub kind: String,
|
||||
pub fields: Vec<(String, String)>,
|
||||
/// (latitude, longitude) em graus decimais.
|
||||
/// `(latitude, longitude)` in decimal degrees.
|
||||
pub gps: Option<(f64, f64)>,
|
||||
/// Data em que a foto foi tirada (EXIF), s desde 1970 (hora da câmera lida como UTC).
|
||||
/// EXIF capture timestamp in seconds since 1970 (camera wall-clock interpreted as UTC).
|
||||
#[serde(default)]
|
||||
pub taken: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub camera: Option<String>,
|
||||
#[serde(default)]
|
||||
pub px: Option<(u32, u32)>,
|
||||
/// Orientação EXIF (1..8).
|
||||
/// EXIF orientation tag (`1..=8`).
|
||||
#[serde(default)]
|
||||
pub orientation: u16,
|
||||
}
|
||||
@@ -142,7 +142,7 @@ fn jpeg(fr: &Fr, m: &mut Meta) {
|
||||
jpeg_bytes(&d, m);
|
||||
}
|
||||
|
||||
/// Lê os segmentos de cabeçalho de um JPEG (já em memória): dimensões e EXIF.
|
||||
/// Parses JPEG header segments (in memory) for dimensions and EXIF tags.
|
||||
pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
|
||||
let mut p = 2usize;
|
||||
while p + 4 <= d.len() && d[p] == 0xFF {
|
||||
@@ -168,7 +168,7 @@ pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Miniatura JPEG embutida no EXIF (IFD1), útil quando a imagem principal está danificada.
|
||||
/// Extracts the embedded EXIF thumbnail (IFD1), useful when the main image is corrupted.
|
||||
pub fn exif_thumbnail(d: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut p = 2usize;
|
||||
while p + 4 <= d.len() && d[p] == 0xFF {
|
||||
@@ -222,7 +222,7 @@ struct Entry {
|
||||
tag: u16,
|
||||
typ: u16,
|
||||
count: u32,
|
||||
pos: usize, // onde estão os dados do valor dentro do TIFF
|
||||
pos: usize, // offset of the tag value payload within the TIFF header
|
||||
}
|
||||
|
||||
impl Tiff<'_> {
|
||||
@@ -423,7 +423,7 @@ fn exif(d: &[u8], m: &mut Meta) {
|
||||
}
|
||||
}
|
||||
|
||||
/// "2023:07:14 18:22:05" -> segundos desde 1970 (hora da câmera lida como UTC)
|
||||
/// `"2023:07:14 18:22:05"` -> seconds since 1970 (camera local time treated as UTC).
|
||||
fn exif_epoch(s: &str) -> Option<i64> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() < 19 || b[4] != b':' || b[7] != b':' {
|
||||
@@ -444,7 +444,7 @@ fn exif_epoch(s: &str) -> Option<i64> {
|
||||
Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se)
|
||||
}
|
||||
|
||||
/// "2023:07:14 18:22:05" -> "2023-07-14T18:22:05" (a interface formata por idioma)
|
||||
/// `"2023:07:14 18:22:05"` -> `"2023-07-14T18:22:05"` (formatted per locale by the UI).
|
||||
fn exif_date(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
if b.len() >= 19 && b[4] == b':' && b[7] == b':' {
|
||||
@@ -526,7 +526,7 @@ fn pdf_string(d: &[u8], key: &str) -> Option<String> {
|
||||
let k = key.as_bytes();
|
||||
for at in find_all(d, k) {
|
||||
let mut p = at + k.len();
|
||||
// evita /Creator casar com /CreatorXyz
|
||||
// prevent `/Creator` from prefix-matching `/CreatorXyz`
|
||||
if d.get(p).map(|c| c.is_ascii_alphanumeric()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
@@ -544,7 +544,7 @@ fn pdf_string(d: &[u8], key: &str) -> Option<String> {
|
||||
b'\\' if i + 1 < d.len() => {
|
||||
i += 1;
|
||||
if d[i].is_ascii_digit() && d[i] < b'8' {
|
||||
// escape octal \ddd (1 a 3 dígitos)
|
||||
// octal escape `\ddd` (1 to 3 digits)
|
||||
let mut v = 0u32;
|
||||
let mut n = 0;
|
||||
while n < 3 && i < d.len() && d[i] >= b'0' && d[i] < b'8' {
|
||||
@@ -609,11 +609,11 @@ fn pdf_text(b: &[u8]) -> String {
|
||||
.collect();
|
||||
return String::from_utf16_lossy(&u);
|
||||
}
|
||||
// PDFDocEncoding ~ Latin-1 na prática
|
||||
// PDFDocEncoding is effectively Latin-1 for printable characters
|
||||
b.iter().map(|&c| c as char).collect()
|
||||
}
|
||||
|
||||
/// "D:20230714182205-03'00'" -> "2023-07-14T18:22:05" (a interface formata por idioma)
|
||||
/// `"D:20230714182205-03'00'"` -> `"2023-07-14T18:22:05"` (formatted per locale by the UI).
|
||||
fn pdf_date(s: &str) -> String {
|
||||
let t = s.trim_start_matches("D:");
|
||||
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
@@ -704,7 +704,7 @@ fn zip_read(fr: &Fr, e: &ZipEnt) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Texto do primeiro elemento `<tag ...>texto</tag>`.
|
||||
/// Extracts the inner text of the first `<tag ...>text</tag>` element.
|
||||
fn xml_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{tag}");
|
||||
let mut from = 0;
|
||||
@@ -733,7 +733,7 @@ fn unescape(s: &str) -> String {
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// Datas ISO 8601 ficam como estão; a interface as formata no idioma escolhido.
|
||||
/// Preserves ISO 8601 date prefixes (`YYYY-MM-DD[THH:MM:SS]`) for locale-aware UI formatting.
|
||||
fn iso_date(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
|
||||
@@ -916,7 +916,7 @@ fn moov(d: &[u8], m: &mut Meta) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Instante em ISO 8601 UTC ("2023-07-14T18:22:05Z"), para a interface formatar.
|
||||
/// Formats a Unix timestamp as ISO 8601 UTC (`"2023-07-14T18:22:05Z"`).
|
||||
pub fn iso_unix(t: i64) -> String {
|
||||
let days = t.div_euclid(86400);
|
||||
let rem = t.rem_euclid(86400);
|
||||
@@ -941,13 +941,13 @@ pub fn fmt_unix(t: i64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// (ano, mês, dia) de um instante em segundos desde 1970.
|
||||
/// Returns `(year, month, day)` for a Unix timestamp in seconds.
|
||||
pub fn ymd(t: i64) -> (i64, u32, u32) {
|
||||
let (y, m, d) = civil_from_days(t.div_euclid(86400));
|
||||
(y, m as u32, d as u32)
|
||||
}
|
||||
|
||||
/// Inverso de `days_from_civil` (Howard Hinnant).
|
||||
/// Inverse of `days_from_civil` (Howard Hinnant civil calendar algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Reconstrução de vídeo MP4/MOV sem o átomo `moov` (gravação interrompida de câmera ou drone,
|
||||
//! arquivo cortado). Os quadros ficam no `mdat` como unidades NAL com prefixo de tamanho; o
|
||||
//! índice (`moov`) é refeito a partir delas: tamanhos de amostra, quadros-chave, tempo e, para
|
||||
//! H.264, a ordem de apresentação (POC) quando há quadros B. O áudio não é reconstruído.
|
||||
//! Reconstructs MP4/MOV video files missing their `moov` atom (interrupted camera/drone recordings
|
||||
//! or truncated files). Video frames sit in `mdat` as length-prefixed NAL units; we rebuild the
|
||||
//! `moov` index from them: sample sizes, keyframes, timing, and for H.264, presentation order
|
||||
//! (POC) when B-frames are present. Audio tracks are not reconstructed.
|
||||
|
||||
use crate::source::{read_vec, Source};
|
||||
|
||||
@@ -58,7 +58,7 @@ impl<'a> Bits<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove os bytes de "emulation prevention" (00 00 03 -> 00 00).
|
||||
/// Strips H.264/HEVC emulation prevention bytes (`00 00 03` -> `00 00`).
|
||||
fn unescape(nal: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(nal.len());
|
||||
let mut z = 0;
|
||||
@@ -86,7 +86,7 @@ pub struct Sps264 {
|
||||
pub poc_type: u32,
|
||||
pub log2_max_poc_lsb: u32,
|
||||
pub frame_mbs_only: bool,
|
||||
/// (unidades por tique, escala de tempo)
|
||||
/// `(num_units_in_tick, time_scale)` from VUI timing info.
|
||||
pub timing: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ pub fn parse_sps264(nal: &[u8]) -> Option<Sps264> {
|
||||
};
|
||||
s.width = (w * 16).checked_sub((cl + cr) * sub_w)?;
|
||||
s.height = ((2 - s.frame_mbs_only as u32) * h * 16).checked_sub((ct + cb) * sub_h)?;
|
||||
// VUI até o timing_info.
|
||||
// Parse VUI parameters up to `timing_info_present_flag`.
|
||||
if b.bit()? == 1 {
|
||||
if b.bit()? == 1 && b.u(8)? == 255 {
|
||||
b.skip(32);
|
||||
@@ -202,7 +202,7 @@ pub fn parse_sps264(nal: &[u8]) -> Option<Sps264> {
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// (primeiro_mb == 0, tipo de fatia, pps_id, frame_num, idr, poc_lsb) do cabeçalho de fatia H.264.
|
||||
/// Parses `(first_mb == 0, slice_type, pps_id, frame_num, is_idr, poc_lsb)` from an H.264 slice header.
|
||||
struct SliceInfo {
|
||||
frame_num: u32,
|
||||
first_mb0: bool,
|
||||
@@ -240,7 +240,7 @@ fn slice264(nal: &[u8], sps: &Sps264, pic_order_present: bool) -> Option<SliceIn
|
||||
})
|
||||
}
|
||||
|
||||
/// bottom_field_pic_order_in_frame_present_flag do PPS.
|
||||
/// Extracts `bottom_field_pic_order_in_frame_present_flag` from an H.264 PPS.
|
||||
fn pps_pic_order_present(nal: &[u8]) -> bool {
|
||||
let rbsp = unescape(&nal[1..]);
|
||||
let mut b = Bits::new(&rbsp);
|
||||
@@ -278,7 +278,7 @@ pub fn parse_sps_hevc(nal: &[u8]) -> Option<SpsHevc> {
|
||||
*v = b.u(8)? as u8;
|
||||
let _ = i;
|
||||
}
|
||||
// general: 2+1+5 bits, 32 compat, 48 flags, 8 level => lemos 12 bytes (1+4+6+1)
|
||||
// general profile/tier/level: 2+1+5 bits, 32 compat, 48 constraint flags, 8 level => 12 bytes (1+4+6+1)
|
||||
let mut sub_profile = vec![];
|
||||
let mut sub_level = vec![];
|
||||
for _ in 0..max_sub {
|
||||
@@ -320,9 +320,9 @@ pub fn parse_sps_hevc(nal: &[u8]) -> Option<SpsHevc> {
|
||||
Some(s)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- amostras
|
||||
// -------------------------------------------------------------- samples
|
||||
|
||||
/// Configuração de codec (SPS/PPS/VPS) extraída de um vídeo íntegro da mesma câmera.
|
||||
/// Codec parameter sets (SPS/PPS/VPS) extracted from a donor video recorded by the same camera.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MediaConfig {
|
||||
pub codec: Codec,
|
||||
@@ -331,9 +331,9 @@ pub struct MediaConfig {
|
||||
pub vps: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Procura o `avcC`/`hvcC` de um MP4 completo (lê até `max` bytes a partir de `off`).
|
||||
/// Extracts `avcC`/`hvcC` codec configuration from an intact MP4 (reading up to `max` bytes from `off`).
|
||||
pub fn config_from_mp4(src: &dyn Source, off: u64, max: u64) -> Option<MediaConfig> {
|
||||
// O moov costuma estar no começo ou no fim: procura nos dois lugares.
|
||||
// The `moov` atom is typically near the beginning or the end of the file: check both.
|
||||
let size = src.size();
|
||||
let head = read_vec(src, off, max.min(4 << 20) as usize).ok()?;
|
||||
let mut hay: Vec<Vec<u8>> = vec![head];
|
||||
@@ -416,7 +416,7 @@ pub enum Codec {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Sample {
|
||||
/// Posição absoluta (na origem) do primeiro byte da amostra (prefixo de tamanho incluso).
|
||||
/// Absolute source offset of the first byte of the sample (including the 4-byte length prefix).
|
||||
off: u64,
|
||||
size: u32,
|
||||
sync: bool,
|
||||
@@ -425,9 +425,9 @@ struct Sample {
|
||||
}
|
||||
|
||||
pub struct Rebuilt {
|
||||
/// Bytes do arquivo original antes do `mdat` (ftyp etc.).
|
||||
/// Leading atoms from the original file before `mdat` (`ftyp`, etc.).
|
||||
pub pre_len: u64,
|
||||
/// Onde começam e terminam os dados dos quadros na origem.
|
||||
/// Start and end offsets of the video sample payload in the source.
|
||||
pub payload: (u64, u64),
|
||||
pub moov: Vec<u8>,
|
||||
pub frames: usize,
|
||||
@@ -439,7 +439,7 @@ pub struct Rebuilt {
|
||||
}
|
||||
|
||||
impl Rebuilt {
|
||||
/// Cabeçalho `mdat` de 64 bits para o payload.
|
||||
/// 16-byte 64-bit `mdat` atom header covering the sample payload.
|
||||
pub fn mdat_header(&self) -> Vec<u8> {
|
||||
let size = 16 + (self.payload.1 - self.payload.0);
|
||||
let mut v = Vec::with_capacity(16);
|
||||
@@ -457,7 +457,7 @@ fn hevc_nal_ok(b0: u8, b1: u8) -> bool {
|
||||
b0 & 0x81 == 0 && ((b0 >> 1) & 0x3F) <= 40 && b1 & 0x07 != 0 && (b1 >> 3) == 0
|
||||
}
|
||||
|
||||
/// Cabeçalho do átomo `mdat` que começa em `at`: (início dos dados, tamanho declarado ou 0).
|
||||
/// Parses the `mdat` header at `at`: returns `(payload_start, declared_end_or_0)`.
|
||||
fn mdat_at(src: &dyn Source, at: u64) -> Option<(u64, u64)> {
|
||||
let h = read_vec(src, at, 16).ok()?;
|
||||
if h.len() < 8 || &h[4..8] != b"mdat" {
|
||||
@@ -471,7 +471,7 @@ fn mdat_at(src: &dyn Source, at: u64) -> Option<(u64, u64)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstrói o índice de um MP4 que começa em `off`, sem `moov`. `avail` limita a leitura.
|
||||
/// Rebuilds the `moov` index for a headerless/truncated MP4 starting at `off` (reading at most `avail` bytes).
|
||||
pub fn rebuild(src: &dyn Source, off: u64, avail: u64, refs: &[MediaConfig]) -> Option<Rebuilt> {
|
||||
if let Some(r) = rebuild_with(src, off, avail, None) {
|
||||
return Some(r);
|
||||
@@ -486,7 +486,7 @@ fn rebuild_with(
|
||||
avail: u64,
|
||||
preset: Option<&MediaConfig>,
|
||||
) -> Option<Rebuilt> {
|
||||
// Átomos antes do mdat.
|
||||
// Parse leading atoms before `mdat`.
|
||||
let mut p = 0u64;
|
||||
let mut pre_end = None;
|
||||
for _ in 0..16 {
|
||||
@@ -520,7 +520,7 @@ fn rebuild_with(
|
||||
(off + pre_len + declared).min(hard_end)
|
||||
};
|
||||
|
||||
// Lê o payload em blocos, interpretando NALs.
|
||||
// Scan the `mdat` payload in chunks, parsing length-prefixed NAL units.
|
||||
let mut samples: Vec<Sample> = vec![];
|
||||
let mut cache: Vec<u8> = vec![];
|
||||
let mut cache_off = data_start;
|
||||
@@ -536,7 +536,7 @@ fn rebuild_with(
|
||||
let s = (o - *cache_off) as usize;
|
||||
cache.get(s..s + n).map(|x| x.to_vec())
|
||||
};
|
||||
// Detecta o codec: o que forma a cadeia mais longa de unidades NAL válidas.
|
||||
// Detect the codec (H.264 vs HEVC) that yields the longest initial chain of valid NAL units.
|
||||
let chain = |c: Codec| -> usize {
|
||||
let Ok(head) = read_vec(src, data_start, 64 << 10) else {
|
||||
return 0;
|
||||
@@ -599,10 +599,10 @@ fn rebuild_with(
|
||||
let mut idr_total = 0usize;
|
||||
let mut idr_bad = 0usize;
|
||||
let mut cur: Option<Sample> = None;
|
||||
let mut group_start: Option<u64> = None; // início do grupo de NALs não-VCL que precede um quadro
|
||||
let mut group_start: Option<u64> = None; // start offset of the non-VCL NAL group preceding a frame
|
||||
let mut nal_count = 0usize;
|
||||
let mut max_len = 0u64;
|
||||
// POC (H.264, tipo 0)
|
||||
// POC (H.264 pic_order_cnt_type 0)
|
||||
let mut prev_msb: i64 = 0;
|
||||
let mut prev_lsb: i64 = 0;
|
||||
|
||||
@@ -617,7 +617,7 @@ fn rebuild_with(
|
||||
};
|
||||
let mut ok = ok;
|
||||
if ok && nal_count >= 3 && len > (max_len * 6).max(1 << 20) {
|
||||
// Um "tamanho" muito maior que os já vistos é lixo (áudio lido como vídeo).
|
||||
// A declared NAL length far larger than valid range is interleaved audio or garbage.
|
||||
ok = false;
|
||||
}
|
||||
if ok {
|
||||
@@ -629,7 +629,7 @@ fn rebuild_with(
|
||||
max_len = max_len.max(len);
|
||||
}
|
||||
if !valid {
|
||||
// Pode ser áudio intercalado: procura a próxima unidade NAL plausível.
|
||||
// Likely an interleaved audio chunk: scan ahead for the next plausible video NAL sequence.
|
||||
let found = scan_nal(
|
||||
src,
|
||||
codec,
|
||||
@@ -642,7 +642,7 @@ fn rebuild_with(
|
||||
);
|
||||
match found {
|
||||
Some(q) if nal_count > 0 => {
|
||||
// O que veio antes fecha o quadro atual.
|
||||
// Flush the current frame before starting the next one.
|
||||
if let Some(mut s) = cur.take() {
|
||||
s.size = (group_start.unwrap_or(pos) - s.off) as u32;
|
||||
samples.push(s);
|
||||
@@ -734,9 +734,9 @@ fn rebuild_with(
|
||||
let _ = ty;
|
||||
if is_vcl {
|
||||
if first_of_pic {
|
||||
// fecha o quadro anterior
|
||||
// flush previous frame
|
||||
if let Some(mut s) = cur.take() {
|
||||
// tamanho até o início do grupo atual (ou deste NAL)
|
||||
// frame size up to the start of the current non-VCL group (or this VCL NAL)
|
||||
let end = group_start.unwrap_or(pos);
|
||||
s.size = (end - s.off) as u32;
|
||||
samples.push(s);
|
||||
@@ -816,7 +816,7 @@ fn rebuild_with(
|
||||
return None;
|
||||
}
|
||||
let (num, den) = match timing {
|
||||
Some((units, scale)) if codec == Codec::Avc => (scale, 2 * units), // quadros por segundo = scale / (2 * units)
|
||||
Some((units, scale)) if codec == Codec::Avc => (scale, 2 * units), // fps = time_scale / (2 * num_units_in_tick)
|
||||
Some((units, scale)) => (scale, units),
|
||||
None => (30, 1),
|
||||
};
|
||||
@@ -827,10 +827,10 @@ fn rebuild_with(
|
||||
(num, den)
|
||||
};
|
||||
let fps = num as f64 / den as f64;
|
||||
// Escala: tique do quadro = den, escala de tempo = num.
|
||||
// Timescale: frame tick = den, timescale = num.
|
||||
let (timescale, dur) = (num, den);
|
||||
|
||||
// Ordem de apresentação (POC) por trecho entre IDR.
|
||||
// Compute presentation order (ctts composition offsets) from POC within each IDR GOP.
|
||||
let mut ctts: Option<Vec<i64>> = None;
|
||||
if samples.iter().all(|s| s.poc.is_some()) {
|
||||
let mut offs = vec![0i64; samples.len()];
|
||||
@@ -936,7 +936,7 @@ fn build_moov(
|
||||
let movie_ts = 1000u32;
|
||||
let movie_dur = media_dur * movie_ts as u64 / timescale as u64;
|
||||
|
||||
// Entrada de amostra
|
||||
// Visual sample entry (`avc1` / `hvc1`)
|
||||
let mut entry = vec![];
|
||||
entry.extend_from_slice(&[0; 6]);
|
||||
entry.extend_from_slice(&1u16.to_be_bytes());
|
||||
@@ -975,18 +975,18 @@ fn build_moov(
|
||||
Codec::Hevc => {
|
||||
let h = hevc.unwrap();
|
||||
let mut c = vec![1];
|
||||
c.extend_from_slice(&h.ptl[..12.min(h.ptl.len())]); // espaço/tier/perfil, compat, restrições, nível
|
||||
c.extend_from_slice(&h.ptl[..12.min(h.ptl.len())]); // profile_space/tier/profile_idc, profile_compatibility, constraint_indicator, level_idc
|
||||
c.truncate(13);
|
||||
while c.len() < 13 {
|
||||
c.push(0);
|
||||
}
|
||||
c.extend_from_slice(&0xF000u16.to_be_bytes()); // segmentação espacial
|
||||
c.push(0xFC); // paralelismo
|
||||
c.extend_from_slice(&0xF000u16.to_be_bytes()); // min_spatial_segmentation_idc
|
||||
c.push(0xFC); // parallelismType
|
||||
c.push(0xFC | h.chroma as u8);
|
||||
c.push(0xF8 | (h.bit_depth_luma - 8) as u8);
|
||||
c.push(0xF8 | (h.bit_depth_chroma - 8) as u8);
|
||||
c.extend_from_slice(&0u16.to_be_bytes()); // avgFrameRate
|
||||
c.push(0x0B); // constFrameRate=0, 1 camada temporal, lengthSizeMinusOne=3
|
||||
c.push(0x0B); // constantFrameRate=0, numTemporalLayers=1, lengthSizeMinusOne=3
|
||||
let arrays: [(u8, &[Vec<u8>]); 3] = [(32, vps), (33, sps), (34, pps)];
|
||||
let present: Vec<_> = arrays.iter().filter(|(_, v)| !v.is_empty()).collect();
|
||||
c.push(present.len() as u8);
|
||||
@@ -1047,7 +1047,7 @@ fn build_moov(
|
||||
.collect();
|
||||
let mut stbl_parts = vec![stsd, stts];
|
||||
if let Some(c) = ctts {
|
||||
// entradas agrupadas
|
||||
// run-length encoded entries
|
||||
let mut runs: Vec<(u32, u32)> = vec![];
|
||||
for &v in c {
|
||||
match runs.last_mut() {
|
||||
@@ -1080,7 +1080,7 @@ fn build_moov(
|
||||
let vmhd = full(b"vmhd", 0, 1, &[0; 8]);
|
||||
let minf = bx(b"minf", &[vmhd, dinf, stbl].concat());
|
||||
let mut mdhd_body = vec![];
|
||||
mdhd_body.extend_from_slice(&[0; 8]); // criação/modificação
|
||||
mdhd_body.extend_from_slice(&[0; 8]); // creation_time / modification_time
|
||||
mdhd_body.extend_from_slice(&u32b(timescale));
|
||||
mdhd_body.extend_from_slice(&u32b(media_dur as u32));
|
||||
mdhd_body.extend_from_slice(&0x55C4u16.to_be_bytes()); // "und"
|
||||
@@ -1100,8 +1100,8 @@ fn build_moov(
|
||||
tkhd_body.extend_from_slice(&[0; 4]);
|
||||
tkhd_body.extend_from_slice(&u32b(movie_dur as u32));
|
||||
tkhd_body.extend_from_slice(&[0; 8]);
|
||||
tkhd_body.extend_from_slice(&[0; 4]); // camada, grupo
|
||||
tkhd_body.extend_from_slice(&[0; 4]); // volume, reservado
|
||||
tkhd_body.extend_from_slice(&[0; 4]); // layer, alternate_group
|
||||
tkhd_body.extend_from_slice(&[0; 4]); // volume, reserved
|
||||
for m in matrix {
|
||||
tkhd_body.extend_from_slice(&u32b(m));
|
||||
}
|
||||
@@ -1136,7 +1136,7 @@ fn build_moov(
|
||||
bx(b"moov", &[mvhd, trak].concat())
|
||||
}
|
||||
|
||||
/// A unidade NAL parece de verdade? Valida o cabeçalho de fatia e o tamanho de SPS/PPS/AUD.
|
||||
/// Checks whether a candidate NAL unit looks structurally valid (slice header, SPS/PPS/AUD sizes).
|
||||
fn nal_plausible(
|
||||
codec: Codec,
|
||||
head: &[u8],
|
||||
@@ -1167,7 +1167,7 @@ fn nal_plausible(
|
||||
return false;
|
||||
}
|
||||
if let Some(sp) = sps {
|
||||
// frame_num de IDR é sempre 0
|
||||
// IDR slice `frame_num` is always 0
|
||||
if t == 5 {
|
||||
if let Some(fnum) = b.u(sp.log2_max_frame_num) {
|
||||
return fnum == 0;
|
||||
@@ -1194,7 +1194,7 @@ fn nal_plausible(
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantas unidades NAL válidas seguidas (sem vãos) começam em `at` (até `max`).
|
||||
/// Counts how many consecutive valid length-prefixed NAL units start at `at` (up to `max`).
|
||||
fn chain_len(
|
||||
buf: &[u8],
|
||||
base: u64,
|
||||
@@ -1232,8 +1232,8 @@ fn chain_len(
|
||||
n
|
||||
}
|
||||
|
||||
/// Próxima posição em [from, limit) onde começa uma sequência de unidades NAL plausíveis.
|
||||
/// Prefere o primeiro ponto com duas ou mais unidades seguidas; senão, uma unidade isolada.
|
||||
/// Finds the next offset in `[from, limit)` where a plausible NAL unit sequence begins.
|
||||
/// Prefers an offset with two or more back-to-back valid NALs; falls back to a single valid NAL.
|
||||
fn scan_nal(
|
||||
src: &dyn Source,
|
||||
codec: Codec,
|
||||
@@ -1247,7 +1247,7 @@ fn scan_nal(
|
||||
if limit <= from + 8 {
|
||||
return None;
|
||||
}
|
||||
// Lê um pouco além do limite para poder seguir a cadeia de unidades.
|
||||
// Read slightly past `limit` so we can verify the following NAL in the chain.
|
||||
let want =
|
||||
((limit - from) as usize + (cap as usize).min(4 << 20)).min((data_end - from) as usize);
|
||||
let buf = read_vec(src, from, want).ok()?;
|
||||
@@ -1259,7 +1259,7 @@ fn scan_nal(
|
||||
if l2 >= 2 && l2 <= cap && q + 4 + l2 <= data_end {
|
||||
let nb = buf[i + 4];
|
||||
let good_head = match codec {
|
||||
// Um quadro novo começa por parâmetros/AUD/SEI ou por fatia do macrobloco 0.
|
||||
// A new access unit starts with parameter sets/AUD/SEI or a slice at macroblock 0.
|
||||
Codec::Avc => {
|
||||
avc_nal_ok(nb)
|
||||
&& (matches!(nb & 0x1F, 6..=9)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Notificações do sistema (varredura concluída, disco desconectado...), usando os
|
||||
//! comandos nativos de cada sistema, sem dependências extras.
|
||||
//! Native OS desktop notifications (scan finished, disk disconnected, etc.) using
|
||||
//! standard platform CLI tools with zero extra crate dependencies.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Avisos que o núcleo envia ao sistema operacional. O texto é montado no
|
||||
/// idioma efetivo da interface (`settings::ui_lang`), porque notificações não
|
||||
/// passam pelo dicionário do DOM.
|
||||
/// Notification events emitted by the core. Messages are formatted in the
|
||||
/// effective UI language (`settings::ui_lang`) since OS notifications bypass
|
||||
/// the DOM i18n translator.
|
||||
pub enum Notice {
|
||||
ScanDone { count: usize, target: String },
|
||||
RecoverDone { count: usize, destination: String },
|
||||
@@ -53,7 +53,7 @@ impl Notice {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
|
||||
/// Sends a desktop notification asynchronously. Failures are silently ignored.
|
||||
pub fn send(notice: Notice) {
|
||||
let (title, body) = notice.text(&crate::settings::ui_lang());
|
||||
std::thread::spawn(move || {
|
||||
@@ -72,7 +72,7 @@ pub fn send(notice: Notice) {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn command(title: &str, body: &str) -> Option<Command> {
|
||||
// Como root, notifica na sessão do usuário real.
|
||||
// When running as root, dispatch the notification inside the real user's session.
|
||||
let mut c = if let Some(u) = crate::owner::invoking_user() {
|
||||
let mut c = Command::new("runuser");
|
||||
c.args(["-u", &u.name, "--", "env"]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para
|
||||
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que
|
||||
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário.
|
||||
//! When Guará runs elevated as root (`sudo`/`pkexec`), all user-facing outputs
|
||||
//! (sessions, reports, recovered files) are chowned back to the invoking user
|
||||
//! rather than root, and default paths resolve inside that user's home directory.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,10 +13,10 @@ pub struct User {
|
||||
pub home: PathBuf,
|
||||
}
|
||||
|
||||
/// O usuário real por trás de um processo root iniciado por sudo/pkexec.
|
||||
/// Identifies the real non-root user who launched an elevated `sudo`/`pkexec` process.
|
||||
#[cfg(unix)]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
// SAFETY: `geteuid` has no preconditions.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub fn invoking_user() -> Option<User> {
|
||||
lookup_user(&passwd, uid, name.as_deref())
|
||||
}
|
||||
|
||||
/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd.
|
||||
/// Looks up a user (by UID first, falling back to username) in `/etc/passwd`-formatted text.
|
||||
pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
@@ -57,7 +57,7 @@ pub fn invoking_user() -> Option<User> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root).
|
||||
/// Returns the invoking user's home directory (even when running as root).
|
||||
pub fn user_home() -> PathBuf {
|
||||
if let Some(u) = invoking_user() {
|
||||
return u.home;
|
||||
@@ -69,7 +69,7 @@ pub fn user_home() -> PathBuf {
|
||||
)
|
||||
}
|
||||
|
||||
/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root).
|
||||
/// Transfers ownership of `path` to the invoking user (no-op when not running as root).
|
||||
pub fn give_to_user(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(u) = invoking_user() {
|
||||
@@ -79,7 +79,7 @@ pub fn give_to_user(path: &Path) {
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo.
|
||||
/// Like `fs::create_dir_all`, but chowns newly created directories to the invoking user.
|
||||
pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
|
||||
let mut missing = vec![];
|
||||
let mut cur = Some(path);
|
||||
@@ -97,7 +97,7 @@ pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como `fs::write`, mas o arquivo fica com o dono certo.
|
||||
/// Like `fs::write`, but chowns the written file to the invoking user.
|
||||
pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all_owned(parent)?;
|
||||
@@ -126,7 +126,7 @@ mod tests {
|
||||
(1001, 1002, "/home/maria")
|
||||
);
|
||||
assert!(lookup_user(PASSWD, Some(4242), None).is_none());
|
||||
// o uid manda sobre o nome
|
||||
// UID takes precedence over username
|
||||
assert_eq!(
|
||||
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
|
||||
1001
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Tabelas de partição (MBR/GPT), detecção de sistemas de arquivos,
|
||||
//! volumes cifrados e busca de partições perdidas.
|
||||
//! Partition tables (MBR/GPT), filesystem and encrypted volume detection,
|
||||
//! and lost-partition scanning across unallocated gaps.
|
||||
|
||||
use crate::source::{read_vec, Source};
|
||||
use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
|
||||
@@ -7,7 +7,7 @@ use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
|
||||
const SECTOR: u64 = 512;
|
||||
const MIN_GAP: u64 = 1024 * 1024;
|
||||
|
||||
/// Identifica o sistema de arquivos que começa em `start` e (se possível) seu tamanho.
|
||||
/// Identifies the filesystem or container starting at `start` and its byte size when known.
|
||||
pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)> {
|
||||
let b = read_vec(src, start, 4096).ok()?;
|
||||
if b.len() < 1100 {
|
||||
@@ -16,7 +16,7 @@ pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)>
|
||||
if let Some(r) = detect_fs_in(&b) {
|
||||
return Some(r);
|
||||
}
|
||||
// Assinaturas mais adiante no volume: Btrfs (64 KiB) e RAID mdadm 1.2 (4 KiB).
|
||||
// Signatures located further into the volume: Btrfs (64 KiB) and mdadm 1.2 RAID (4 KiB).
|
||||
if let Ok(h) = read_vec(src, start + 0x10000, 0x100) {
|
||||
if h.len() >= 0x80 && &h[0x40..0x48] == b"_BHRfS_M" {
|
||||
return Some((
|
||||
@@ -33,7 +33,7 @@ pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)>
|
||||
None
|
||||
}
|
||||
|
||||
/// Mesma detecção sobre um buffer que começa no setor candidato (>= 1100 bytes).
|
||||
/// Runs volume signature detection on a buffer starting at the candidate sector (>= 1100 bytes).
|
||||
pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
||||
if b.len() < 1100 {
|
||||
return None;
|
||||
@@ -133,8 +133,8 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
||||
let root_secs = (root_entries * 32).div_ceil(bps);
|
||||
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
|
||||
let clusters = data / spc;
|
||||
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
|
||||
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
|
||||
// BPB structure takes precedence: zero root-entry count + 32-bit FAT size = FAT32 even on
|
||||
// small cluster counts (`mkfs.vfat -F 32` on small images produces this and Linux mounts it).
|
||||
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
|
||||
let kind = if is32 {
|
||||
FsKind::Fat32
|
||||
@@ -146,7 +146,7 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
||||
Some((kind, Some(total * bps)))
|
||||
}
|
||||
|
||||
/// Lê o rótulo (label) do volume quando disponível de forma simples.
|
||||
/// Reads the volume label when directly accessible in the superblock/boot sector.
|
||||
fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
|
||||
let off = if kind == FsKind::Fat32 { 71 } else { 43 };
|
||||
let s = String::from_utf8_lossy(b.get(off..off + 11)?)
|
||||
@@ -155,9 +155,9 @@ fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
|
||||
(!s.is_empty() && s != "NO NAME").then_some(s)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- tabelas
|
||||
// ----------------------------------------------------------------- partition tables
|
||||
|
||||
/// Analisa o disco. `search_lost` varre lacunas atrás de volumes perdidos (lento em discos grandes).
|
||||
/// Inspects disk layout. `search_lost` scans unpartitioned gaps for lost volumes.
|
||||
pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
|
||||
let size = src.size();
|
||||
let mut layout = DiskLayout {
|
||||
@@ -174,7 +174,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
|
||||
layout.table = "gpt".into();
|
||||
parts = g;
|
||||
} else if has_mbr {
|
||||
// Pode ser MBR de verdade ou um setor de boot de volume sem tabela (superfloppy).
|
||||
// Sector 0 may be either a partitioned MBR or a partitionless VBR ("superfloppy").
|
||||
let looks_boot = detect_fs_in(&read_vec(src, 0, 4096).unwrap_or_default()).is_some();
|
||||
if !looks_boot {
|
||||
let mp = read_mbr(src, §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 let Some((fs, len)) = detect_fs(src, 0) {
|
||||
parts.push(PartInfo {
|
||||
@@ -217,12 +217,12 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
|
||||
parts.sort_by_key(|p| p.start);
|
||||
layout.gaps = compute_gaps(&parts, size);
|
||||
|
||||
// Partições perdidas: procurar assinaturas de volumes nas lacunas.
|
||||
// Lost partitions: scan unallocated gaps for volume signatures.
|
||||
let mut lost = Vec::new();
|
||||
if search_lost {
|
||||
lost = find_lost(src, &layout.gaps, &parts);
|
||||
if parts.is_empty() && layout.table == "none" {
|
||||
// Disco sem tabela nem volume no início: procurar no disco todo.
|
||||
// No partition table or volume at sector 0: scan the entire disk for lost volumes.
|
||||
lost = find_lost(
|
||||
src,
|
||||
&[Gap {
|
||||
@@ -304,7 +304,7 @@ fn read_mbr(src: &dyn Source, s0: &[u8], size: u64) -> Vec<PartInfo> {
|
||||
continue;
|
||||
}
|
||||
if ty == 0xEE {
|
||||
continue; // MBR protetor de GPT
|
||||
continue; // GPT protective MBR
|
||||
}
|
||||
if matches!(ty, 0x05 | 0x0F | 0x85) {
|
||||
ext_base = Some(lba * SECTOR);
|
||||
@@ -365,7 +365,7 @@ fn part(index: usize, start: u64, len: u64, table_type: Option<String>) -> PartI
|
||||
}
|
||||
|
||||
fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
|
||||
// Tenta o cabeçalho primário (LBA 1) e depois o de backup (último setor).
|
||||
// Try the primary GPT header (LBA 1) first, falling back to the backup header (last LBA).
|
||||
let hdr = read_vec(src, SECTOR, 512)
|
||||
.ok()
|
||||
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART")
|
||||
@@ -383,7 +383,7 @@ fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
|
||||
}
|
||||
let mut tbl = read_vec(src, table_lba * SECTOR, n * esz).ok()?;
|
||||
if tbl.len() < n * esz {
|
||||
// Cabeçalho de backup aponta para a tabela de backup; se falhar, desiste.
|
||||
// Backup GPT header points to the backup partition array; abort if both fail.
|
||||
tbl.resize(n * esz, 0);
|
||||
}
|
||||
let mut out = vec![];
|
||||
@@ -418,7 +418,7 @@ fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
|
||||
}
|
||||
|
||||
fn gpt_type_name(guid: &[u8]) -> &'static str {
|
||||
// GUIDs em formato de disco (mixed-endian).
|
||||
// Known partition type GUIDs in on-disk mixed-endian byte order.
|
||||
match guid {
|
||||
[0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11, 0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B] => {
|
||||
"EFI System"
|
||||
@@ -445,7 +445,7 @@ fn gpt_type_name(guid: &[u8]) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Procura volumes (por assinatura) dentro de lacunas, em limites de setor.
|
||||
/// Scans unallocated byte gaps on sector boundaries for lost filesystem/volume headers.
|
||||
fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo> {
|
||||
const CHUNK: u64 = 4 * 1024 * 1024;
|
||||
let mut found: Vec<PartInfo> = vec![];
|
||||
@@ -468,7 +468,7 @@ fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo
|
||||
if abs >= skip_until {
|
||||
let s = &buf[off as usize..];
|
||||
if s.len() >= 1100 {
|
||||
// Filtro rápido: só chama a detecção completa se algum magic casa.
|
||||
// Fast pre-filter: only run full detection when a known magic byte sequence matches.
|
||||
let quick = &s[3..11] == b"NTFS "
|
||||
|| &s[3..11] == b"EXFAT "
|
||||
|| &s[3..11] == b"-FVE-FS-"
|
||||
@@ -477,13 +477,13 @@ fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo
|
||||
|| (s[1080] == 0x53 && s[1081] == 0xEF);
|
||||
if quick {
|
||||
if let Some((fs, len)) = detect_fs_in(s) {
|
||||
// ext: o superbloco está a 1024 bytes do início do volume,
|
||||
// por isso o candidato ext é o setor atual (início do volume).
|
||||
// ext2/3/4: the superblock sits 1024 bytes into the volume,
|
||||
// so `off` (1024 bytes before the magic) is the candidate volume start.
|
||||
let len = len.unwrap_or(0);
|
||||
let ok = len >= 64 * 1024
|
||||
&& abs + len <= src.size() + 1024 * 1024
|
||||
&& !overlaps(known, abs, len);
|
||||
// Evita falso positivo de FAT em setor de boot MBR genérico.
|
||||
// Avoid false-positive FAT detection on generic MBR boot code.
|
||||
if ok && (fs != FsKind::Fat12 || len >= 512 * 1024) {
|
||||
let mut p =
|
||||
part(0, abs, len, Some("encontrada por varredura".into()));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Gerador mínimo de PDF (A4, texto e tabelas) para o relatório de recuperação.
|
||||
//! Minimal standalone PDF generator (A4, text, and tables) for forensic recovery reports.
|
||||
//!
|
||||
//! Usa só as fontes padrão do PDF (Helvetica, Helvetica-Bold e Courier) com codificação
|
||||
//! WinAnsi, então o arquivo é autocontido e não depende de fontes instaladas. Caracteres fora
|
||||
//! do Latin-1/WinAnsi viram `?`.
|
||||
//! Uses only built-in PDF Type 1 fonts (Helvetica, Helvetica-Bold, and Courier) with WinAnsi
|
||||
//! encoding so output files are self-contained with no embedded font dependencies. Characters
|
||||
//! outside Latin-1/WinAnsi are replaced with `?`.
|
||||
|
||||
use flate2::write::ZlibEncoder;
|
||||
use flate2::Compression;
|
||||
@@ -29,7 +29,7 @@ impl Font {
|
||||
}
|
||||
}
|
||||
|
||||
// Larguras AFM (milésimos de em) dos caracteres ASCII 32..=126.
|
||||
// AFM glyph widths (in thousandths of an em) for ASCII 32..=126.
|
||||
const HELV: [u16; 95] = [
|
||||
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
|
||||
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
|
||||
@@ -45,7 +45,7 @@ const HELV_B: [u16; 95] = [
|
||||
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
|
||||
];
|
||||
|
||||
/// Letra base de um caractere acentuado do Latin-1 (a largura é a da letra sem acento).
|
||||
/// Maps a Latin-1 accented character to its ASCII base letter for glyph width lookup.
|
||||
fn base_letter(c: char) -> char {
|
||||
match c {
|
||||
'À'..='Å' => 'A',
|
||||
@@ -88,7 +88,7 @@ pub fn text_width(s: &str, size: f64, f: Font) -> f64 {
|
||||
s.chars().map(|c| char_width(c, f)).sum::<f64>() * size
|
||||
}
|
||||
|
||||
/// Byte WinAnsi de um caractere (ou `?`).
|
||||
/// Converts a Unicode char to its WinAnsi byte (or `?` if unrepresentable).
|
||||
fn win_ansi(c: char) -> u8 {
|
||||
let n = c as u32;
|
||||
match c {
|
||||
@@ -120,8 +120,8 @@ fn escape(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// Quebra `s` em linhas de no máximo `width` pontos. Sem espaço onde quebrar (caminhos,
|
||||
/// hashes), corta no meio da palavra.
|
||||
/// Greedy word-wraps `s` into lines of at most `width` points. Long tokens without spaces
|
||||
/// (paths, hashes) are broken mid-token.
|
||||
pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> {
|
||||
let mut lines = vec![];
|
||||
for para in s.split('\n') {
|
||||
@@ -270,7 +270,7 @@ impl Pdf {
|
||||
self.y -= 3.0;
|
||||
}
|
||||
|
||||
/// Aviso destacado com barra lateral.
|
||||
/// Renders a highlighted callout box with a colored left accent bar.
|
||||
pub fn note(&mut self, s: &str) {
|
||||
let size = 9.0;
|
||||
let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0);
|
||||
@@ -292,7 +292,7 @@ impl Pdf {
|
||||
self.y -= h + 6.0;
|
||||
}
|
||||
|
||||
/// Linhas rótulo/valor; o valor pode ser monoespaçado.
|
||||
/// Renders key/value metadata rows (with optional monospace value styling).
|
||||
pub fn kv(&mut self, rows: &[(&str, &str, Font)]) {
|
||||
let lw = 140.0;
|
||||
let size = 9.5;
|
||||
@@ -318,7 +318,7 @@ impl Pdf {
|
||||
self.y -= 4.0;
|
||||
}
|
||||
|
||||
/// Tabela com cabeçalho repetido a cada página e linhas que quebram texto.
|
||||
/// Renders a multi-page table with repeating headers and wrapped cell text.
|
||||
pub fn table(&mut self, cols: &[Col], rows: &[Vec<String>]) {
|
||||
let size = 8.0;
|
||||
let pad = 3.0;
|
||||
@@ -391,7 +391,7 @@ impl Pdf {
|
||||
self.y -= 6.0;
|
||||
}
|
||||
|
||||
/// Fecha o documento. `date` é a data de criação em UTC (`AAAAMMDDHHMMSS`).
|
||||
/// Finalizes and serializes the PDF document. `date` is the UTC creation timestamp (`YYYYMMDDHHMMSS`).
|
||||
pub fn finish(mut self, date: &str) -> Vec<u8> {
|
||||
if !self.cur.is_empty() || self.pages.is_empty() {
|
||||
self.pages.push(std::mem::take(&mut self.cur));
|
||||
@@ -416,7 +416,7 @@ impl Pdf {
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
// 1 catálogo, 2 páginas, 3-5 fontes, 6 info, depois (página, conteúdo) por página
|
||||
// PDF objects: 1 catalog, 2 pages root, 3-5 fonts, 6 info dict, then (page, content stream) per page
|
||||
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids: String = (0..total)
|
||||
.map(|i| format!("{} 0 R", 7 + 2 * i))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! RAID por software: linear, 0, 1, 4, 5, 6 e 10, a partir dos discos soltos.
|
||||
//! Software RAID reconstruction: linear, 0, 1, 4, 5, 6, and 10 from member disks.
|
||||
//!
|
||||
//! Lê os metadados do mdadm (superbloco 0.90 e 1.x) para montar sozinho; sem metadados, o
|
||||
//! módulo `raid_detect` descobre ordem, tamanho de faixa e layout. Se faltarem discos, o
|
||||
//! array é lido em modo degradado (paridade reconstrói o que faltar).
|
||||
//! Parses Linux `mdadm` superblocks (v0.90 and v1.x) for automatic assembly; for metadata-less
|
||||
//! arrays, `raid_detect` infers disk order, chunk size, and parity layout. Missing disks are
|
||||
//! reconstructed on the fly in degraded mode via XOR / GF(2^8) parity.
|
||||
|
||||
use crate::source::{read_vec, Source};
|
||||
use std::io;
|
||||
@@ -34,7 +34,7 @@ impl Level {
|
||||
}
|
||||
}
|
||||
|
||||
/// Posição da paridade e ordem dos dados, como no driver md do Linux.
|
||||
/// Parity placement and data stripe ordering, matching the Linux kernel `md` driver.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Layout {
|
||||
@@ -42,7 +42,7 @@ pub enum Layout {
|
||||
RightAsymmetric,
|
||||
LeftSymmetric,
|
||||
RightSymmetric,
|
||||
/// RAID 4 e variantes "parity-0"/"parity-n".
|
||||
/// RAID 4 and fixed-parity (`parity-first` / `parity-last`) variants.
|
||||
ParityFirst,
|
||||
ParityLast,
|
||||
}
|
||||
@@ -72,23 +72,23 @@ impl Layout {
|
||||
#[derive(Clone)]
|
||||
pub struct Member {
|
||||
pub src: Arc<dyn Source>,
|
||||
/// Onde começam os dados dentro do membro.
|
||||
/// Byte offset where array data begins within this member disk.
|
||||
pub data_offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RaidParams {
|
||||
pub level: Level,
|
||||
/// Tamanho da faixa por disco, em bytes.
|
||||
/// Stripe chunk size per disk in bytes.
|
||||
pub chunk: u64,
|
||||
pub layout: Layout,
|
||||
/// Membros na ordem do array; `None` = disco ausente.
|
||||
/// Member disks in array slot order (`None` = missing/failed disk).
|
||||
pub members: Vec<Option<Member>>,
|
||||
/// RAID 10: cópias "perto" (near) e "longe" (far).
|
||||
/// RAID 10 layout parameters: `near` and `far` replica counts.
|
||||
pub near: u32,
|
||||
pub far: u32,
|
||||
pub far_offset: bool,
|
||||
/// Bytes úteis por membro (0 = até o fim do menor).
|
||||
/// Usable data bytes per member (0 = use the shortest member's length).
|
||||
pub per_member: u64,
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ pub struct RaidSource {
|
||||
per_member: u64,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ GF(2^8) para RAID 6
|
||||
// ------------------------------------------------------------ GF(2^8) arithmetic for RAID 6
|
||||
|
||||
fn gf_mul(mut a: u8, mut b: u8) -> u8 {
|
||||
let mut r = 0u8;
|
||||
@@ -231,7 +231,7 @@ impl RaidSource {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (disco de paridade P, disco de paridade Q, disco que guarda o dado `d` da linha `row`)
|
||||
/// Returns `(p_disk, q_disk, data_disk_for_d)` for stripe `row`.
|
||||
fn raid5_map(&self, row: u64, d: usize) -> (usize, usize) {
|
||||
let n = self.p.members.len();
|
||||
let r = (row % n as u64) as usize;
|
||||
@@ -273,7 +273,7 @@ impl RaidSource {
|
||||
let disk = if self.p.layout == Layout::LeftSymmetric {
|
||||
(pd + 2 + d) % n
|
||||
} else {
|
||||
// assimétrico: dados em ordem pulando P e Q
|
||||
// asymmetric layout: data chunks placed in ascending disk order, skipping P and Q
|
||||
let mut k = d;
|
||||
for skip in sorted2(pd, qd) {
|
||||
if k >= skip {
|
||||
@@ -305,7 +305,7 @@ impl RaidSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lê `buf.len()` bytes dentro de um único chunk lógico.
|
||||
/// Reads `buf.len()` bytes within a single logical stripe chunk.
|
||||
fn read_in_chunk(&self, chunk_idx: u64, within: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let n = self.p.members.len();
|
||||
let c = self.p.chunk;
|
||||
@@ -333,7 +333,7 @@ impl RaidSource {
|
||||
if self.p.members[disk].is_some() {
|
||||
return self.read_member(disk, off, buf);
|
||||
}
|
||||
// Reconstrói por XOR de todos os outros (dados + paridade) da linha.
|
||||
// Reconstruct missing disk chunk by XORing all remaining data and parity chunks in the stripe.
|
||||
buf.fill(0);
|
||||
let mut tmp = vec![0u8; buf.len()];
|
||||
for i in 0..n {
|
||||
@@ -374,7 +374,7 @@ impl RaidSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Onde ficam todas as cópias de um chunk lógico (como `raid10_find_phys` do kernel).
|
||||
/// Computes all physical `(disk, chunk_index)` replicas for a logical RAID 10 chunk (matches Linux `raid10_find_phys`).
|
||||
fn r10_locations(&self, chunk_idx: u64) -> Vec<(usize, u64)> {
|
||||
let n = self.p.members.len() as u64;
|
||||
let c = self.p.chunk;
|
||||
@@ -416,7 +416,7 @@ impl RaidSource {
|
||||
let dd = n - 2;
|
||||
let (pd, qd, _) = self.raid6_map(row, 0);
|
||||
let len = buf.len();
|
||||
// Estado de cada dado da linha.
|
||||
// Read all available data chunks in the stripe.
|
||||
let disks: Vec<usize> = (0..dd).map(|k| self.raid6_map(row, k).2).collect();
|
||||
let missing_data: Vec<usize> = (0..dd)
|
||||
.filter(|&k| self.p.members[disks[k]].is_none())
|
||||
@@ -549,7 +549,7 @@ impl Source for RaidSource {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ metadados do mdadm
|
||||
// ============================================================ mdadm superblock metadata
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MdSuper {
|
||||
@@ -560,10 +560,10 @@ pub struct MdSuper {
|
||||
pub layout: u32,
|
||||
pub chunk: u64,
|
||||
pub raid_disks: u32,
|
||||
/// Papel deste disco no array (None = reserva/falho).
|
||||
/// Slot index of this disk in the array (`None` = spare or failed).
|
||||
pub role: Option<u32>,
|
||||
pub data_offset: u64,
|
||||
/// Bytes de dados por membro.
|
||||
/// Usable data size per member in bytes.
|
||||
pub data_size: u64,
|
||||
pub events: u64,
|
||||
pub array_size: u64,
|
||||
@@ -589,10 +589,10 @@ fn level_from(v: i32) -> Option<Level> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Procura um superbloco md nas posições possíveis de `src` (um disco ou partição).
|
||||
/// Probes `src` (disk or partition) for an `mdadm` superblock at standard v1.x and v0.90 offsets.
|
||||
pub fn read_md_super(src: &dyn Source) -> Option<MdSuper> {
|
||||
let size = src.size();
|
||||
// 1.1 no início, 1.2 a 4 KiB, 1.0 a 8 KiB do fim.
|
||||
// v1.1 at 0, v1.2 at 4 KiB, v1.0 near end of device (8 KiB-aligned).
|
||||
let mut cands: Vec<(u64, &str)> = vec![(0, "1.1"), (4096, "1.2")];
|
||||
if size > 16384 {
|
||||
cands.push(((size & !4095) - 8192, "1.0"));
|
||||
@@ -644,7 +644,7 @@ pub fn read_md_super(src: &dyn Source) -> Option<MdSuper> {
|
||||
array_size,
|
||||
});
|
||||
}
|
||||
// 0.90: no fim, alinhado a 64 KiB.
|
||||
// v0.90: within the last 64 KiB-aligned boundary at the end of the device.
|
||||
if size > 131072 {
|
||||
let off = (size & !65535) - 65536;
|
||||
if let Ok(b) = read_vec(src, off, 4096) {
|
||||
@@ -688,7 +688,7 @@ fn uuid_str(u: &[u8; 16]) -> String {
|
||||
u.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Um array reconhecido pelos superblocos dos discos fornecidos.
|
||||
/// A software RAID array assembled from `mdadm` superblocks.
|
||||
pub struct FoundArray {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
@@ -697,7 +697,7 @@ pub struct FoundArray {
|
||||
pub missing: usize,
|
||||
}
|
||||
|
||||
/// Agrupa membros por UUID e monta cada array (degradado se for possível).
|
||||
/// Groups member disks by array UUID and assembles each array (including degraded arrays when recoverable).
|
||||
pub fn assemble_md(members: Vec<(Arc<dyn Source>, MdSuper)>) -> Vec<Result<FoundArray, String>> {
|
||||
let mut groups: Vec<(String, Vec<(Arc<dyn Source>, MdSuper)>)> = vec![];
|
||||
for (s, m) in members {
|
||||
@@ -709,7 +709,7 @@ pub fn assemble_md(members: Vec<(Arc<dyn Source>, MdSuper)>) -> Vec<Result<Found
|
||||
}
|
||||
let mut out = vec![];
|
||||
for (uuid, mut list) in groups {
|
||||
// O membro com mais eventos manda; membros desatualizados são tratados como ausentes.
|
||||
// Use the member with the highest event counter as authoritative; stale members are treated as missing.
|
||||
list.sort_by_key(|(_, m)| std::cmp::Reverse(m.events));
|
||||
let top = list[0].1.clone();
|
||||
let n = top.raid_disks as usize;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Descobre sozinho nível, ordem dos discos, tamanho de faixa e layout de um RAID sem
|
||||
//! metadados, testando as combinações e medindo a coerência do sistema de arquivos que sai.
|
||||
//! Automatically detects level, disk order, chunk size, and parity layout of a metadata-less
|
||||
//! RAID array by scoring parity consistency and filesystem structural coherence across combinations.
|
||||
|
||||
use crate::partition;
|
||||
use crate::raid::{Layout, Level, Member, RaidParams, RaidSource};
|
||||
@@ -12,7 +12,7 @@ pub struct Candidate {
|
||||
pub level: Level,
|
||||
pub chunk: u64,
|
||||
pub layout: Layout,
|
||||
/// Índices dos membros fornecidos, na ordem do array.
|
||||
/// Indices into the input member slice, ordered by array slot.
|
||||
pub order: Vec<usize>,
|
||||
pub score: f64,
|
||||
}
|
||||
@@ -96,7 +96,7 @@ fn ntfs_score(src: &dyn Source, start: u64) -> f64 {
|
||||
if r.len() < 64 {
|
||||
continue;
|
||||
}
|
||||
// Registros além do fim do MFT estão zerados: não contam contra.
|
||||
// Zero-filled blocks past the active MFT records do not penalize the score.
|
||||
if r.iter().all(|&x| x == 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -112,7 +112,7 @@ fn ntfs_score(src: &dyn Source, start: u64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coerência do volume que começa em `start`.
|
||||
/// Scores the structural coherence of a candidate volume starting at `start`.
|
||||
fn volume_score(src: &dyn Source, start: u64, fs: FsKind) -> f64 {
|
||||
match fs {
|
||||
FsKind::Ntfs => ntfs_score(src, start),
|
||||
@@ -140,7 +140,7 @@ fn first_sector_plausible(src: &dyn Source) -> bool {
|
||||
b.len() >= 1100 && ((b[510] == 0x55 && b[511] == 0xAA) || partition::detect_fs_in(&b).is_some())
|
||||
}
|
||||
|
||||
/// Testa as combinações e devolve as melhores (maior pontuação primeiro).
|
||||
/// Evaluates candidate RAID configurations and returns them sorted by score (best first).
|
||||
pub fn detect(
|
||||
members: &[Arc<dyn Source>],
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
@@ -149,7 +149,7 @@ pub fn detect(
|
||||
if n < 2 {
|
||||
return vec![];
|
||||
}
|
||||
// Trabalha sobre um prefixo em memória de cada disco.
|
||||
// Operate on an in-memory prefix of each member disk for fast candidate evaluation.
|
||||
let cached: Vec<Arc<dyn Source>> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
@@ -163,7 +163,7 @@ pub fn detect(
|
||||
}
|
||||
let mut out: Vec<Candidate> = vec![];
|
||||
|
||||
// RAID 1: cópias idênticas.
|
||||
// RAID 1: identical mirror copies.
|
||||
let probe = |s: &Arc<dyn Source>| read_vec(s.as_ref(), 0, 1 << 20).unwrap_or_default();
|
||||
let first = probe(&cached[0]);
|
||||
if first.iter().any(|&x| x != 0) && cached.iter().skip(1).all(|c| probe(c) == first) {
|
||||
@@ -177,7 +177,7 @@ pub fn detect(
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
// RAID 5/4 satisfaz "XOR de todos os discos = 0" em toda faixa.
|
||||
// RAID 5/4 satisfies `XOR across all disks == 0` on every active stripe.
|
||||
let sample = 4usize << 20;
|
||||
let bufs: Vec<Vec<u8>> = cached
|
||||
.iter()
|
||||
@@ -246,7 +246,7 @@ pub fn detect(
|
||||
let Ok(r) = RaidSource::new(params) else {
|
||||
continue;
|
||||
};
|
||||
// O primeiro setor do volume precisa estar num disco que o tenha.
|
||||
// The first sector of the candidate volume must reside on a disk where that signature was seen.
|
||||
if any_first {
|
||||
let mut b = [0u8; 512];
|
||||
let _ = r.read_at(0, &mut b);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
//! Reencontra o disco de uma sessão quando ele foi desconectado e voltou com
|
||||
//! outro nome (por exemplo `/dev/sda` virando `/dev/sdb`).
|
||||
//! Re-identifies and reconnects a session's source disk when unplugged and reattached
|
||||
//! under a different OS device node (e.g. `/dev/sda` becoming `/dev/sdb`).
|
||||
|
||||
use crate::device;
|
||||
use crate::source::{fingerprint, open_path, Source};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A origem é compatível com o que a sessão espera?
|
||||
/// `size == 0` ou `fp == None` significam "não sei" e não reprovam.
|
||||
/// Checks whether `src` matches the size and fingerprint expected by the session.
|
||||
/// `size == 0` or `fp == None` mean unknown (legacy session) and do not reject the source.
|
||||
pub fn matches(src: &dyn Source, size: u64, fp: Option<&str>) -> bool {
|
||||
(size == 0 || src.size() == size) && fp.map_or(true, |f| fingerprint(src).as_deref() == Some(f))
|
||||
}
|
||||
|
||||
/// Procura entre os discos conectados o que corresponde à sessão.
|
||||
/// Com impressão digital, exige igualdade; sem ela, só aceita se houver um único disco do mesmo tamanho.
|
||||
/// Searches attached storage devices for the disk matching the session's fingerprint.
|
||||
/// Requires an exact fingerprint match when present; otherwise accepts a unique size match.
|
||||
pub fn find_matching_source(
|
||||
size: u64,
|
||||
fp: Option<&str>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Gravação dos arquivos recuperados (sempre em outro destino) com SHA-256
|
||||
//! e manifesto da sessão.
|
||||
//! Writes recovered files to a separate destination volume along with SHA-256
|
||||
//! digests, recovery journal, and session manifest.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fs::sanitize_name;
|
||||
@@ -33,12 +33,12 @@ pub struct RecoverReport {
|
||||
pub failed: Vec<(u64, String)>,
|
||||
pub total_bytes: u64,
|
||||
pub cancelled: bool,
|
||||
/// Arquivos já recuperados e conferidos numa execução anterior (pulados ao retomar).
|
||||
/// Files already recovered and SHA-256 verified in a prior run (skipped on resume).
|
||||
#[serde(default)]
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||
/// Rejects destination paths located on the same physical device as the source.
|
||||
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let dest = dest.canonicalize()?;
|
||||
@@ -69,24 +69,24 @@ pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()>
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(sp) {
|
||||
if meta.is_file() {
|
||||
// Imagem: evita gravar ao lado só se for o mesmo arquivo (não faz sentido), mas aceita.
|
||||
// For image files: only reject writing directly over the source image file itself.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como organizar os arquivos recuperados dentro do destino.
|
||||
/// Directory layout strategy for recovered files inside the destination folder.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RecoverLayout {
|
||||
/// Pastas originais quando conhecidas; o resto separado por tipo (padrão).
|
||||
/// Preserve original folder hierarchy when known; group carved files by type (default).
|
||||
#[default]
|
||||
Folders,
|
||||
/// Uma pasta por tipo (Fotos, Documentos, Vídeos...).
|
||||
/// Group into one folder per category (Photos, Documents, Videos, etc.).
|
||||
Type,
|
||||
/// Pastas por ano e mês (data da foto, ou a de modificação).
|
||||
/// Group into `YYYY/YYYY-MM` folders by capture or modification date.
|
||||
Date,
|
||||
/// Tudo direto na pasta de destino.
|
||||
/// Place all files flat inside the destination folder.
|
||||
Flat,
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ pub fn recover_with(
|
||||
recover_resumable(src, files, dest, layout, "", false, cancelled, progress)
|
||||
}
|
||||
|
||||
/// Uma linha do diário de recuperação (`guara-diario.jsonl`): cada arquivo gravado e conferido.
|
||||
/// A single entry in the recovery journal (`guara-diario.jsonl`) for a written and verified file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct JournalEntry {
|
||||
source: String,
|
||||
@@ -156,9 +156,9 @@ fn sha256_of_file(p: &Path) -> Option<String> {
|
||||
Some(h.finalize().sha256)
|
||||
}
|
||||
|
||||
/// Como `recover_with`, mas mantém um diário e, com `resume`, pula o que já foi
|
||||
/// recuperado (e conferido por SHA-256) numa execução interrompida.
|
||||
/// `source_key` identifica a origem para que o diário de outro disco não seja reaproveitado.
|
||||
/// Like `recover_with`, but maintains an append-only journal and, when `resume` is set, skips
|
||||
/// files already written and SHA-256 verified during an interrupted run.
|
||||
/// `source_key` binds the journal to the source disk so another disk's journal is never reused.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn recover_resumable(
|
||||
src: &dyn Source,
|
||||
@@ -202,7 +202,7 @@ pub fn recover_resumable(
|
||||
break;
|
||||
}
|
||||
progress(i, files.len(), report.total_bytes);
|
||||
// retomada: já está lá e confere?
|
||||
// resume check: does the file already exist on disk with the expected size and SHA-256?
|
||||
if let Some(e) = prior.get(&f.id) {
|
||||
let p = dest.join(&e.file.path);
|
||||
if e.name == f.name && e.file.size == f.size && p.is_file() {
|
||||
@@ -215,7 +215,7 @@ pub fn recover_resumable(
|
||||
report.recovered.push(e.file.clone());
|
||||
continue;
|
||||
}
|
||||
// incompleto ou alterado: refaz (é um arquivo nosso)
|
||||
// incomplete or modified partial file from our previous run: overwrite it
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +321,7 @@ fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
|
||||
p
|
||||
}
|
||||
|
||||
/// Espaço livre (bytes) no volume onde `path` está (ou estaria).
|
||||
/// Returns available free bytes on the filesystem containing `path`.
|
||||
pub fn free_space(path: &Path) -> Option<u64> {
|
||||
let mut p = path;
|
||||
while !p.exists() {
|
||||
@@ -331,7 +331,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: `c` é uma string C válida e `st` é uma estrutura de saída local.
|
||||
// SAFETY: `c` is a valid NUL-terminated C string and `st` is a valid local output struct.
|
||||
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
|
||||
return None;
|
||||
@@ -348,7 +348,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut free = 0u64;
|
||||
// SAFETY: `wide` termina em NUL e o ponteiro de saída é válido.
|
||||
// SAFETY: `wide` is NUL-terminated and the output pointer is valid.
|
||||
let ok = unsafe {
|
||||
GetDiskFreeSpaceExW(
|
||||
wide.as_ptr(),
|
||||
@@ -361,7 +361,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Escreve o conteúdo e devolve o SHA-256.
|
||||
/// Streams the recovered file content to `out` and returns its hex SHA-256 digest.
|
||||
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
let mut out = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
@@ -443,8 +443,8 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
Ok(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset`
|
||||
/// (usado para streaming/pré-visualização sem gravar em disco).
|
||||
/// Reads up to `buf.len()` bytes of a recovered file's logical content starting at `offset`
|
||||
/// (used for zero-copy streaming and preview without writing to disk).
|
||||
pub fn read_found_at(
|
||||
src: &dyn Source,
|
||||
f: &Found,
|
||||
@@ -510,7 +510,7 @@ pub fn read_found_at(
|
||||
}
|
||||
}
|
||||
|
||||
/// Um arquivo achado visto como origem de leitura posicional (para leitores de formato).
|
||||
/// Adapter exposing a recovered `Found` file as a positional `Source` (for format parsers).
|
||||
pub struct FoundSource<'a> {
|
||||
pub src: &'a dyn Source,
|
||||
pub found: &'a Found,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Exportação para análise e relatório de recuperação (cadeia de custódia):
|
||||
//! CSV dos achados e um relatório HTML autocontido, pronto para imprimir.
|
||||
//! Forensic export and chain-of-custody recovery reporting:
|
||||
//! CSV findings table and self-contained, print-ready HTML/PDF reports.
|
||||
|
||||
use crate::hashing::Hashes;
|
||||
use crate::meta::fmt_unix;
|
||||
@@ -19,11 +19,11 @@ pub struct CaseInfo {
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Situação da trilha de auditoria no momento do relatório.
|
||||
/// Status of the audit hash chain at the time the report is generated.
|
||||
pub struct AuditNote {
|
||||
pub entries: usize,
|
||||
pub ok: bool,
|
||||
/// Hash da última entrada, para guardar fora da máquina.
|
||||
/// Head hash of the latest audit entry, to be archived off-machine.
|
||||
pub head: String,
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ pub struct ReportInput<'a> {
|
||||
}
|
||||
|
||||
fn cell(s: &str) -> String {
|
||||
// aspas e escapa; neutraliza "injeção de fórmula" em planilhas
|
||||
// quote and escape; neutralize spreadsheet formula injection (`=`, `+`, `-`, `@`)
|
||||
let mut t = s.replace('"', "\"\"");
|
||||
if t.starts_with(['=', '+', '-', '@', '\t', '\r']) {
|
||||
t.insert(0, '\'');
|
||||
@@ -66,7 +66,7 @@ fn iso(t: Option<i64>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// CSV com `;` (abre direto no Excel em português) e BOM UTF-8.
|
||||
/// Writes a UTF-8 BOM `;`-delimited CSV of findings.
|
||||
pub fn csv(found: &[&Found]) -> String {
|
||||
let mut out = String::from("\u{feff}id;nome;pasta;extensao;categoria;tamanho_bytes;origem;confianca;modificado_utc;criado_utc;acessado_utc;foto_tirada;camera;largura;altura;gps;deslocamento_no_disco;observacao\r\n");
|
||||
for f in found {
|
||||
@@ -237,7 +237,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
||||
o.push_str("</table>");
|
||||
}
|
||||
|
||||
// Resumo
|
||||
// Summary
|
||||
let mut by_cat = std::collections::BTreeMap::new();
|
||||
let mut conf = [0usize; 3];
|
||||
let mut bytes = 0u64;
|
||||
@@ -318,7 +318,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
||||
o
|
||||
}
|
||||
|
||||
/// Relatório em PDF (A4), com as mesmas seções do HTML.
|
||||
/// Generates an A4 PDF recovery report containing the same sections as the HTML report.
|
||||
pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
||||
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
|
||||
let now = std::time::SystemTime::now()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
|
||||
//! carving no espaço livre/desconhecido → fusão sem duplicatas.
|
||||
//! Scan orchestrator: partition layout -> filesystem metadata readers ->
|
||||
//! signature carving across free/unallocated space -> deduplicated merge.
|
||||
|
||||
use crate::carve::{carve_range, CarveCfg, CarveEnd, CarveEvents, HitAction};
|
||||
use crate::fs::{self, merge_extents, overlaps, Ctx};
|
||||
@@ -18,12 +18,12 @@ pub struct Progress {
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Receptor dos resultados (a interface/CLI implementa).
|
||||
/// Callback sink for scan progress and findings (implemented by the CLI and UI service).
|
||||
pub trait Sink {
|
||||
fn found(&mut self, f: Found);
|
||||
/// Um achado de carving confirmou um arquivo já listado por metadados.
|
||||
/// A signature-carved hit confirmed a file already discovered via filesystem metadata.
|
||||
fn confirm(&mut self, id: u64, confidence: Confidence);
|
||||
/// Um achado já emitido ganhou dados melhores (ex.: JPEG remontado de fragmentos).
|
||||
/// A previously emitted finding was upgraded (e.g. fragmented JPEG reassembled or MP4 `moov` rebuilt).
|
||||
fn replace(&mut self, f: Found) {
|
||||
self.found(f);
|
||||
}
|
||||
@@ -39,14 +39,14 @@ pub struct RangeState {
|
||||
pub next: u64,
|
||||
}
|
||||
|
||||
/// Estado serializável para pausar/retomar.
|
||||
/// Serializable scan state for pause/resume checkpoints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Checkpoint {
|
||||
pub fs_done: bool,
|
||||
pub ranges: Vec<RangeState>,
|
||||
pub next_id: u64,
|
||||
pub layout: Option<DiskLayout>,
|
||||
/// A varredura parou porque o dispositivo parou de responder (desconectado?).
|
||||
/// True if the scan halted because the source device stopped responding (disconnected).
|
||||
#[serde(default)]
|
||||
pub io_lost: bool,
|
||||
}
|
||||
@@ -93,7 +93,7 @@ pub fn run(
|
||||
layout.gaps.clear();
|
||||
layout.table = "membro de RAID".into();
|
||||
}
|
||||
// Volumes lógicos e outros volumes montados que não estão na tabela do disco.
|
||||
// Logical volumes and unlocked virtual volumes not present in the physical partition table.
|
||||
for p in &opts.extra_parts {
|
||||
if !layout.partitions.iter().any(|q| q.start == p.start) {
|
||||
let mut p = p.clone();
|
||||
@@ -103,7 +103,7 @@ pub fn run(
|
||||
}
|
||||
cp.layout = Some(layout.clone());
|
||||
|
||||
// Índices para fusão: primeiro offset -> (id, confiança), e extents de achados por metadados.
|
||||
// Merge indices: `first_offset -> (id, confidence)` and allocated extents of metadata findings.
|
||||
let mut starts: HashMap<u64, (u64, Confidence)> = HashMap::new();
|
||||
let mut fs_extents: Vec<Extent> = vec![];
|
||||
for f in existing {
|
||||
@@ -239,7 +239,7 @@ pub fn run(
|
||||
Outcome::Completed
|
||||
}
|
||||
|
||||
/// Arquivo que parece quebrado: tenta recompor depois de varrer o disco todo.
|
||||
/// Truncated/fragmented candidate queued for post-scan reassembly once all clusters are indexed.
|
||||
struct Pending {
|
||||
found: Found,
|
||||
hit_len: u64,
|
||||
@@ -256,16 +256,16 @@ struct Handler<'a> {
|
||||
total: u64,
|
||||
index: crate::defrag::ClusterIndex,
|
||||
pending: Vec<Pending>,
|
||||
/// Vídeos íntegros achados no carving (início, tamanho): fontes de SPS/PPS.
|
||||
/// Intact MP4 videos found during carving `(offset, size)`: donor sources for SPS/PPS.
|
||||
intact_mp4: Vec<(u64, u64)>,
|
||||
refs: Vec<crate::mp4rebuild::MediaConfig>,
|
||||
/// Extents de arquivos carved já emitidos (não podem servir de fragmento para outro).
|
||||
/// Byte extents of already-emitted carved files (excluded from candidate defrag fragments).
|
||||
carved: Vec<Extent>,
|
||||
}
|
||||
|
||||
impl Handler<'_> {
|
||||
fn rebuild_mp4(&mut self, p: Pending, off: u64) {
|
||||
// Referências: as informadas pelo usuário e os vídeos íntegros mais próximos no disco.
|
||||
// Donor references: user-supplied reference videos plus the closest intact videos on disk.
|
||||
let mut refs = self.refs.clone();
|
||||
let mut near = self.intact_mp4.clone();
|
||||
near.sort_by_key(|&(o, _)| o.abs_diff(off));
|
||||
@@ -316,7 +316,7 @@ impl Handler<'_> {
|
||||
}
|
||||
let mut used = self.fs_extents.clone();
|
||||
used.extend(self.carved.iter().copied());
|
||||
// O próprio começo do arquivo não conta como "ocupado" para ele mesmo.
|
||||
// Exclude the file's own leading prefix from the claimed-extents filter.
|
||||
used.retain(|e| !(e.offset == off));
|
||||
let pool = crate::defrag::Pool {
|
||||
index: &self.index,
|
||||
@@ -365,7 +365,7 @@ impl CarveEvents for Handler<'_> {
|
||||
return HitAction::Skip;
|
||||
}
|
||||
if overlaps(&self.fs_extents, off, 1) {
|
||||
return HitAction::Continue; // dentro de arquivo já listado (ex.: miniatura embutida)
|
||||
return HitAction::Continue; // embedded inside an already-listed file (e.g. EXIF thumbnail): skip duplicate
|
||||
}
|
||||
let mut hit = hit.clone();
|
||||
let mut skip_len = None;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Busca de texto bruta em todo o disco (inclusive espaço livre e não particionado),
|
||||
//! em UTF-8 e UTF-16LE, útil quando não há estrutura de arquivo para recuperar.
|
||||
//! Raw full-disk text search (including free and unpartitioned space)
|
||||
//! across UTF-8 and UTF-16LE encodings, useful when filesystem structures are gone.
|
||||
|
||||
use crate::source::Source;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -7,10 +7,10 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchOpts {
|
||||
pub terms: Vec<String>,
|
||||
/// Ignora maiúsculas/minúsculas (letras ASCII).
|
||||
/// Case-insensitive matching (ASCII letters).
|
||||
#[serde(default = "yes")]
|
||||
pub ignore_case: bool,
|
||||
/// Procura também a codificação UTF-16 (Windows, Office, bancos de dados).
|
||||
/// Also match UTF-16LE encoded strings (Windows, Office, SQLite, etc.).
|
||||
#[serde(default = "yes")]
|
||||
pub utf16: bool,
|
||||
#[serde(default = "default_max")]
|
||||
@@ -116,7 +116,7 @@ fn snippet(buf: &[u8], at: usize, len: usize, utf16: bool) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Varre `src` inteira. Retorna o total de ocorrências. `on_hit` pode devolver false para parar.
|
||||
/// Scans `src` from start to end. Returns total matches found; `on_hit` may return `false` to stop.
|
||||
pub fn search(
|
||||
src: &dyn Source,
|
||||
o: &SearchOpts,
|
||||
@@ -144,7 +144,7 @@ pub fn search(
|
||||
Ok(0) => break,
|
||||
Ok(n) => got += n,
|
||||
Err(_) => {
|
||||
// setor ruim: pula este trecho
|
||||
// unreadable chunk: skip and continue
|
||||
for b in &mut buf[got..want] {
|
||||
*b = 0;
|
||||
}
|
||||
@@ -155,7 +155,7 @@ pub fn search(
|
||||
if got == 0 {
|
||||
break;
|
||||
}
|
||||
let fresh = got.min(CHUNK); // início de ocorrências aceitas neste bloco
|
||||
let fresh = got.min(CHUNK); // start offsets of accepted hits within this chunk (prevents duplicate UTF-8/16 hits at same pos)
|
||||
let folded: Vec<u8>;
|
||||
let hay: &[u8] = if o.ignore_case {
|
||||
folded = buf[..got].to_ascii_lowercase();
|
||||
@@ -163,7 +163,7 @@ pub fn search(
|
||||
} else {
|
||||
&buf[..got]
|
||||
};
|
||||
let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset no bloco, índice do padrão)
|
||||
let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset_in_chunk, pattern_index)
|
||||
for (pi, p) in pats.iter().enumerate() {
|
||||
find_all(hay, p, |i| {
|
||||
if i < fresh {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Camada de serviço usada pela interface gráfica (Tauri e modo `guara ui`):
|
||||
//! jobs em segundo plano (varredura, recuperação, imagem) e despachante RPC.
|
||||
//! Background service layer backing the GUI (Tauri and `guara ui` mode):
|
||||
//! manages scan/recovery/imaging jobs, sessions, and JSON-RPC dispatch.
|
||||
|
||||
use crate::device;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -49,7 +49,7 @@ struct State {
|
||||
report: Option<Value>,
|
||||
started: Instant,
|
||||
finished_in: Option<f64>,
|
||||
/// Leitura de datas EXIF/dimensões em andamento: (feitos, total).
|
||||
/// Background EXIF/dimension enrichment progress: `(done, total)`.
|
||||
enrich: Option<(u64, u64)>,
|
||||
recoveries: Vec<recover::RecoverReport>,
|
||||
started_unix: Option<i64>,
|
||||
@@ -64,11 +64,11 @@ pub struct Job {
|
||||
cancel: AtomicBool,
|
||||
stop_requested: AtomicBool,
|
||||
src: Mutex<Option<Arc<dyn Source>>>,
|
||||
/// Caminho atualmente usado para ler a origem (pode mudar se o disco voltar com outro nome).
|
||||
/// Active device/image path used to read the source (may update if reattached under a new node).
|
||||
src_path: Mutex<String>,
|
||||
/// Impressão digital do disco (reconhecer o mesmo disco depois de reconectar).
|
||||
/// Source disk content fingerprint (used to recognize the drive after reconnection).
|
||||
fingerprint: Mutex<Option<String>>,
|
||||
/// Arquivo de sessão salvo automaticamente.
|
||||
/// Path to the autosaved session file.
|
||||
session_file: Mutex<Option<PathBuf>>,
|
||||
st: Mutex<State>,
|
||||
}
|
||||
@@ -213,7 +213,7 @@ impl Engine {
|
||||
job
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- varredura
|
||||
// ---------------------------------------------------------------- scan
|
||||
|
||||
pub fn start_scan(&self, path: &str, mut opts: ScanOptions) -> Result<String> {
|
||||
let raw = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
@@ -282,7 +282,7 @@ impl Engine {
|
||||
}
|
||||
s.checkpoint.as_ref().is_some_and(|c| c.io_lost)
|
||||
};
|
||||
// Sem origem, ou depois de uma desconexão: reabre o disco (mesmo com outro nome).
|
||||
// Source not yet open or previously disconnected: try to reopen (even under a new device name).
|
||||
if j.source().is_none() || io_lost {
|
||||
self.reconnect(&j).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
@@ -359,14 +359,14 @@ impl Engine {
|
||||
Ok(json!({ "total": total, "items": page }))
|
||||
}
|
||||
|
||||
/// Contagens para a navegação lateral (tipos, datas, pastas, câmeras).
|
||||
/// Sidebar navigation facet counts (categories, dates, folders, cameras).
|
||||
pub fn facets(&self, id: &str) -> Result<Value> {
|
||||
let j = self.job(id)?;
|
||||
let s = j.st.lock().unwrap();
|
||||
Ok(crate::explorer::facets(&s.found))
|
||||
}
|
||||
|
||||
/// Esquema de um banco SQLite achado, com o número de linhas de cada tabela.
|
||||
/// Schema of a recovered SQLite database, including row counts per table.
|
||||
pub fn db_info(&self, id: &str, file: u64) -> Result<Value> {
|
||||
let (f, src) = self.open_file(id, file)?;
|
||||
let fs = recover::FoundSource {
|
||||
@@ -383,7 +383,7 @@ impl Engine {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Uma página de linhas de uma tabela (ou os registros apagados prováveis dela).
|
||||
/// A page of live table rows (or carvable deleted records from free blocks).
|
||||
pub fn db_rows(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -415,13 +415,13 @@ impl Engine {
|
||||
Ok(serde_json::to_value(&page)?)
|
||||
}
|
||||
|
||||
/// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo.
|
||||
/// Detailed format metadata (EXIF, PDF, Office, video, etc.) for a recovered file.
|
||||
pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
|
||||
let (f, src) = self.open_file(id, file)?;
|
||||
Ok(crate::meta::extract(src.as_ref(), &f))
|
||||
}
|
||||
|
||||
/// Miniatura JPEG (cache em memória; decodificações limitadas em paralelo).
|
||||
/// Generates or fetches an in-memory cached JPEG thumbnail (with bounded concurrency).
|
||||
pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result<Arc<Vec<u8>>, u16> {
|
||||
let size = size.clamp(48, 640);
|
||||
let key = (job.to_string(), id, size);
|
||||
@@ -445,7 +445,7 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Achado + origem para leitura posicional (streaming de pré-visualização).
|
||||
/// Returns `(Found, Source)` for positional streaming preview.
|
||||
pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
|
||||
let j = self.job(id)?;
|
||||
let s = j.st.lock().unwrap();
|
||||
@@ -456,7 +456,7 @@ impl Engine {
|
||||
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recuperação
|
||||
// ---------------------------------------------------------------- recovery
|
||||
|
||||
pub fn start_recover(
|
||||
&self,
|
||||
@@ -575,13 +575,13 @@ impl Engine {
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- imagem
|
||||
// ---------------------------------------------------------------- disk imaging
|
||||
|
||||
pub fn start_image(&self, path: &str, dest: &str) -> Result<String> {
|
||||
self.start_image_as(path, dest, &image::ImageFormat::Raw)
|
||||
}
|
||||
|
||||
/// Cria a imagem em bruto ou em outro formato forense (E01 ou AFF4).
|
||||
/// Creates a raw, E01, or AFF4 forensic disk image.
|
||||
pub fn start_image_as(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -644,9 +644,9 @@ impl Engine {
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- exportação e relatório
|
||||
// ---------------------------------------------------------------- export and report
|
||||
|
||||
/// CSV dos achados que atendem ao filtro (ou todos). Devolve quantas linhas gravou.
|
||||
/// Exports matching findings (or all findings) to CSV. Returns the number of rows written.
|
||||
pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result<usize> {
|
||||
let j = self.job(id)?;
|
||||
let s = j.st.lock().unwrap();
|
||||
@@ -662,7 +662,7 @@ impl Engine {
|
||||
Ok(items.len())
|
||||
}
|
||||
|
||||
/// Relatório HTML de recuperação (identificação do caso, origem, achados, hashes).
|
||||
/// Generates an HTML or PDF forensic recovery report (case info, source, findings, hashes).
|
||||
pub fn export_report(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -714,7 +714,7 @@ impl Engine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- busca de texto e hash da origem
|
||||
// ---------------------------------------------------------------- text search and source hashing
|
||||
|
||||
pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result<String> {
|
||||
if opts.terms.iter().all(|t| t.trim().is_empty()) {
|
||||
@@ -776,7 +776,7 @@ impl Engine {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade).
|
||||
/// Computes MD5/SHA-1/SHA-256 across the entire source (integrity verification job).
|
||||
pub fn start_hash(&self, path: &str) -> Result<String> {
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
let job = self.register(
|
||||
@@ -833,7 +833,7 @@ impl Engine {
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões
|
||||
// ---------------------------------------------------------------- sessions
|
||||
|
||||
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
@@ -860,7 +860,7 @@ impl Engine {
|
||||
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
|
||||
))
|
||||
})?;
|
||||
// Clicar de novo numa sessão já aberta reaproveita o job em vez de criar outro igual.
|
||||
// Re-opening an already loaded session reuses its existing job ID.
|
||||
let want = Path::new(path);
|
||||
if let Some(j) = self
|
||||
.jobs
|
||||
@@ -899,8 +899,8 @@ impl Engine {
|
||||
Status::Paused
|
||||
};
|
||||
}
|
||||
// A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome);
|
||||
// sem a origem ainda dá para navegar, exportar e gerar relatório.
|
||||
// Always load the session metadata. Attempt to reconnect the source disk (even under a new path);
|
||||
// if the source is offline, browsing, filtering, CSV export, and reporting still work.
|
||||
match self.reconnect(&job) {
|
||||
Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!(
|
||||
"O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)."
|
||||
@@ -911,7 +911,7 @@ impl Engine {
|
||||
data.target
|
||||
)),
|
||||
}
|
||||
// continua salvando automaticamente no mesmo arquivo se a varredura for retomada
|
||||
// keep autosaving to the same file if the scan is resumed
|
||||
*job.session_file.lock().unwrap() = Some(PathBuf::from(path));
|
||||
crate::audit::record(
|
||||
"session.load",
|
||||
@@ -923,9 +923,9 @@ impl Engine {
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
/// Reabre a origem da sessão: tenta o caminho atual e o original e, se o disco voltou com
|
||||
/// outro nome, procura entre os dispositivos conectados o que tem o mesmo conteúdo.
|
||||
/// `Ok(Some(caminho))` quando achou o disco num caminho diferente do esperado.
|
||||
/// Reopens the session's source: tries the current and original paths first, then scans attached
|
||||
/// devices for a matching fingerprint if the drive reconnected under a new device node.
|
||||
/// Returns `Ok(Some(new_path))` when found at a different device path.
|
||||
fn reconnect(&self, j: &Arc<Job>) -> Result<Option<String>> {
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
@@ -970,8 +970,8 @@ impl Engine {
|
||||
))
|
||||
}
|
||||
|
||||
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||
/// Recusa uma origem que não seja o mesmo disco, a menos que `force`.
|
||||
/// Rebinds a loaded session to a user-selected source device or disk image.
|
||||
/// Rejects a source whose fingerprint/size mismatches the session unless `force` is set.
|
||||
pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
@@ -1010,7 +1010,7 @@ impl Engine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discos conectados que podem ser a origem desta sessão (mesmo tamanho primeiro).
|
||||
/// Lists attached disks that could match this session's source (same-size candidates first).
|
||||
pub fn candidates(&self, id: &str) -> Result<Value> {
|
||||
let j = self.job(id)?;
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
@@ -1042,9 +1042,9 @@ impl Engine {
|
||||
Ok(json!(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões automáticas
|
||||
// ---------------------------------------------------------------- autosaved sessions
|
||||
|
||||
/// Sessões salvas automaticamente (mais recentes primeiro).
|
||||
/// Lists autosaved sessions (most recent first).
|
||||
pub fn sessions_list(&self) -> Value {
|
||||
let dir = sessions_dir();
|
||||
let mut items: Vec<Value> = vec![];
|
||||
@@ -1071,7 +1071,7 @@ impl Engine {
|
||||
json!(items)
|
||||
}
|
||||
|
||||
/// Apaga uma sessão automática (e a miniatura de metadados).
|
||||
/// Deletes an autosaved session file (and its `.meta.json` sidecar).
|
||||
pub fn session_delete(&self, path: &str) -> Result<()> {
|
||||
let p = Path::new(path);
|
||||
if p.parent() != Some(sessions_dir().as_path()) {
|
||||
@@ -1162,7 +1162,7 @@ impl Engine {
|
||||
"health" => json!(crate::health::check(&s("path")?)),
|
||||
"open_url" => {
|
||||
let u = s("url")?;
|
||||
// só mapas do OpenStreetMap: evita abrir endereços arbitrários a partir de metadados
|
||||
// restrict to OpenStreetMap URLs: never open arbitrary URLs derived from untrusted file metadata
|
||||
if !u.starts_with("https://www.openstreetmap.org/") {
|
||||
return Err(Error::msg("endereço não permitido"));
|
||||
}
|
||||
@@ -1303,9 +1303,26 @@ impl Engine {
|
||||
"version" => {
|
||||
json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS })
|
||||
}
|
||||
"update_check" => {
|
||||
let st = self.update_check()?;
|
||||
json!(st)
|
||||
}
|
||||
"update_apply" => {
|
||||
let version = self.update_apply()?;
|
||||
json!({ "version": version })
|
||||
}
|
||||
other => return Err(Error::msg(format!("método desconhecido: {other}"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_check(&self) -> Result<crate::update::UpdateStatus> {
|
||||
crate::update::check(crate::VERSION)
|
||||
}
|
||||
|
||||
pub fn update_apply(&self) -> Result<String> {
|
||||
let exe = std::env::current_exe()?;
|
||||
crate::update::apply(&exe)
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::explorer::Query;
|
||||
@@ -1332,7 +1349,7 @@ struct SessionFile {
|
||||
|
||||
use crate::explorer::cat_key;
|
||||
|
||||
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
|
||||
/// Returns HTTP 503 when the session source is disconnected, or 404 otherwise.
|
||||
fn status_of(e: Error) -> u16 {
|
||||
if e.to_string().contains("não está disponível") {
|
||||
503
|
||||
@@ -1356,7 +1373,7 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
}
|
||||
|
||||
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
// Salva a sessão sozinho enquanto a varredura roda (e ao terminar/pausar).
|
||||
// Periodically autosaves the session while scanning (and upon completion or pause).
|
||||
let saver = job.clone();
|
||||
std::thread::spawn(move || autosave_loop(saver));
|
||||
std::thread::spawn(move || {
|
||||
@@ -1420,8 +1437,8 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Lê EXIF/dimensões das imagens achadas (em segundo plano) para permitir
|
||||
/// navegar por data, câmera e resolução.
|
||||
/// Background worker that extracts EXIF dates, camera models, GPS, and image dimensions
|
||||
/// so findings can be browsed by date, camera, and resolution.
|
||||
fn spawn_enrich(job: Arc<Job>) {
|
||||
std::thread::spawn(move || {
|
||||
let Some(src) = job.source() else { return };
|
||||
@@ -1487,8 +1504,8 @@ pub fn base64(data: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar
|
||||
/// script (HTML/SVG/XML) saem como texto puro.
|
||||
/// Returns a safe MIME type for preview streaming. Formats capable of executing
|
||||
/// scripts (HTML/SVG/XML) are served as `text/plain`.
|
||||
pub fn mime_for(ext: &str) -> &'static str {
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
@@ -1513,13 +1530,13 @@ pub fn mime_for(ext: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP
|
||||
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri.
|
||||
/// Byte slice of a recovered file, ready to answer an HTTP `Range` request
|
||||
/// in both `guara ui` mode and the Tauri custom protocol handler.
|
||||
pub struct FileSlice {
|
||||
found: Found,
|
||||
src: Arc<dyn Source>,
|
||||
pub start: u64,
|
||||
/// Inclusivo.
|
||||
/// Inclusive end byte offset.
|
||||
pub end: u64,
|
||||
pub total: u64,
|
||||
pub partial: bool,
|
||||
@@ -1549,7 +1566,7 @@ impl FileSlice {
|
||||
("Accept-Ranges", "bytes".into()),
|
||||
("Cache-Control", "no-store".into()),
|
||||
("X-Content-Type-Options", "nosniff".into()),
|
||||
// Conteúdo recuperado é não confiável: nunca executa script nem carrega nada.
|
||||
// Recovered content is untrusted: forbid script execution and external resource loading via CSP.
|
||||
(
|
||||
"Content-Security-Policy",
|
||||
"sandbox; default-src 'none'".into(),
|
||||
@@ -1569,7 +1586,7 @@ impl FileSlice {
|
||||
}
|
||||
h
|
||||
}
|
||||
/// Lê o trecho inteiro em memória (limitado a `cap` bytes).
|
||||
/// Reads the requested byte range into memory (capped at `cap` bytes).
|
||||
pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
|
||||
let n = self.len().min(cap) as usize;
|
||||
let mut out = vec![0u8; n];
|
||||
@@ -1621,7 +1638,7 @@ impl std::io::Read for FileSliceReader {
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// Resolve `/file/<job>/<id>` + cabeçalho Range. `Err(status)` em caso de falha.
|
||||
/// Resolves `/file/<job>/<id>` with optional `Range` header. Returns `Err(http_status)` on failure.
|
||||
pub fn file_slice(
|
||||
&self,
|
||||
job: &str,
|
||||
@@ -1663,9 +1680,9 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões automáticas
|
||||
// ---------------------------------------------------------------- autosaved sessions
|
||||
|
||||
/// Reaplica ao disco reaberto a montagem (volumes desbloqueados, RAID) da sessão.
|
||||
/// Re-applies the session's virtual disk assembly (unlocked volumes, RAID, LVM) to a reopened source.
|
||||
fn finish_source(j: &Job, raw: Arc<dyn Source>) -> Arc<dyn Source> {
|
||||
match crate::assemble::assemble_source(raw.clone(), &j.opts.assembly) {
|
||||
Ok(a) => a.source,
|
||||
@@ -1700,7 +1717,7 @@ fn new_session_path(target: &str) -> PathBuf {
|
||||
|
||||
fn snapshot(j: &Job) -> SessionFile {
|
||||
let s = j.st.lock().unwrap();
|
||||
// Senhas e chaves nunca vão para o arquivo de sessão (só as chaves de volume, se o usuário pediu).
|
||||
// Passwords and raw keyfiles are stripped before saving (only derived volume keys are kept if opted in).
|
||||
let mut options = j.opts.clone();
|
||||
options.assembly.secrets.clear();
|
||||
if !options.assembly.remember_keys {
|
||||
@@ -1722,14 +1739,14 @@ fn snapshot(j: &Job) -> SessionFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grava a sessão de forma atômica (arquivo temporário + renomear), com o dono certo.
|
||||
/// Atomically writes the session file (temp file + rename) owned by the invoking user.
|
||||
fn write_session(j: &Job, path: &Path) -> Result<()> {
|
||||
let data = snapshot(j);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
crate::owner::give_to_user(path);
|
||||
// metadados pequenos para listar as sessões recentes sem ler o arquivo inteiro
|
||||
// lightweight `.meta.json` sidecar so recent sessions can be listed without parsing full snapshots
|
||||
let status = if data.done {
|
||||
"concluída"
|
||||
} else if data.checkpoint.is_some() {
|
||||
@@ -1749,7 +1766,7 @@ fn write_session(j: &Job, path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Salva de tempos em tempos (o intervalo cresce se a sessão for grande) e no fim.
|
||||
/// Autosaves periodically (backing off as snapshot write time grows) and once on completion.
|
||||
fn autosave_loop(job: Arc<Job>) {
|
||||
let mut last = Instant::now();
|
||||
let mut interval = std::time::Duration::from_secs(45);
|
||||
@@ -1766,7 +1783,7 @@ fn autosave_loop(job: Arc<Job>) {
|
||||
if write_session(&job, &path).is_err() {
|
||||
return;
|
||||
}
|
||||
// nunca gasta mais de ~10% do tempo salvando
|
||||
// spend at most ~10% of wall-clock time writing snapshots
|
||||
interval = (t0.elapsed() * 10).max(std::time::Duration::from_secs(45));
|
||||
last = Instant::now();
|
||||
if !running {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o
|
||||
//! reiniciado como administrador.
|
||||
//! User preferences (theme, language, notifications, etc.), shared between normal and
|
||||
//! elevated (root/administrator) instances.
|
||||
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -7,24 +7,24 @@ use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// "auto", "light" ou "dark".
|
||||
/// Theme mode: `"auto"`, `"light"`, or `"dark"`.
|
||||
#[serde(default = "auto")]
|
||||
pub theme: String,
|
||||
/// Idioma da interface: "auto", "pt" ou "en".
|
||||
/// UI language preference: `"auto"`, `"pt"`, or `"en"`.
|
||||
#[serde(default = "auto")]
|
||||
pub lang: String,
|
||||
/// Idioma que a interface resolveu de fato ("pt" ou "en"), para as
|
||||
/// notificações do sistema e a saída do terminal acompanharem a tela.
|
||||
/// Effective language resolved by the UI (`"pt"` or `"en"`), so OS desktop
|
||||
/// notifications and CLI messages match the active UI language.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_lang: Option<String>,
|
||||
/// Notificação do sistema quando uma tarefa longa termina.
|
||||
/// Send a desktop notification when a long-running task finishes.
|
||||
#[serde(default = "yes")]
|
||||
pub notify: bool,
|
||||
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
|
||||
/// Show OpenStreetMap tiles for geotagged photos (fetches tiles over the network).
|
||||
#[serde(default)]
|
||||
pub maps: bool,
|
||||
/// Procurar versão nova no Gitea ao abrir o app (uma consulta ao `latest.json` da release,
|
||||
/// sem enviar nada além do que qualquer navegador envia).
|
||||
/// Check Gitea Releases for updates on startup (one query to the releases API,
|
||||
/// without sending any telemetry).
|
||||
#[serde(default = "yes")]
|
||||
pub updates: bool,
|
||||
}
|
||||
@@ -50,8 +50,8 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root
|
||||
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios).
|
||||
/// Configuration directory of the *real* invoking user, even when running as root
|
||||
/// via `sudo`/`pkexec` (so theme and language settings persist across elevation).
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
|
||||
if !d.is_empty() {
|
||||
@@ -111,8 +111,8 @@ pub fn load() -> Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Idioma efetivo para textos gerados fora da interface (notificações e CLI).
|
||||
/// Usa o que a interface informou; sem isso, a preferência e o ambiente.
|
||||
/// Effective language for strings generated outside the webview (OS notifications and CLI).
|
||||
/// Uses the UI-resolved language when available, falling back to saved preference and `LANG`.
|
||||
pub fn ui_lang() -> String {
|
||||
let s = load();
|
||||
if let Some(l) = s.ui_lang.as_deref() {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
//! Assinatura digital (Ed25519) de manifestos, relatórios e qualquer outro arquivo, e conferência
|
||||
//! de uma recuperação inteira.
|
||||
//! Ed25519 digital signatures for manifests, reports, and arbitrary files, plus full recovery
|
||||
//! folder verification.
|
||||
//!
|
||||
//! A assinatura fica ao lado do arquivo, em `<arquivo>.assinatura.json`, e cobre o SHA-256 do
|
||||
//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo,
|
||||
//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso
|
||||
//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de
|
||||
//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou
|
||||
//! registrou fora da máquina, passando-a em `trusted_fingerprint`.
|
||||
//! Signatures are stored alongside the file in `<file>.assinatura.json` and cover the content
|
||||
//! SHA-256, byte size, filename, timestamp, and signer name. Verification recomputes the file
|
||||
//! hash, reconstructs the canonical signed message, and checks the Ed25519 signature against the
|
||||
//! embedded public key. To bind the signature to a specific examiner, compare `fingerprint`
|
||||
//! against the examiner's published fingerprint via `trusted_fingerprint`.
|
||||
//!
|
||||
//! A hora é a do relógio da máquina de quem assinou: não é um carimbo de tempo de terceiros
|
||||
//! (RFC 3161) e, sozinha, não prova *quando* a assinatura foi feita.
|
||||
//!
|
||||
//! A chave privada fica em `assinatura.key` na pasta de configuração (ou onde
|
||||
//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem
|
||||
//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá.
|
||||
//! Note: `signed_at_utc` comes from the signing machine's local clock, not an RFC 3161 trusted
|
||||
//! timestamp authority.
|
||||
//!
|
||||
//! The private key is stored in `assinatura.key` inside the config directory (or at
|
||||
//! `GUARA_SIGNING_KEY`) with `0600` permissions on Unix. For higher security, store the key on
|
||||
//! an encrypted removable volume and point `GUARA_SIGNING_KEY` to it.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature};
|
||||
@@ -41,7 +41,7 @@ fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Impressão digital da chave pública: os 10 primeiros bytes do SHA-256, em grupos de 4 hex.
|
||||
/// Public key fingerprint: first 10 bytes of SHA-256(pubkey), formatted as 5 groups of 4 hex chars.
|
||||
pub fn fingerprint(public_key: &[u8]) -> String {
|
||||
let h = Sha256::digest(public_key);
|
||||
let h = hex(&h[..10]);
|
||||
@@ -52,7 +52,7 @@ pub fn fingerprint(public_key: &[u8]) -> String {
|
||||
.join(":")
|
||||
}
|
||||
|
||||
/// Compara impressões digitais ignorando maiúsculas, `:` e espaços.
|
||||
/// Compares two key fingerprints ignoring case, colons, hyphens, and whitespace.
|
||||
fn same_fingerprint(a: &str, b: &str) -> bool {
|
||||
let n = |s: &str| {
|
||||
s.chars()
|
||||
@@ -64,7 +64,7 @@ fn same_fingerprint(a: &str, b: &str) -> bool {
|
||||
!a.is_empty() && a == b
|
||||
}
|
||||
|
||||
/// Par de chaves de assinatura.
|
||||
/// Ed25519 signing keypair.
|
||||
pub struct SigningKey {
|
||||
kp: KeyPair,
|
||||
}
|
||||
@@ -108,7 +108,7 @@ impl SigningKey {
|
||||
})
|
||||
}
|
||||
|
||||
/// Grava a chave privada (0600 no Unix) e, ao lado, a pública (`.pub`). Recusa sobrescrever.
|
||||
/// Writes the private key (`0600` on Unix) and companion `.pub` file. Refuses to overwrite existing keys.
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(dir) = path.parent() {
|
||||
@@ -148,7 +148,7 @@ fn pub_path(key: &Path) -> PathBuf {
|
||||
key.with_extension("pub")
|
||||
}
|
||||
|
||||
/// `GUARA_SIGNING_KEY` ou `assinatura.key` na pasta de configuração.
|
||||
/// Resolves `GUARA_SIGNING_KEY` or `assinatura.key` in the configuration directory.
|
||||
pub fn default_key_path() -> PathBuf {
|
||||
match std::env::var("GUARA_SIGNING_KEY") {
|
||||
Ok(p) if !p.is_empty() => PathBuf::from(p),
|
||||
@@ -156,7 +156,7 @@ pub fn default_key_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arquivo que acompanha `path` com a assinatura.
|
||||
/// Companion `<path>.assinatura.json` sidecar path.
|
||||
pub fn signature_path(path: &Path) -> PathBuf {
|
||||
let mut n = path.as_os_str().to_os_string();
|
||||
n.push(".assinatura.json");
|
||||
@@ -190,7 +190,7 @@ fn message(f: &SignatureFile) -> Vec<u8> {
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// SHA-256 e tamanho de um arquivo, lido em blocos.
|
||||
/// Computes the hex SHA-256 and byte length of a file.
|
||||
pub fn hash_file(path: &Path) -> Result<(String, u64)> {
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut h = Sha256::new();
|
||||
@@ -215,7 +215,7 @@ fn now_iso() -> String {
|
||||
crate::audit::utc_iso(secs)
|
||||
}
|
||||
|
||||
/// Assina `path` e grava `<path>.assinatura.json`. Devolve o caminho da assinatura.
|
||||
/// Signs `path` and writes `<path>.assinatura.json`. Returns the signature file path.
|
||||
pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
|
||||
let (sha256, size) = hash_file(path)?;
|
||||
let mut sig = SignatureFile {
|
||||
@@ -242,17 +242,17 @@ pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Resultado de conferir uma assinatura.
|
||||
/// Result of verifying a detached `.assinatura.json` signature.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Check {
|
||||
/// O arquivo é o mesmo e a assinatura vale para a chave que vem nela.
|
||||
/// True if the file hash matches and the Ed25519 signature is valid for the embedded public key.
|
||||
pub valid: bool,
|
||||
pub file_matches: bool,
|
||||
pub signature_valid: bool,
|
||||
pub fingerprint: String,
|
||||
pub signed_at: String,
|
||||
pub signer: Option<String>,
|
||||
/// `Some(true/false)` quando uma impressão digital de confiança foi informada.
|
||||
/// `Some(true/false)` when a `trusted_fingerprint` was provided for comparison.
|
||||
pub trusted: Option<bool>,
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
@@ -320,8 +320,8 @@ pub fn verify_file(
|
||||
})
|
||||
}
|
||||
|
||||
/// Confere uma pasta de recuperação: a assinatura do manifesto (se houver) e o hash de cada
|
||||
/// arquivo contra o que o manifesto registrou.
|
||||
/// Verifies a recovery directory: checks the manifest signature (if present) and verifies every
|
||||
/// recovered file's SHA-256 against the manifest.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecoveryCheck {
|
||||
pub manifest_signature: Option<Check>,
|
||||
@@ -329,7 +329,7 @@ pub struct RecoveryCheck {
|
||||
pub ok: usize,
|
||||
pub missing: Vec<String>,
|
||||
pub changed: Vec<String>,
|
||||
/// `true` quando tudo confere (e, havendo assinatura, ela vale).
|
||||
/// `true` when all recovered files match their manifest hashes (and the manifest signature is valid if present).
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<
|
||||
let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else {
|
||||
continue;
|
||||
};
|
||||
// o manifesto é dado de entrada: não deixa sair da pasta
|
||||
// manifest paths are untrusted input: reject path traversal outside the recovery folder
|
||||
if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() {
|
||||
changed.push(format!("{rel} (caminho fora da pasta)"));
|
||||
continue;
|
||||
@@ -380,8 +380,8 @@ pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<
|
||||
})
|
||||
}
|
||||
|
||||
/// Assina `path` se existir uma chave configurada; nunca interrompe quem chamou. Registra na
|
||||
/// trilha de auditoria.
|
||||
/// Auto-signs `path` if a signing key exists; never aborts the caller on error. Logs to the
|
||||
/// audit trail.
|
||||
pub fn sign_if_configured(path: &Path) {
|
||||
let kp = default_key_path();
|
||||
if !kp.is_file() {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Assinaturas de formatos e validação estrutural para o carving.
|
||||
//! File format signatures and structural validators for file carving.
|
||||
//!
|
||||
//! Cada detector lê a estrutura real do arquivo (cabeçalhos, blocos, rodapés)
|
||||
//! para determinar o tamanho exato e o nível de confiança, em vez de apenas
|
||||
//! procurar bytes mágicos.
|
||||
//! Each detector parses the internal container structure (headers, chunks, footers)
|
||||
//! to determine the exact file size and confidence level rather than relying on
|
||||
//! magic bytes alone.
|
||||
|
||||
use crate::source::{read_vec, Source};
|
||||
use crate::types::{Category, Confidence};
|
||||
|
||||
/// Resultado de um detector: arquivo reconhecido a partir de `offset`.
|
||||
/// Output of a format detector: a file recognized starting at `offset`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hit {
|
||||
pub len: u64,
|
||||
@@ -37,8 +37,8 @@ pub fn category_for_ext(ext: &str) -> Category {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica se o início do conteúdo é coerente com a extensão.
|
||||
/// `None` = extensão sem assinatura conhecida (não dá para julgar).
|
||||
/// Checks whether the leading bytes `head` match the expected magic for `ext`.
|
||||
/// Returns `None` if `ext` has no known magic signature.
|
||||
pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
let starts = |m: &[u8]| head.starts_with(m);
|
||||
@@ -86,8 +86,8 @@ pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Tabela de primeiros bytes -> detectores candidatos.
|
||||
/// O carving consulta `candidates(first_byte)` e chama `detect`.
|
||||
/// Dispatch table mapping leading byte -> candidate format detectors.
|
||||
/// The carver queries `candidates(first_byte)` and invokes `detect`.
|
||||
pub fn might_start(b0: u8) -> bool {
|
||||
crate::formats::might_start(b0)
|
||||
|| matches!(
|
||||
@@ -111,8 +111,8 @@ pub fn might_start(b0: u8) -> bool {
|
||||
|
||||
const MAX_HEADER: usize = 64 * 1024;
|
||||
|
||||
/// Tenta reconhecer um arquivo que começa em `off`.
|
||||
/// `head` já contém os primeiros bytes lidos em `off` (pelo menos 16, se existirem).
|
||||
/// Attempts to recognize a file starting at `off`.
|
||||
/// `head` contains the leading bytes already read at `off` (at least 16 when available).
|
||||
pub fn detect(src: &dyn Source, off: u64, head: &[u8], max_size: u64) -> Option<Hit> {
|
||||
if head.len() < 12 {
|
||||
return None;
|
||||
@@ -204,7 +204,7 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
if !saw_sof {
|
||||
return None;
|
||||
}
|
||||
// Dados entropy-coded: procurar EOI (FF D9) ignorando FF00 e RSTn.
|
||||
// Entropy-coded scan data: scan for EOI (`FF D9`), skipping `FF 00` byte stuffing and `RSTn`.
|
||||
let mut pos = off + start as u64;
|
||||
let limit = off + max.min(512 * 1024 * 1024);
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
@@ -230,7 +230,7 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
pos += n as u64;
|
||||
}
|
||||
// Sem EOI: imagem truncada/sobrescrita; ainda útil, mas incerta.
|
||||
// Missing EOI: truncated or overwritten JPEG; still emit with Low confidence.
|
||||
Some(Hit {
|
||||
len: (pos - off).min(4 * 1024 * 1024),
|
||||
ext: "jpg",
|
||||
@@ -252,8 +252,8 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
||||
let ty = &c[4..8];
|
||||
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do
|
||||
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante.
|
||||
// Valid PNG chunk: 4 ASCII letters with uppercase 3rd letter (reserved bit), plausible size within
|
||||
// the source. Prevents random ASCII text after a truncated PNG from being read as a ~1.9 GB chunk.
|
||||
let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
|
||||
&& ty[2].is_ascii_uppercase()
|
||||
&& len <= 128 * 1024 * 1024
|
||||
@@ -278,7 +278,7 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sem IEND: só vale como imagem truncada se já tem dados e é de tamanho razoável.
|
||||
// Missing `IEND`: only keep as a truncated PNG if at least one `IDAT` was seen and size is reasonable.
|
||||
if first || !saw_idat || p > 64 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
@@ -388,7 +388,7 @@ fn pdf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let mut i = 0;
|
||||
while let Some(f) = find(&hay[i..], b"%%EOF") {
|
||||
let end = base + (i + f) as u64 + 5;
|
||||
// Continua se logo após o %%EOF vier uma atualização incremental.
|
||||
// Continue scanning if `%%EOF` is followed by an incremental PDF update.
|
||||
let tail = read_vec(src, end, 32).ok()?;
|
||||
let rest: Vec<u8> = tail
|
||||
.iter()
|
||||
@@ -428,7 +428,7 @@ fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ZIP & derivados
|
||||
// ---------------------------------------------------------------- ZIP & derivatives
|
||||
|
||||
fn zip(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let limit = max.min(4 << 30);
|
||||
@@ -508,7 +508,7 @@ fn classify_zip(src: &dyn Source, start: u64, names: &[String]) -> &'static str
|
||||
} else if has("ppt/") {
|
||||
"pptx"
|
||||
} else if names.first().map(|n| n == "mimetype").unwrap_or(false) {
|
||||
// O primeiro item (sem compressão) traz o tipo MIME logo após o cabeçalho.
|
||||
// An uncompressed `mimetype` entry at the start identifies ODF/EPUB containers.
|
||||
let head = read_vec(src, start, 256).unwrap_or_default();
|
||||
let s = String::from_utf8_lossy(&head);
|
||||
if s.contains("opendocument.text") {
|
||||
@@ -574,7 +574,7 @@ fn isobmff(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
size = u64::from_be_bytes(b[8..16].try_into().unwrap());
|
||||
} else if size == 0 {
|
||||
// Até o fim do arquivo: tamanho desconhecido.
|
||||
// Box extends to EOF: exact size unknown.
|
||||
size = total - p;
|
||||
}
|
||||
if size < 8 || p + size > max {
|
||||
@@ -705,7 +705,7 @@ pub fn crc32(data: &[u8]) -> u32 {
|
||||
!crc
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- OLE2 (doc/xls/ppt legado)
|
||||
// ---------------------------------------------------------------- OLE2 (legacy doc/xls/ppt)
|
||||
|
||||
fn ole(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let hdr = read_vec(src, off, 512).ok()?;
|
||||
@@ -782,7 +782,7 @@ fn mp3(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
if frames < 20 {
|
||||
return None;
|
||||
}
|
||||
// Possível tag ID3v1 de 128 bytes ao final.
|
||||
// Include optional trailing 128-byte ID3v1 tag.
|
||||
if let Ok(t) = read_vec(src, off + p, 3) {
|
||||
if t == b"TAG" {
|
||||
p += 128;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
//! Acesso somente leitura a discos, partições e imagens.
|
||||
//! Read-only positional access to disks, partitions, and forensic images.
|
||||
//!
|
||||
//! Nenhum tipo deste módulo expõe escrita: a origem nunca é modificada.
|
||||
//! No type in this module exposes write operations: the source is never modified.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Origem de bytes legível por posição absoluta.
|
||||
/// Read-only byte source addressable by absolute 64-bit byte offset.
|
||||
pub trait Source: Send + Sync {
|
||||
fn size(&self) -> u64;
|
||||
/// Lê até `buf.len()` bytes a partir de `offset`. Retorna 0 no fim.
|
||||
/// Reads up to `buf.len()` bytes starting at `offset`. Returns `0` at EOF.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize>;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ impl<T: Source + ?Sized> Source for Arc<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lê exatamente `buf.len()` bytes, ou falha com `UnexpectedEof`.
|
||||
/// Reads exactly `buf.len()` bytes at `offset`, or fails with `UnexpectedEof`.
|
||||
pub fn read_exact_at(src: &dyn Source, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
|
||||
while !buf.is_empty() {
|
||||
let n = src.read_at(offset, buf)?;
|
||||
@@ -39,7 +39,7 @@ pub fn read_exact_at(src: &dyn Source, mut offset: u64, mut buf: &mut [u8]) -> i
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lê o máximo possível (pode devolver menos que `len` no fim da origem).
|
||||
/// Reads up to `len` bytes into a `Vec<u8>` (may return fewer bytes near EOF).
|
||||
pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut v = vec![0u8; len];
|
||||
let mut got = 0;
|
||||
@@ -54,11 +54,11 @@ pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Arquivo de imagem ou dispositivo de blocos aberto somente para leitura.
|
||||
/// Image file or raw block device opened strictly read-only.
|
||||
pub struct FileSource {
|
||||
file: File,
|
||||
size: u64,
|
||||
/// Dispositivos brutos (Windows/macOS) exigem leituras alinhadas ao setor.
|
||||
/// Raw block devices on Windows/macOS require sector-aligned reads.
|
||||
align: u64,
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Source for FileSource {
|
||||
if self.align <= 1 {
|
||||
return self.raw_read(offset, &mut buf[..want]);
|
||||
}
|
||||
// Leitura alinhada para dispositivos brutos.
|
||||
// Bounce buffer for sector-aligned reads on raw devices.
|
||||
let a = self.align;
|
||||
let start = offset / a * a;
|
||||
let end = ((offset + want as u64).div_ceil(a) * a).min(self.size.div_ceil(a) * a);
|
||||
@@ -128,7 +128,7 @@ impl Source for FileSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Janela [start, start+len) de outra origem (uma partição, por exemplo).
|
||||
/// Sub-window `[start, start + len)` of another `Source` (e.g. a partition).
|
||||
pub struct SubSource {
|
||||
inner: Arc<dyn Source>,
|
||||
start: u64,
|
||||
@@ -155,7 +155,7 @@ impl Source for SubSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Origem em memória (testes e dados pequenos).
|
||||
/// In-memory byte source (for tests and small buffers).
|
||||
pub struct MemSource(pub Vec<u8>);
|
||||
|
||||
impl Source for MemSource {
|
||||
@@ -195,7 +195,7 @@ fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
|
||||
#[cfg(windows)]
|
||||
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
// FILE_SHARE_READ | FILE_SHARE_WRITE: não bloqueia outros processos.
|
||||
// `FILE_SHARE_READ | FILE_SHARE_WRITE`: do not lock out other processes.
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(0x1 | 0x2)
|
||||
@@ -211,7 +211,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
|
||||
const DKIOCGETBLOCKCOUNT: libc::c_ulong = 0x4008_6419;
|
||||
let fd = file.as_raw_fd();
|
||||
let (mut bs, mut bc) = (0u32, 0u64);
|
||||
// SAFETY: ioctls de leitura com ponteiros para variáveis locais válidas.
|
||||
// SAFETY: read-only size query ioctls with pointers to valid stack variables.
|
||||
let ok = unsafe {
|
||||
libc::ioctl(fd, DKIOCGETBLOCKSIZE, &mut bs) == 0
|
||||
&& libc::ioctl(fd, DKIOCGETBLOCKCOUNT, &mut bc) == 0
|
||||
@@ -225,7 +225,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
|
||||
f.seek(SeekFrom::End(0))
|
||||
}
|
||||
|
||||
/// Abre um dispositivo Windows (`\\.\PhysicalDriveN`) e obtém o tamanho.
|
||||
/// Opens a Windows raw device (`\\.\PhysicalDriveN`) read-only and queries its byte size.
|
||||
#[cfg(windows)]
|
||||
pub fn open_windows_device(path: &Path) -> io::Result<FileSource> {
|
||||
let file = open_readonly(path, true)?;
|
||||
@@ -244,7 +244,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
|
||||
use windows_sys::Win32::System::IO::DeviceIoControl;
|
||||
let mut info = GET_LENGTH_INFORMATION { Length: 0 };
|
||||
let mut ret = 0u32;
|
||||
// SAFETY: buffer de saída válido com o tamanho exato da estrutura.
|
||||
// SAFETY: valid output buffer matching the exact struct layout.
|
||||
let ok = unsafe {
|
||||
DeviceIoControl(
|
||||
file.as_raw_handle() as _,
|
||||
@@ -263,7 +263,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
|
||||
Ok(info.Length as u64)
|
||||
}
|
||||
|
||||
/// Abre um caminho (imagem ou dispositivo) como origem somente leitura.
|
||||
/// Opens a path (raw disk, partition, or forensic/virtual disk image) as a read-only `Source`.
|
||||
pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -273,7 +273,7 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||
}
|
||||
}
|
||||
let file = FileSource::open(path)?;
|
||||
// Imagens forenses EWF (E01/Ex01) são abertas como o disco que contêm.
|
||||
// Forensic containers (EWF, AFF4) and virtual disks (QCOW2, VMDK, VHD, VHDX, DMG) are opened transparently.
|
||||
let mut head = [0u8; 96];
|
||||
let got = file.read_at(0, &mut head).unwrap_or(0);
|
||||
if got >= 4 && head.starts_with(b"PK") && crate::aff4::is_aff4(&head[..got]) {
|
||||
@@ -295,9 +295,9 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||
Ok(Arc::new(FileSource::open(path)?))
|
||||
}
|
||||
|
||||
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
|
||||
/// (tabela de partições, cabeçalhos e backup). Serve para reconhecer o mesmo disco
|
||||
/// quando ele volta com outro nome (`/dev/sda` virando `/dev/sdb`).
|
||||
/// Content fingerprint of a source disk: SHA-256 of its byte size plus the first and last MiB
|
||||
/// (partition tables, headers, and backup GPT). Used to recognize the same physical drive
|
||||
/// if it is unplugged and reattached under a different device name (`/dev/sda` -> `/dev/sdb`).
|
||||
pub fn fingerprint(src: &dyn Source) -> Option<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Leitor de bancos SQLite feito para arquivos recuperados.
|
||||
//! Standalone SQLite B-tree reader tailored for recovered databases.
|
||||
//!
|
||||
//! Lê as páginas B-tree diretamente (sem a biblioteca do SQLite), então funciona
|
||||
//! em bancos danificados ou truncados, para os quais a biblioteca oficial desiste.
|
||||
//! Também procura **registros apagados** nas áreas livres das páginas, onde o
|
||||
//! SQLite deixa o conteúdo antigo até que outro dado o sobrescreva.
|
||||
//! Parses B-tree pages directly (without linking `libsqlite3`), so it works on
|
||||
//! damaged or truncated database files that the official library rejects.
|
||||
//! Also carves **deleted records** from page freeblocks and unallocated cell areas
|
||||
//! where SQLite leaves unlinked row payloads until overwritten.
|
||||
|
||||
use crate::source::Source;
|
||||
use serde::Serialize;
|
||||
@@ -18,7 +18,7 @@ pub enum Cell {
|
||||
Int(i64),
|
||||
Real(f64),
|
||||
Text(String),
|
||||
/// Tamanho e os primeiros bytes (hex) para pré-visualizar; `mime` se reconhecido.
|
||||
/// Byte length and leading hex preview of a BLOB, plus `mime` when recognized.
|
||||
Blob {
|
||||
len: usize,
|
||||
head: String,
|
||||
@@ -41,7 +41,7 @@ pub struct DbTable {
|
||||
pub columns: Vec<DbColumn>,
|
||||
pub without_rowid: bool,
|
||||
pub virtual_table: bool,
|
||||
/// Coluna `INTEGER PRIMARY KEY` (alias do rowid), se houver.
|
||||
/// Index of the `INTEGER PRIMARY KEY` column (`rowid` alias), if any.
|
||||
#[serde(skip)]
|
||||
pub rowid_alias: Option<usize>,
|
||||
}
|
||||
@@ -83,7 +83,7 @@ pub struct Db<'a> {
|
||||
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
// ------------------------------------------------------------------ utilidades
|
||||
// ------------------------------------------------------------------ utilities
|
||||
|
||||
fn varint(b: &[u8]) -> Option<(i64, usize)> {
|
||||
let mut v: u64 = 0;
|
||||
@@ -158,7 +158,7 @@ fn decode_text(b: &[u8], enc: u32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodifica um registro (cabeçalho de tipos + corpo).
|
||||
/// Decodes a SQLite record payload (serial type header + column values).
|
||||
fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
|
||||
let (hlen, n) = varint(p)?;
|
||||
let hlen = hlen as usize;
|
||||
@@ -201,7 +201,7 @@ fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
|
||||
Some(cells)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ banco
|
||||
// ------------------------------------------------------------------ database
|
||||
|
||||
impl<'a> Db<'a> {
|
||||
pub fn open(src: &'a dyn Source) -> Res<Self> {
|
||||
@@ -259,7 +259,7 @@ impl<'a> Db<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lê o payload completo de uma célula (segue as páginas de overflow).
|
||||
/// Reads the full payload of a B-tree cell, following overflow page chains when needed.
|
||||
fn payload(
|
||||
&self,
|
||||
page: &[u8],
|
||||
@@ -304,8 +304,8 @@ impl<'a> Db<'a> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Percorre as folhas de uma árvore de tabela chamando `f(rowid, payload)`.
|
||||
/// `f` devolve false para parar. Devolve os avisos de corrupção encontrados.
|
||||
/// Walks all leaf pages of a table B-tree, invoking `f(rowid, payload)` for each cell.
|
||||
/// `f` returns `false` to stop early. Returns any page/cell corruption warnings encountered.
|
||||
fn walk_table(&self, root: u32, f: &mut dyn FnMut(i64, &[u8]) -> bool) -> Vec<String> {
|
||||
let mut warnings = vec![];
|
||||
let mut stack = vec![(root, 0u32)];
|
||||
@@ -407,7 +407,7 @@ impl<'a> Db<'a> {
|
||||
leaves
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- esquema
|
||||
// ---------------------------------------------------------- schema
|
||||
|
||||
pub fn schema(&self) -> DbSchema {
|
||||
let mut s = DbSchema {
|
||||
@@ -486,7 +486,7 @@ impl<'a> Db<'a> {
|
||||
.ok_or_else(|| format!("tabela {name} não encontrada"))
|
||||
}
|
||||
|
||||
/// Conta as linhas da tabela (sem decodificar o conteúdo).
|
||||
/// Counts rows in a table B-tree without decoding cell payloads.
|
||||
pub fn count_rows(&self, t: &DbTable) -> u64 {
|
||||
if t.rootpage == 0 || t.virtual_table || t.without_rowid {
|
||||
return 0;
|
||||
@@ -500,7 +500,7 @@ impl<'a> Db<'a> {
|
||||
n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- linhas
|
||||
// ---------------------------------------------------------- rows
|
||||
|
||||
pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> {
|
||||
let t = self.table(table)?;
|
||||
@@ -550,11 +550,11 @@ impl<'a> Db<'a> {
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- registros apagados
|
||||
// ---------------------------------------------------------- deleted records
|
||||
|
||||
/// Procura registros apagados nas áreas livres das páginas da tabela.
|
||||
/// São "prováveis": o SQLite não marca o que foi apagado, então validamos pela forma
|
||||
/// do registro (mesmo número de colunas, tipos e textos coerentes).
|
||||
/// Carves deleted records from unallocated gaps and freeblocks across table leaf pages.
|
||||
/// Candidates are validated structurally against the table schema (column count,
|
||||
/// column type affinities, and valid UTF-8 text).
|
||||
pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> {
|
||||
let t = self.table(table)?;
|
||||
let n = t.columns.len();
|
||||
@@ -579,7 +579,7 @@ impl<'a> Db<'a> {
|
||||
if content > ptr_end {
|
||||
regions.push((ptr_end, content));
|
||||
}
|
||||
// cadeia de blocos livres
|
||||
// page freeblock chain
|
||||
let mut fb = be16(&pg, hb + 1).unwrap_or(0);
|
||||
let mut guard = 0;
|
||||
while fb != 0 && guard < 2000 {
|
||||
@@ -620,10 +620,10 @@ impl<'a> Db<'a> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Tenta ler um registro apagado no início de `b`, em três formas:
|
||||
/// (a) cabeçalho íntegro; (b) tamanho do cabeçalho perdido (o SQLite sobrescreve os 4
|
||||
/// primeiros bytes do bloco livre); (c) como (b), mas sem o primeiro tipo (coluna que é
|
||||
/// alias do rowid, sempre NULL).
|
||||
/// Attempts to parse a deleted record at the start of `b` in three layouts:
|
||||
/// (a) intact record header; (b) overwritten header-size varint (SQLite overwrites the first
|
||||
/// 4 bytes of a freeblock with `next`/`size`); (c) like (b), but with the first serial type
|
||||
/// (`INTEGER PRIMARY KEY` rowid alias, always `NULL`) also overwritten.
|
||||
fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> {
|
||||
let n = t.columns.len();
|
||||
// (a)
|
||||
@@ -641,8 +641,8 @@ impl<'a> Db<'a> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Lê `ntypes` tipos seriais a partir de `b[start..]` (com um tipo inicial opcional já
|
||||
/// conhecido) e o corpo logo depois do cabeçalho.
|
||||
/// Reads `ntypes` serial type varints from `b[start..]` (with an optional synthesized leading
|
||||
/// type) followed immediately by the value body.
|
||||
fn record_with_types(
|
||||
&self,
|
||||
b: &[u8],
|
||||
@@ -654,7 +654,7 @@ impl<'a> Db<'a> {
|
||||
let n = t.columns.len();
|
||||
let mut types: Vec<i64> = first.into_iter().collect();
|
||||
let mut o = start;
|
||||
let mut hdr_bytes = 1usize; // o próprio byte do tamanho do cabeçalho
|
||||
let mut hdr_bytes = 1usize; // the header-length varint byte itself
|
||||
for _ in 0..ntypes {
|
||||
let (ty, m) = varint(b.get(o..)?)?;
|
||||
types.push(ty);
|
||||
@@ -665,7 +665,7 @@ impl<'a> Db<'a> {
|
||||
hdr_bytes += 1;
|
||||
}
|
||||
if start == 1 {
|
||||
// forma (a): o primeiro byte declara o tamanho do cabeçalho e precisa bater
|
||||
// layout (a): the leading byte declares the total header size and must match
|
||||
if *b.first()? as usize != o {
|
||||
return None;
|
||||
}
|
||||
@@ -685,7 +685,7 @@ impl<'a> Db<'a> {
|
||||
if body == 0 || o + body > b.len() {
|
||||
return None;
|
||||
}
|
||||
// monta um registro canônico para reaproveitar o decodificador
|
||||
// reassemble a canonical record buffer to reuse `decode_record`
|
||||
let mut rec = vec![];
|
||||
let mut hdr = vec![];
|
||||
for &ty in &types {
|
||||
@@ -716,7 +716,7 @@ impl<'a> Db<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// O tipo serial é compatível com o tipo declarado da coluna (afinidade do SQLite)?
|
||||
/// Checks whether a serial type is plausible for the declared column affinity.
|
||||
fn type_ok(decl: &str, ty: i64) -> bool {
|
||||
if ty == 10 || ty == 11 || ty < 0 {
|
||||
return false;
|
||||
@@ -757,7 +757,7 @@ fn put_varint(out: &mut Vec<u8>, v: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// O SQLite guarda REAIS inteiros como inteiros; devolve-os como REAL quando a coluna é REAL.
|
||||
/// SQLite stores whole-number REAL values as integers; promote them back to `Real` for `REAL` columns.
|
||||
fn apply_affinity(cells: &mut [Cell], cols: &[DbColumn]) {
|
||||
for (c, col) in cells.iter_mut().zip(cols) {
|
||||
let d = col.decl_type.to_uppercase();
|
||||
@@ -786,14 +786,14 @@ fn unquote(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrai colunas, alias de rowid e WITHOUT ROWID de um `CREATE TABLE`.
|
||||
/// Parses column definitions, `INTEGER PRIMARY KEY` rowid alias, and `WITHOUT ROWID` from a `CREATE TABLE` statement.
|
||||
fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
|
||||
let upper = sql.to_uppercase();
|
||||
let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID");
|
||||
let Some(open) = sql.find('(') else {
|
||||
return (vec![], None, without_rowid);
|
||||
};
|
||||
// fecha o parêntese correspondente
|
||||
// find matching closing parenthesis
|
||||
let mut depth = 0;
|
||||
let mut close = sql.len();
|
||||
let mut in_q: Option<char> = None;
|
||||
@@ -814,7 +814,7 @@ fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
|
||||
}
|
||||
}
|
||||
let body = &sql[open + 1..close.min(sql.len())];
|
||||
// separa por vírgulas de nível 0
|
||||
// split on top-level commas (depth 0)
|
||||
let mut parts = vec![];
|
||||
let (mut d, mut start, mut q) = (0, 0, None::<char>);
|
||||
for (i, ch) in body.char_indices() {
|
||||
@@ -894,7 +894,7 @@ fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
|
||||
(cols, alias, without_rowid)
|
||||
}
|
||||
|
||||
/// O arquivo começa com o cabeçalho de um banco SQLite?
|
||||
/// Returns `true` if `b` starts with the 16-byte SQLite 3 file header magic.
|
||||
pub fn is_sqlite(head: &[u8]) -> bool {
|
||||
head.len() >= 16 && &head[0..16] == MAGIC
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Miniaturas para a grade de arquivos. Decodifica com redução na própria
|
||||
//! decodificação (JPEG) para ser rápido mesmo com milhares de fotos e, se a
|
||||
//! imagem estiver danificada, tenta a miniatura embutida no EXIF.
|
||||
//! Thumbnail generator for the file explorer grid. Uses DCT-scaled JPEG decoding
|
||||
//! for fast generation across thousands of photos, falling back to the embedded
|
||||
//! EXIF thumbnail when the main image is damaged.
|
||||
|
||||
use crate::recover::read_found_at;
|
||||
use crate::source::Source;
|
||||
@@ -25,7 +25,7 @@ fn read_all(src: &dyn Source, f: &Found) -> Option<Vec<u8>> {
|
||||
(!v.is_empty()).then_some(v)
|
||||
}
|
||||
|
||||
/// Miniatura JPEG com o maior lado = `max` (None se não for imagem ou não decodificar).
|
||||
/// Renders a JPEG thumbnail with longest edge `<= max` (`None` if not a decodable image).
|
||||
pub fn thumbnail(src: &dyn Source, f: &Found, max: u32) -> Option<Vec<u8>> {
|
||||
let data = read_all(src, f)?;
|
||||
let img = match f.ext.to_ascii_lowercase().as_str() {
|
||||
@@ -59,7 +59,7 @@ fn decode_jpeg(data: &[u8], max: u32) -> Option<RgbImage> {
|
||||
jpeg_decoder::PixelFormat::CMYK32 => px
|
||||
.chunks_exact(4)
|
||||
.flat_map(|c| {
|
||||
// Adobe CMYK vem invertido
|
||||
// Adobe CMYK JPEGs store inverted channel values
|
||||
let k = c[3] as u32;
|
||||
[
|
||||
(c[0] as u32 * k / 255) as u8,
|
||||
@@ -76,7 +76,7 @@ fn decode_jpeg(data: &[u8], max: u32) -> Option<RgbImage> {
|
||||
fn decode_other(data: &[u8], max: u32) -> Option<RgbImage> {
|
||||
let dynimg = image::load_from_memory(data).ok()?;
|
||||
let rgba = dynimg.to_rgba8();
|
||||
// Achata a transparência sobre cinza claro.
|
||||
// Composite alpha channel over a neutral light-gray background.
|
||||
let mut rgb = RgbImage::new(rgba.width(), rgba.height());
|
||||
for (x, y, p) in rgba.enumerate_pixels() {
|
||||
let a = p[3] as u32;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Tipos compartilhados entre núcleo, CLI e interface gráfica.
|
||||
//! Core data types shared across `guara-core`, the CLI, and the GUI.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -69,12 +69,12 @@ impl Category {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trecho contíguo de bytes na origem (offset absoluto).
|
||||
/// Contiguous byte range `[ offset, offset + len )` on the source.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Extent {
|
||||
pub offset: u64,
|
||||
pub len: u64,
|
||||
/// Trecho esparso: lido como zeros.
|
||||
/// Sparse hole (`offset == 0`): read as zero bytes.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sparse: bool,
|
||||
}
|
||||
@@ -99,14 +99,14 @@ impl Extent {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum DataLoc {
|
||||
/// Dados dentro do próprio registro (NTFS residente).
|
||||
/// Resident file payload stored inline inside the metadata record (e.g. resident NTFS `$DATA`).
|
||||
Resident {
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
Extents {
|
||||
extents: Vec<Extent>,
|
||||
},
|
||||
/// Arquivo montado: trechos em memória (cabeçalhos refeitos) e trechos do disco.
|
||||
/// Composite file made of in-memory slices (rebuilt headers/`moov`) and on-disk runs.
|
||||
Parts {
|
||||
parts: Vec<Part>,
|
||||
},
|
||||
@@ -119,11 +119,11 @@ pub enum Part {
|
||||
#[serde(with = "b64")]
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
/// Trecho contíguo da origem.
|
||||
/// Contiguous byte run on the source.
|
||||
Run { offset: u64, len: u64 },
|
||||
/// Zeros (buracos de arquivos esparsos).
|
||||
/// Zero-filled range (sparse file hole).
|
||||
Zeros { len: u64 },
|
||||
/// Extensão comprimida na origem (Btrfs): descompactada ao ler.
|
||||
/// Compressed on-disk extent (Btrfs): decompressed on the fly when read.
|
||||
Packed(Packed),
|
||||
}
|
||||
|
||||
@@ -137,20 +137,20 @@ pub enum PackedCodec {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Packed {
|
||||
/// Onde estão os bytes comprimidos na origem.
|
||||
/// Source byte offset of the compressed extent.
|
||||
pub offset: u64,
|
||||
pub disk_len: u64,
|
||||
/// Tamanho da extensão depois de descompactada.
|
||||
/// Total uncompressed byte length of the extent.
|
||||
pub ram_len: u64,
|
||||
/// Bytes a pular no começo da extensão descompactada.
|
||||
/// Leading uncompressed bytes to skip within the extent.
|
||||
pub skip: u64,
|
||||
/// Bytes úteis a partir de `skip`.
|
||||
/// Number of uncompressed bytes to expose starting at `skip`.
|
||||
pub len: u64,
|
||||
pub codec: PackedCodec,
|
||||
}
|
||||
|
||||
impl Packed {
|
||||
/// Descompacta a extensão e devolve os `len` bytes úteis.
|
||||
/// Decompresses the extent and returns the `[skip .. skip + len]` slice.
|
||||
pub fn read(&self, src: &dyn crate::source::Source) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let raw = crate::source::read_vec(src, self.offset, self.disk_len as usize)?;
|
||||
@@ -283,12 +283,12 @@ impl Origin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Um arquivo encontrado (apagado, perdido ou reconstruído por assinatura).
|
||||
/// A discovered file (deleted metadata entry, orphaned inode, or signature-carved hit).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Found {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Pasta de origem, quando o sistema de arquivos a preserva.
|
||||
/// Original parent directory path, when preserved by filesystem metadata.
|
||||
pub dir: Option<String>,
|
||||
pub ext: String,
|
||||
pub category: Category,
|
||||
@@ -296,31 +296,31 @@ pub struct Found {
|
||||
pub origin: Origin,
|
||||
pub confidence: Confidence,
|
||||
pub data: DataLoc,
|
||||
/// Segundos desde 1970-01-01 UTC.
|
||||
/// Seconds since 1970-01-01 00:00:00 UTC.
|
||||
pub modified: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub created: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub accessed: Option<i64>,
|
||||
/// Data em que a foto foi tirada (EXIF; hora da câmera lida como UTC), s desde 1970.
|
||||
/// EXIF capture timestamp (camera local time interpreted as UTC), in seconds since 1970.
|
||||
#[serde(default)]
|
||||
pub taken: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub camera: Option<String>,
|
||||
/// Largura × altura em pixels, quando conhecidas.
|
||||
/// Image/video dimensions `(width, height)` in pixels, when known.
|
||||
#[serde(default)]
|
||||
pub px: Option<(u32, u32)>,
|
||||
/// Há coordenadas GPS nos metadados.
|
||||
/// Whether EXIF GPS coordinates are present.
|
||||
#[serde(default)]
|
||||
pub gps: bool,
|
||||
/// Assinatura rápida do conteúdo (tamanho + início + fim) para achar duplicadas.
|
||||
/// Fast content hash (size + head + tail) used for duplicate detection.
|
||||
#[serde(default)]
|
||||
pub sig: Option<u64>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// Data mais representativa: quando foi tirada > modificada > criada.
|
||||
/// Best representative timestamp: `taken > modified > created`.
|
||||
pub fn when(&self) -> Option<i64> {
|
||||
self.taken.or(self.modified).or(self.created)
|
||||
}
|
||||
@@ -362,11 +362,11 @@ pub enum FsKind {
|
||||
Refs,
|
||||
Bitlocker,
|
||||
Luks,
|
||||
/// FileVault 2 (Core Storage).
|
||||
/// Apple FileVault 2 (Core Storage).
|
||||
Corestorage,
|
||||
/// Membro de volume LVM (physical volume).
|
||||
/// Linux LVM2 Physical Volume (PV).
|
||||
Lvm,
|
||||
/// Membro de RAID por software (mdadm).
|
||||
/// Linux `mdadm` software RAID member.
|
||||
Raid,
|
||||
Unknown,
|
||||
}
|
||||
@@ -394,7 +394,7 @@ impl FsKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume cifrado: só detectamos (v1), sem desbloquear.
|
||||
/// Returns `true` if this kind represents an encrypted container.
|
||||
pub fn encrypted(self) -> Option<&'static str> {
|
||||
match self {
|
||||
FsKind::Bitlocker => Some("BitLocker"),
|
||||
@@ -404,7 +404,7 @@ impl FsKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Há leitor de metadados (arquivos apagados com nome) para este sistema.
|
||||
/// Returns `true` if a dedicated filesystem metadata reader exists for this format.
|
||||
pub fn has_metadata_scanner(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -429,10 +429,10 @@ pub struct PartInfo {
|
||||
pub len: u64,
|
||||
pub fs: FsKind,
|
||||
pub label: Option<String>,
|
||||
/// Encontrada por varredura, não consta da tabela de partições.
|
||||
/// Discovered by scanning unallocated gaps rather than listed in the partition table.
|
||||
pub lost: bool,
|
||||
pub encrypted: Option<String>,
|
||||
/// Descrição do tipo na tabela (ex.: "Microsoft basic data").
|
||||
/// Partition type label from the partition table (e.g. `"Microsoft basic data"`).
|
||||
pub table_type: Option<String>,
|
||||
}
|
||||
|
||||
@@ -445,10 +445,10 @@ pub struct Gap {
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DiskLayout {
|
||||
pub size: u64,
|
||||
/// "gpt", "mbr" ou "none".
|
||||
/// Partition table scheme: `"gpt"`, `"mbr"`, or `"none"`.
|
||||
pub table: String,
|
||||
pub partitions: Vec<PartInfo>,
|
||||
/// Espaço não particionado relevante (> 1 MiB).
|
||||
/// Significant unpartitioned byte gaps (`> 1 MiB`).
|
||||
pub gaps: Vec<Gap>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -456,37 +456,37 @@ pub struct DiskLayout {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ScanMode {
|
||||
/// Só metadados dos sistemas de arquivos (segundos).
|
||||
/// Fast scan: filesystem metadata readers only (completes in seconds).
|
||||
Quick,
|
||||
/// Metadados + carving por assinatura em todo espaço livre/desconhecido.
|
||||
/// Deep scan: filesystem metadata plus signature carving across free/unallocated space.
|
||||
Deep,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanOptions {
|
||||
pub mode: ScanMode,
|
||||
/// Inclui áreas ainda alocadas no carving (mais lento, acha cópias antigas).
|
||||
/// Also carve currently allocated space (slower, finds older copies inside allocated blocks).
|
||||
#[serde(default)]
|
||||
pub include_allocated: bool,
|
||||
/// Limita o carving a estas categorias (vazio = todas).
|
||||
/// Restrict signature carving to these categories (empty = all categories).
|
||||
#[serde(default)]
|
||||
pub categories: Vec<Category>,
|
||||
/// Tamanho máximo aceito para arquivos carved (bytes).
|
||||
/// Maximum byte size accepted for a single carved file.
|
||||
#[serde(default = "default_max_carve")]
|
||||
pub max_carve_size: u64,
|
||||
/// Membros extras (RAID/LVM) e segredos para volumes cifrados.
|
||||
/// Extra disk members (RAID/LVM) and credentials for unlocking encrypted volumes.
|
||||
#[serde(default)]
|
||||
pub assembly: crate::assemble::Assembly,
|
||||
/// Partições que não estão na tabela do disco (volumes lógicos, desbloqueados).
|
||||
/// Virtual partitions not present in the physical partition table (LVM LVs, unlocked volumes).
|
||||
#[serde(default)]
|
||||
pub extra_parts: Vec<PartInfo>,
|
||||
/// Partições que só contêm RAID/LVM remontado: a varredura entra pelos volumes virtuais.
|
||||
/// Physical partitions that act solely as assembled RAID/LVM containers (skipped in favor of virtual volumes).
|
||||
#[serde(default)]
|
||||
pub covered: Vec<u64>,
|
||||
/// O disco principal é só membro de RAID sem metadados: varre apenas os volumes virtuais.
|
||||
/// Primary disk is part of a whole-disk RAID array: scan only the assembled virtual volumes.
|
||||
#[serde(default)]
|
||||
pub skip_primary: bool,
|
||||
/// Vídeos íntegros da mesma câmera/drone: fornecem SPS/PPS para remontar MP4 sem moov.
|
||||
/// Intact donor videos from the same camera/drone: supply SPS/PPS to rebuild headerless MP4s.
|
||||
#[serde(default)]
|
||||
pub references: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! CLI self-updater: queries the latest Gitea Release, downloads the matching
|
||||
//! platform archive, verifies its SHA-256 checksum, and atomically replaces the
|
||||
//! running `guara` binary.
|
||||
//!
|
||||
//! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack
|
||||
//! into `guara-core`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
||||
const LATEST_RELEASE_URL: &str =
|
||||
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/guara/releases/latest";
|
||||
|
||||
/// Human-facing Gitea Releases page for desktop GUI users.
|
||||
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
pub latest_version: Option<String>,
|
||||
pub update_available: bool,
|
||||
}
|
||||
|
||||
fn curl_text(url: &str) -> Result<String> {
|
||||
let out = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: guara-updater", url])
|
||||
.output()
|
||||
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
||||
if !out.status.success() {
|
||||
return Err(Error::msg(format!(
|
||||
"curl {url}: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
||||
let status = Command::new("curl")
|
||||
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
||||
.arg(dest)
|
||||
.arg(url)
|
||||
.status()
|
||||
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(Error::msg(format!("curl {url}: download falhou")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the expected release asset suffix for the current OS/architecture
|
||||
/// (e.g. `"-linux-x86_64.tar.gz"`).
|
||||
fn asset_suffix_for_platform() -> String {
|
||||
let os = if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else {
|
||||
"aarch64"
|
||||
};
|
||||
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
|
||||
format!("-{os}-{arch}.{ext}")
|
||||
}
|
||||
|
||||
/// Queries the latest published release and compares it against the running
|
||||
/// `CARGO_PKG_VERSION`.
|
||||
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||
let url = LATEST_RELEASE_URL;
|
||||
let body = match curl_text(url) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
// Offline or no published releases yet: treat as no update available.
|
||||
return Ok(UpdateStatus {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: None,
|
||||
update_available: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
let latest = release.tag_name.trim_start_matches('v').to_string();
|
||||
let update_available = latest != current_version;
|
||||
Ok(UpdateStatus {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: Some(latest),
|
||||
update_available,
|
||||
})
|
||||
}
|
||||
|
||||
/// Downloads, verifies, and installs the latest CLI release in place.
|
||||
/// Returns the newly installed version string.
|
||||
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||
// Guard against replacing `guara-gui` with the CLI binary (`guara`);
|
||||
// desktop GUI updates use the installer/package from the release page instead.
|
||||
let is_cli = current_exe
|
||||
.file_stem()
|
||||
.map(|stem| stem == "guara")
|
||||
.unwrap_or(false);
|
||||
if !is_cli {
|
||||
return Err(Error::msg(format!(
|
||||
"a atualização automática vale só para o CLI `guara`; para o app desktop, baixe o instalador em {RELEASES_PAGE_URL}"
|
||||
)));
|
||||
}
|
||||
let body = curl_text(LATEST_RELEASE_URL)?;
|
||||
let release: Release = serde_json::from_str(&body)?;
|
||||
|
||||
let suffix = asset_suffix_for_platform();
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(&suffix))
|
||||
.ok_or_else(|| {
|
||||
Error::msg(format!(
|
||||
"nenhum asset terminado em `{suffix}` na release {}",
|
||||
release.tag_name
|
||||
))
|
||||
})?;
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == format!("{}.sha256", asset.name));
|
||||
|
||||
let tmp_dir = std::env::temp_dir().join("guara-update");
|
||||
fs::create_dir_all(&tmp_dir)?;
|
||||
let archive_path = tmp_dir.join(&asset.name);
|
||||
curl_download(&asset.browser_download_url, &archive_path)?;
|
||||
|
||||
if let Some(checksum_asset) = checksum_asset {
|
||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||
let expected_hash = expected
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let data = fs::read(&archive_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
let actual_hash: String = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
if actual_hash != expected_hash {
|
||||
return Err(Error::msg(
|
||||
"checksum SHA-256 não confere — atualização descartada",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern
|
||||
// libarchive/bsdtar) and atomically replace the current executable.
|
||||
let extract_dir = tmp_dir.join("extracted");
|
||||
let _ = fs::remove_dir_all(&extract_dir);
|
||||
fs::create_dir_all(&extract_dir)?;
|
||||
let status = Command::new("tar")
|
||||
.arg("xf")
|
||||
.arg(&archive_path)
|
||||
.arg("-C")
|
||||
.arg(&extract_dir)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(Error::msg("tar xf: extração falhou"));
|
||||
}
|
||||
|
||||
let new_binary = find_binary(&extract_dir)?;
|
||||
let backup = current_exe.with_extension("bak");
|
||||
let _ = fs::remove_file(&backup);
|
||||
fs::rename(current_exe, &backup)?;
|
||||
fs::copy(&new_binary, current_exe)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(current_exe)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(current_exe, perms)?;
|
||||
}
|
||||
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
/// Locates `name` at the archive root or one directory level below
|
||||
/// (`guara-vX.Y.Z-<platform>/guara`).
|
||||
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
||||
fn is_binary(name: &std::ffi::OsStr) -> bool {
|
||||
name == "guara" || name == "guara.exe"
|
||||
}
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if is_binary(&entry.file_name()) {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
for inner in fs::read_dir(entry.path())? {
|
||||
let inner = inner?;
|
||||
if is_binary(&inner.file_name()) {
|
||||
return Ok(inner.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::msg("binário não encontrado no pacote baixado"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_refuses_to_overwrite_the_desktop_app() {
|
||||
let err = apply(&PathBuf::from("/opt/guara/guara-gui")).unwrap_err();
|
||||
assert!(err.to_string().contains("só para o CLI"), "{err}");
|
||||
let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err();
|
||||
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
|
||||
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem
|
||||
//! clara.
|
||||
//! DMG (Apple UDIF): parses the `koly` trailer, plist `mish` block tables, and zero-fill, raw,
|
||||
//! ADC, zlib, bzip2, and LZFSE (`ULFO`) chunks. Encrypted DMG images (`encrcdsa`) are rejected
|
||||
//! with a descriptive error.
|
||||
|
||||
use super::{bad, be32, be64, read_exact, read_vec, Backend};
|
||||
use crate::source::{FileSource, Source};
|
||||
@@ -21,7 +21,7 @@ pub(super) fn is_dmg(file: &FileSource) -> bool {
|
||||
&& be32(&t, 8) == 512
|
||||
}
|
||||
|
||||
/// Imagem cifrada pelo `hdiutil`: começa com `encrcdsa` ou `cdsaencr`.
|
||||
/// Checks whether `head` starts with an `hdiutil` encrypted DMG magic (`encrcdsa` / `cdsaencr`).
|
||||
pub(super) fn is_encrypted(head: &[u8]) -> bool {
|
||||
head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr")
|
||||
}
|
||||
@@ -65,7 +65,7 @@ fn base64(s: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Descompacta ADC (Apple Data Compression).
|
||||
/// Decompresses an Apple Data Compression (ADC) chunk.
|
||||
fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(max);
|
||||
let mut i = 0;
|
||||
@@ -210,7 +210,7 @@ impl Backend for Dmg {
|
||||
return Ok(n);
|
||||
};
|
||||
if voff < c.start {
|
||||
// espaço que nenhuma tabela cobre
|
||||
// unmapped gap between `mish` runs: zero-fill
|
||||
let n = buf.len().min((c.start - voff) as usize);
|
||||
buf[..n].fill(0);
|
||||
return Ok(n);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Imagens de disco virtual e contêineres: QCOW2, VMDK, VHD, VHDX e DMG (UDIF).
|
||||
//! Virtual disk images and containers: QCOW2, VMDK, VHD, VHDX, and DMG (UDIF).
|
||||
//!
|
||||
//! Each format is opened read-only as a logical block device and plugs transparently into
|
||||
//! the standard pipeline (partitions, filesystems, encryption) via [`Source`]. Differencing
|
||||
//! disks (VHD/VHDX parent locators, QCOW2 backing files, VMDK parent chains) resolve reads
|
||||
//! up the backing chain to the base image.
|
||||
//!
|
||||
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
|
||||
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
|
||||
//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a
|
||||
//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi
|
||||
//! alterado depois do filho, os dados lidos refletem o pai atual.
|
||||
|
||||
mod dmg;
|
||||
mod qcow2;
|
||||
@@ -36,21 +36,21 @@ pub(crate) fn le64(b: &[u8], o: usize) -> u64 {
|
||||
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Leitura dentro de um único bloco do formato; devolve quantos bytes entregou (pode ser
|
||||
/// menos que `buf.len()`, nunca zero antes do fim do disco).
|
||||
/// Reads within a single format block/cluster; returns the number of bytes filled (may be
|
||||
/// less than `buf.len()`, but never `0` before the logical end of disk).
|
||||
pub(crate) trait Backend: Send + Sync {
|
||||
fn size(&self) -> u64;
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize>;
|
||||
}
|
||||
|
||||
/// Disco virtual aberto de uma imagem de contêiner.
|
||||
/// Opened virtual disk backed by a container image.
|
||||
pub struct VDisk {
|
||||
inner: Box<dyn Backend>,
|
||||
format: &'static str,
|
||||
}
|
||||
|
||||
impl VDisk {
|
||||
/// Nome do formato (`QCOW2`, `VMDK`, `VHD`, `VHDX`, `DMG`).
|
||||
/// Format label (`"QCOW2"`, `"VMDK"`, `"VHD"`, `"VHDX"`, `"DMG"`).
|
||||
pub fn format(&self) -> &'static str {
|
||||
self.format
|
||||
}
|
||||
@@ -88,12 +88,12 @@ pub(crate) fn io_err(e: io::Error, what: &str) -> Error {
|
||||
Error::msg(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// Cadeias de arquivos pai (VHD/VHDX diferenciais) não passam deste limite, o que também
|
||||
/// corta ciclos.
|
||||
/// Maximum parent/backing chain depth (prevents infinite loops on cyclic parent references).
|
||||
///
|
||||
const MAX_DEPTH: u32 = 16;
|
||||
|
||||
/// Formato de contêiner reconhecido pelos primeiros bytes (e, para VHD fixo e DMG, pelo
|
||||
/// fim do arquivo). `None` quando é um disco bruto.
|
||||
/// Identifies a virtual disk container format from its leading bytes (and trailing footer
|
||||
/// for fixed VHD and DMG). Returns `None` for raw disk images.
|
||||
pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
|
||||
if head.starts_with(b"QFI\xfb") {
|
||||
return Some("QCOW2");
|
||||
@@ -113,7 +113,7 @@ pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Abre `path` como disco virtual, ou `Ok(None)` se não for um contêiner conhecido.
|
||||
/// Opens `path` as a virtual disk, or returns `Ok(None)` if it is not a recognized container.
|
||||
pub fn open(path: &Path) -> Result<Option<VDisk>> {
|
||||
open_depth(path, 0)
|
||||
}
|
||||
@@ -139,8 +139,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
||||
Ok(Some(VDisk { inner, format }))
|
||||
}
|
||||
|
||||
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o
|
||||
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato.
|
||||
/// Opens a parent/backing image at `depth`. When `raw` is `true`, treats the backing file
|
||||
/// directly as a raw image without probing for a container header.
|
||||
pub(crate) fn open_parent(
|
||||
candidates: &[std::path::PathBuf],
|
||||
depth: u32,
|
||||
@@ -170,7 +170,7 @@ pub(crate) fn open_parent(
|
||||
)))
|
||||
}
|
||||
|
||||
/// Lê de um arquivo por posição e exige todos os bytes.
|
||||
/// Positional read helper that fills all of `buf` or returns `UnexpectedEof`.
|
||||
pub(crate) fn read_exact(f: &FileSource, off: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
crate::source::read_exact_at(f, off, buf)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ pub(crate) fn read_vec(f: &FileSource, off: u64, len: usize) -> io::Result<Vec<u
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Tabelas em memória têm teto: um cabeçalho adulterado não pode pedir gigabytes.
|
||||
/// Upper bound on in-memory allocation tables so a corrupted header cannot trigger multi-GB allocations.
|
||||
pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Result<usize> {
|
||||
const MAX: u64 = 512 * 1024 * 1024;
|
||||
if entries.saturating_mul(entry_size) > MAX {
|
||||
@@ -190,7 +190,7 @@ pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Resu
|
||||
Ok(entries as usize)
|
||||
}
|
||||
|
||||
/// UTF-16 (big ou little endian) até o primeiro nulo.
|
||||
/// Decodes UTF-16 (BE or LE) up to the first NUL code unit.
|
||||
pub(crate) fn utf16(b: &[u8], big: bool) -> String {
|
||||
let units: Vec<u16> = b
|
||||
.chunks_exact(2)
|
||||
@@ -200,8 +200,8 @@ pub(crate) fn utf16(b: &[u8], big: bool) -> String {
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
|
||||
/// Caminhos onde procurar o disco pai: o caminho gravado (relativo ao arquivo filho, com `\`
|
||||
/// virando `/`) e, como alternativa, só o nome do arquivo na mesma pasta.
|
||||
/// Candidate paths for locating a parent/backing disk: relative to the child image's directory
|
||||
/// (normalizing `\` to `/`), falling back to the basename in the same directory.
|
||||
pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> {
|
||||
let mut out: Vec<std::path::PathBuf> = vec![];
|
||||
for n in names {
|
||||
@@ -228,7 +228,7 @@ pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::Pa
|
||||
out
|
||||
}
|
||||
|
||||
/// Lê do disco pai (ou zeros, sem pai); o que o pai não cobre também é zero.
|
||||
/// Reads from the parent disk (or fills zeros if there is no parent or `off` is past parent EOF).
|
||||
pub(crate) fn read_parent(
|
||||
parent: &Option<Box<dyn Source>>,
|
||||
voff: u64,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd),
|
||||
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai.
|
||||
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um
|
||||
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas.
|
||||
//! QCOW2 (QEMU) v2 and v3: L1/L2 tables, compressed clusters (deflate and zstd),
|
||||
//! zero-flagged clusters, and backing files (unallocated child clusters fall through to parent).
|
||||
//! Encrypted images, external data files, extended L2 subclusters, and non-local backing URIs
|
||||
//! (`json:`, `nbd:`, URLs) are rejected.
|
||||
|
||||
use super::{
|
||||
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
|
||||
@@ -22,7 +22,7 @@ pub(super) struct Qcow2 {
|
||||
l1: Vec<u64>,
|
||||
zstd: bool,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
|
||||
/// Single-entry cache of the last decompressed cluster: `(guest_cluster_offset, data)`.
|
||||
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ impl Qcow2 {
|
||||
})
|
||||
}
|
||||
|
||||
/// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw`
|
||||
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz.
|
||||
/// Opens the backing file. Its format comes from header extension `0xE2792ACA`; `"raw"`
|
||||
/// makes the backing file open directly as raw bytes without probing.
|
||||
fn open_backing(
|
||||
file: &FileSource,
|
||||
h: &[u8],
|
||||
@@ -109,7 +109,7 @@ impl Qcow2 {
|
||||
"o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados"
|
||||
)));
|
||||
}
|
||||
// extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes
|
||||
// header extensions follow the header, 8-byte aligned
|
||||
let hlen = if version == 3 && h.len() >= 104 {
|
||||
be32(h, 100) as u64
|
||||
} else {
|
||||
@@ -201,7 +201,7 @@ impl Backend for Qcow2 {
|
||||
}
|
||||
let off = entry & OFFSET_MASK;
|
||||
if entry & 1 != 0 {
|
||||
buf[..n].fill(0); // zerado de propósito: não consulta o pai
|
||||
buf[..n].fill(0); // explicitly zeroed grain (`GTE == 1`): does not fall back to parent
|
||||
} else if off == 0 {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! VHD (Microsoft Virtual PC e Hyper-V): discos fixos, dinâmicos e diferenciais. O disco
|
||||
//! diferencial lê o pai (achado pelos localizadores do cabeçalho) nos setores que a camada
|
||||
//! filha não preencheu.
|
||||
//! VHD (Microsoft Virtual PC and Hyper-V): fixed, dynamic, and differencing disks.
|
||||
//! Differencing disks resolve parent locators (`W2ku`/`W2ru`) and fall back to the parent
|
||||
//! image for sectors not present in the child block bitmap.
|
||||
|
||||
use super::{
|
||||
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
|
||||
@@ -42,7 +42,7 @@ fn footer_ok(f: &[u8]) -> bool {
|
||||
!sum == be32(f, 64)
|
||||
}
|
||||
|
||||
/// VHD fixo: disco bruto seguido de um rodapé de 512 bytes.
|
||||
/// Fixed VHD: raw disk payload followed by a 512-byte `conectix` footer.
|
||||
pub(super) fn is_fixed(file: &FileSource) -> bool {
|
||||
let size = file.size();
|
||||
if size < 2 * SECTOR {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! VHDX (Hyper-V): tabela de regiões, metadados, BAT com blocos de bitmap intercalados,
|
||||
//! discos fixos, dinâmicos e diferenciais (pai achado pelo localizador de pai).
|
||||
//! VHDX (Hyper-V): region table, metadata table, Block Allocation Table (BAT) with interleaved
|
||||
//! sector bitmap blocks, and fixed, dynamic, and differencing disks.
|
||||
//!
|
||||
//! The VHDX transaction log is not replayed on disk (opening an image never writes to it).
|
||||
//!
|
||||
//! O log de transações não é reproduzido: abrir a imagem nunca a escreve. Se o disco foi
|
||||
//! desligado no meio de uma escrita, os dados ainda no log não aparecem.
|
||||
|
||||
use super::{
|
||||
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
|
||||
@@ -14,7 +14,7 @@ use std::path::Path;
|
||||
|
||||
const MB: u64 = 1 << 20;
|
||||
|
||||
/// GUID em texto para os 16 bytes gravados em disco (três primeiros campos little-endian).
|
||||
/// Converts a hyphenated GUID string into on-disk mixed-endian 16-byte representation.
|
||||
fn guid(s: &str) -> [u8; 16] {
|
||||
let hex: Vec<u8> = s
|
||||
.replace('-', "")
|
||||
@@ -93,7 +93,7 @@ impl Vhdx {
|
||||
}
|
||||
let chunk_ratio = (1u64 << 23) * sector_size / block_size;
|
||||
let payload = size.div_ceil(block_size);
|
||||
// Com pai, cada trecho termina com a entrada do bitmap de setores, mesmo que o disco acabe antes.
|
||||
// With a parent (`has_parent`), each chunk ratio group is followed by a sector bitmap BAT entry.
|
||||
let want = if has_parent {
|
||||
payload.div_ceil(chunk_ratio) * (chunk_ratio + 1)
|
||||
} else {
|
||||
@@ -166,7 +166,7 @@ impl Backend for Vhdx {
|
||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||
}
|
||||
7 => {
|
||||
// Parcialmente presente: o bitmap do trecho diz, setor a setor, onde está o dado.
|
||||
// Partially present block: consult the chunk's sector bitmap sector by sector.
|
||||
let chunk = block / self.chunk_ratio;
|
||||
let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio);
|
||||
let ss = self.sector_size;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
|
||||
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
|
||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos
|
||||
//! que o filho não alocou; o formato SEsparse é recusado.
|
||||
//! VMDK (VMware, VirtualBox): sparse extents (including `streamOptimized` with deflate-compressed
|
||||
//! grains) and text descriptors referencing `FLAT`, `SPARSE`, and `ZERO` extents such as
|
||||
//! `monolithicFlat` and `twoGbMaxExtentSparse`. Snapshot/delta VMDKs fall back to their parent
|
||||
//! for unallocated grains; `SEsparse` is rejected.
|
||||
|
||||
use super::{
|
||||
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
|
||||
@@ -42,7 +42,7 @@ pub(super) struct Vmdk {
|
||||
parent: Option<Box<dyn Source>>,
|
||||
}
|
||||
|
||||
/// Valor de uma chave `chave=valor` do descritor, sem aspas.
|
||||
/// Extracts the unquoted value of a `key=value` line from a VMDK text descriptor.
|
||||
fn desc_value(descriptor: &str, key: &str) -> Option<String> {
|
||||
descriptor.lines().find_map(|l| {
|
||||
l.trim()
|
||||
@@ -52,8 +52,8 @@ fn desc_value(descriptor: &str, key: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido
|
||||
/// logo depois do cabeçalho de uma extensão esparsa.
|
||||
/// Reads the VMDK text descriptor from `path` (either a standalone text file or embedded inside
|
||||
/// a monolithic sparse header).
|
||||
fn descriptor_of(path: &Path) -> Option<String> {
|
||||
let f = FileSource::open(path).ok()?;
|
||||
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?;
|
||||
@@ -68,8 +68,8 @@ fn descriptor_of(path: &Path) -> Option<String> {
|
||||
.then(|| String::from_utf8_lossy(&head).into_owned())
|
||||
}
|
||||
|
||||
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só
|
||||
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter.
|
||||
/// Parent reference parsed from a VMDK descriptor: `parentFileNameHint` and/or VirtualBox
|
||||
/// `ddb.uuid.parent`, plus the expected `parentCID`.
|
||||
struct ParentRef {
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
@@ -85,7 +85,7 @@ fn parent_ref(descriptor: &str) -> Option<ParentRef> {
|
||||
(cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid })
|
||||
}
|
||||
|
||||
/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`.
|
||||
/// Searches the child directory (and up to two parent directories) for a VMDK whose `ddb.uuid.image` matches `uuid`.
|
||||
fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
|
||||
let mut d = Some(dir);
|
||||
for _ in 0..3 {
|
||||
@@ -169,8 +169,8 @@ impl Sparse {
|
||||
))
|
||||
}
|
||||
|
||||
/// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo
|
||||
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros.
|
||||
/// Reads within a single grain. Returns `(bytes_read, allocated_or_zeroed)`: when the `bool` is
|
||||
/// `false`, the grain is unallocated in this extent and the caller should read from the parent.
|
||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
|
||||
let g = off / self.grain;
|
||||
let within = off % self.grain;
|
||||
@@ -213,7 +213,7 @@ impl Sparse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
|
||||
/// Parses extent lines from a text descriptor (e.g. `RW 2048 FLAT "disk-f001.vmdk" 0`).
|
||||
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
||||
let mut out = vec![];
|
||||
let mut start = 0u64;
|
||||
@@ -290,7 +290,7 @@ impl Vmdk {
|
||||
));
|
||||
}
|
||||
if let Some(cid) = &r.cid {
|
||||
// o pai precisa ser o mesmo de quando o filho foi criado
|
||||
// verify that the parent's `CID` matches the child's `parentCID`
|
||||
if let Some(p) = found.iter().find(|p| p.is_file()) {
|
||||
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
|
||||
if have
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Disco virtual: a origem real com trechos substituídos por volumes desbloqueados,
|
||||
//! membros de RAID montados ou volumes lógicos (LVM) anexados depois do fim do disco.
|
||||
//! Virtual disk address space: overlays a base `Source` with unlocked encrypted volumes,
|
||||
//! assembled RAID arrays, or LVM logical volumes appended beyond the physical end of disk.
|
||||
//!
|
||||
//! Os deslocamentos continuam sendo "do disco": quem varre e quem recupera enxergam o mesmo
|
||||
//! espaço de endereços, desde que a montagem seja refeita igual (a `Assembly` é determinística).
|
||||
//! Byte offsets remain globally addressable: scanning and recovery operate on a single unified
|
||||
//! address space as long as the deterministic `Assembly` is re-applied.
|
||||
|
||||
use crate::source::Source;
|
||||
use std::io;
|
||||
@@ -58,7 +58,7 @@ impl Source for Overlay {
|
||||
let n = want.min((r.start + r.len - offset) as usize);
|
||||
return r.src.read_at(offset - r.start, &mut buf[..n]);
|
||||
}
|
||||
// Antes da próxima região: lê da base só até ela.
|
||||
// Offset precedes the next virtual region: read from the base source up to the region start.
|
||||
let n = want.min((r.start - offset) as usize);
|
||||
return self.read_base(offset, &mut buf[..n]);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ impl Source for Overlay {
|
||||
impl Overlay {
|
||||
fn read_base(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if offset >= self.base.size() {
|
||||
// Lacuna entre a base e uma região anexada.
|
||||
// Unmapped gap between the end of the base source and an appended virtual region.
|
||||
buf.fill(0);
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! AFF4: imagens de referência da Evimetry (github.com/aff4/ReferenceImages) e ida e volta.
|
||||
//! AFF4: Evimetry reference images (`github.com/aff4/ReferenceImages`) and write/read roundtrip.
|
||||
|
||||
use guara_core::aff4::{write_aff4, Aff4Options, Aff4Source};
|
||||
use guara_core::source::{open_path, read_vec, MemSource, Source};
|
||||
@@ -28,11 +28,11 @@ fn reads_reference_images_and_stored_hash_matches() {
|
||||
Some(true),
|
||||
"{name}: hash do fluxo"
|
||||
);
|
||||
// setor 0 legível pelo caminho genérico
|
||||
// sector 0 is readable through the generic `source::open` entrypoint
|
||||
let s = open_path(&p).unwrap();
|
||||
let head = read_vec(s.as_ref(), 0, 4096).unwrap();
|
||||
assert_eq!(head.len(), 4096);
|
||||
// leitura em vãos do mapa devolve zeros
|
||||
// reading unmapped gaps in a sparse AFF4 map returns zeros
|
||||
let far = read_vec(s.as_ref(), 200 << 20, 512).unwrap();
|
||||
assert_eq!(far.len(), 512);
|
||||
eprintln!(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! APFS: imagem real do dfvfs (4 checkpoints) e exclusão simulada pela troca da raiz da árvore.
|
||||
//! APFS: real `dfvfs` image (4 checkpoints) and simulated COW deletion by swapping the fs-tree root.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
@@ -69,7 +69,7 @@ fn quick_scan_is_clean_on_pristine_volume() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- manipulação bruta de nós (independente do módulo) ---
|
||||
// --- raw B-tree node helpers (independent of the production parser) ---
|
||||
|
||||
fn le16(b: &[u8], o: usize) -> usize {
|
||||
u16::from_le_bytes([b[o], b[o + 1]]) as usize
|
||||
@@ -144,7 +144,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
|
||||
let mut d = std::fs::read(&p).unwrap();
|
||||
let bs = 4096usize;
|
||||
let blk = |d: &[u8], n: u64| d[n as usize * bs..(n as usize + 1) * bs].to_vec();
|
||||
// Superbloco mais recente do contêiner.
|
||||
// Locate the latest container superblock (`NXSB`).
|
||||
let (desc_blocks, desc_base) = (le32(&d, 104) as u64, le64(&d, 112));
|
||||
let mut nx = d[..bs].to_vec();
|
||||
for i in 0..desc_blocks {
|
||||
@@ -153,11 +153,11 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
|
||||
nx = b;
|
||||
}
|
||||
}
|
||||
// omap do contêiner -> volume
|
||||
// container omap -> volume superblock (`APSB`)
|
||||
let cont_omap = blk(&d, le64(&nx, 160));
|
||||
let cont_tree = blk(&d, le64(&cont_omap, 48));
|
||||
let (_, cont_entries) = read_node(&{
|
||||
// omap usa chaves fixas (16/16): reaproveita o leitor variável convertendo a tabela
|
||||
// omap uses fixed-size keys/values (16/16): convert its TOC to variable-size format for `read_var_node`
|
||||
let mut c = cont_tree.clone();
|
||||
fixed_to_var(&mut c);
|
||||
c
|
||||
@@ -187,7 +187,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
|
||||
let root = blk(&d, root_paddr);
|
||||
assert_eq!(le16(&root, 34), 0, "raiz deveria ser folha nesta imagem");
|
||||
let (_, recs) = read_node(&root);
|
||||
// inode do arquivo
|
||||
// find target file inode number
|
||||
let victim = b"passwords.txt";
|
||||
let ino = recs
|
||||
.iter()
|
||||
@@ -216,12 +216,12 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
|
||||
.collect();
|
||||
assert!(kept.len() < recs.len());
|
||||
let newnode = write_node(&root, &kept);
|
||||
// bloco livre para a nova versão
|
||||
// pick an unused block for the new COW root leaf
|
||||
let free = (10..d.len() / bs)
|
||||
.find(|&n| d[n * bs..(n + 1) * bs].iter().all(|&x| x == 0) && n != vol_tree_paddr as usize)
|
||||
.expect("bloco livre");
|
||||
d[free * bs..(free + 1) * bs].copy_from_slice(&newnode);
|
||||
// aponta o mapa de objetos para a nova versão (mesmo xid), editando o valor da entrada
|
||||
// repoint the volume omap entry to `free_blk` (keeping the same `xid`)
|
||||
let toc_off = le16(&vt_orig, 40);
|
||||
let key_base = 56 + toc_off + le16(&vt_orig, 42);
|
||||
let _ = key_base;
|
||||
@@ -236,7 +236,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p2 = dir.path().join("a.img");
|
||||
std::fs::write(&p2, &d).unwrap();
|
||||
// O arquivo sumiu da árvore atual...
|
||||
// The file is gone from the current live tree...
|
||||
let src = FileSource::open(&p2).unwrap();
|
||||
assert!(!live_paths(&src, 0).iter().any(|f| f.0 == "passwords.txt"));
|
||||
init();
|
||||
@@ -275,7 +275,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Converte a tabela de entradas fixas (4 bytes) de um nó de omap para o formato variável (8 bytes) em memória.
|
||||
/// Converts a fixed-entry TOC (4 bytes/entry) of an omap node into variable-entry format (8 bytes/entry) in memory.
|
||||
fn fixed_to_var(b: &mut Vec<u8>) {
|
||||
let flags = le16(b, 32) as u16;
|
||||
if flags & 4 == 0 {
|
||||
@@ -302,7 +302,7 @@ fn fixed_to_var(b: &mut Vec<u8>) {
|
||||
|
||||
fn write_node_flagsless(old: &[u8], entries: &[Entry]) -> Vec<u8> {
|
||||
let mut b = write_node(old, entries);
|
||||
// write_node usa as flags de `old`
|
||||
// `write_node` preserves the node flags from `old`
|
||||
let fl = le16(&b, 32) as u16;
|
||||
b[32..34].copy_from_slice(&fl.to_le_bytes());
|
||||
b
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Trilha de auditoria: encadeamento por hash, detecção de adulteração e registro das ações do motor.
|
||||
//! Audit trail: SHA-256 hash chaining, tamper detection, and engine action logging.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
@@ -28,7 +28,7 @@ fn entries_are_numbered_and_chained() {
|
||||
assert!(e[0].time.ends_with('Z') && e[0].time.len() == 20);
|
||||
let v = log.verify().unwrap();
|
||||
assert!(v.ok && v.entries == 4 && v.head == e[3].hash);
|
||||
// continua de onde parou, inclusive numa instância nova
|
||||
// continues the chain across separate `Log` instances
|
||||
let again = Log::at(log.path());
|
||||
assert_eq!(again.append("mais", json!({})).unwrap().seq, 5);
|
||||
assert!(again.verify().unwrap().ok);
|
||||
@@ -74,7 +74,7 @@ fn removing_or_reordering_lines_is_detected() {
|
||||
tamper(&log, |l| l.swap(1, 2));
|
||||
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 2);
|
||||
|
||||
// trocar o hash por outro também não passa: o seguinte deixa de continuar
|
||||
// recomputing a single tampered line's hash still breaks the next entry's `prev_hash` link
|
||||
let (_d, log) = log_with(3);
|
||||
tamper(&log, |l| {
|
||||
let h = l[0].rsplit('"').nth(1).unwrap().to_string();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! BitLocker: imagens reais do conjunto de testes do cryptsetup (GitLab), com senhas conhecidas.
|
||||
//! Baixe com `scripts/fetch-testdata.sh`; sem elas os testes são pulados.
|
||||
//! BitLocker: real test images from the `cryptsetup` test suite (with known passwords/recovery keys).
|
||||
//! Download with `scripts/fetch-testdata.sh`; tests are skipped if images are absent.
|
||||
|
||||
use guara_core::crypt::{bitlocker, Secret, UnlockError};
|
||||
use guara_core::partition::detect_fs_in;
|
||||
@@ -31,7 +31,7 @@ fn conf(d: &std::path::Path) -> BTreeMap<String, BTreeMap<String, String>> {
|
||||
}
|
||||
|
||||
fn check_fs(src: &dyn Source, what: &str) {
|
||||
// Imagens "encrypt-on-write" não têm sistema de arquivos reconhecível (UUID vazio no images.conf).
|
||||
// Used-space-only ("encrypt-on-write") images in `images.conf` have an empty filesystem UUID.
|
||||
if what.contains("-eow ") {
|
||||
return;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ fn opens_all_reference_images_with_password_and_recovery_key() {
|
||||
let src: Arc<dyn Source> = Arc::new(FileSource::open(&path).unwrap());
|
||||
let pw = c.get("PW").cloned().unwrap_or_default();
|
||||
let rp = c.get("RP").cloned().unwrap_or_default();
|
||||
// Proteção suspensa (chave limpa): abre sem segredo nenhum.
|
||||
// Suspended protection (clear key present on disk): unlocks without any user secret.
|
||||
if let Ok(u) = bitlocker::unlock(src.clone(), &[]) {
|
||||
check_fs(u.source.as_ref(), &format!("{name} (chave limpa)"));
|
||||
eprintln!("ok {name}: chave limpa");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Btrfs: imagens criadas com mkfs.btrfs --rootdir; exclusão simulada como o COW faz
|
||||
//! (nova versão da folha em outro bloco, a antiga continua no disco).
|
||||
//! Btrfs: images created with `mkfs.btrfs --rootdir`; COW deletion simulated by writing a
|
||||
//! new fs-tree leaf generation to a free metadata block while leaving the old leaf on disk.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
@@ -89,7 +89,7 @@ fn lists_the_live_tree_with_paths_and_sizes() {
|
||||
assert!(listed.iter().any(|x| x.0 == "docs/sub" && x.2));
|
||||
}
|
||||
|
||||
// --- manipulação bruta de folhas (independente do módulo) ---
|
||||
// --- raw B-tree leaf manipulation helpers (independent of the production reader) ---
|
||||
|
||||
struct Leaf {
|
||||
raw: Vec<u8>,
|
||||
@@ -118,7 +118,7 @@ impl Leaf {
|
||||
let c = crc32c(&self.raw[32..]);
|
||||
self.raw[0..4].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
/// Nova versão da folha sem os itens que `drop` rejeita, num novo endereço lógico.
|
||||
/// Rebuilds a B-tree leaf without items matching `drop`, at a new logical `bytenr` and generation.
|
||||
fn without(&self, drop: impl Fn(u64, u8) -> bool, new_bytenr: u64) -> Leaf {
|
||||
let mut kept: Vec<(usize, Vec<u8>)> = vec![];
|
||||
for i in 0..self.nritems() {
|
||||
@@ -152,7 +152,7 @@ fn superblock(img: &[u8]) -> &[u8] {
|
||||
&img[0x10000..0x11000]
|
||||
}
|
||||
|
||||
/// Mapa lógico -> físico dos chunks de sistema (suficiente para imagens pequenas de um dispositivo).
|
||||
/// Logical -> physical chunk mapping from `sys_chunk_array` (sufficient for small single-device images).
|
||||
fn chunk_map(img: &[u8]) -> Vec<(u64, u64, u64)> {
|
||||
let sb = superblock(img);
|
||||
let n = u32::from_le_bytes(sb[160..164].try_into().unwrap()) as usize;
|
||||
@@ -193,7 +193,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
let fl = Leaf {
|
||||
raw: img[fs_leaf.0..fs_leaf.0 + nodesize].to_vec(),
|
||||
};
|
||||
// inode do arquivo apagado
|
||||
// find the objectid (inode) of the target file
|
||||
let victim = "relatorio.dat";
|
||||
let mut ino = 0;
|
||||
for k in 0..fl.nritems() {
|
||||
@@ -207,7 +207,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
}
|
||||
}
|
||||
assert!(ino > 256);
|
||||
// extent de dados do arquivo (para liberar no extent tree)
|
||||
// locate the file's data extent `(disk_bytenr, disk_num_bytes)` so we can free it in the extent tree
|
||||
let mut data_bytenr = 0;
|
||||
for k in 0..fl.nritems() {
|
||||
let (obj, ty, _, _, _) = fl.item(k);
|
||||
@@ -216,7 +216,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
}
|
||||
}
|
||||
assert!(data_bytenr > 0);
|
||||
// Novo bloco livre para a nova versão: primeiro bloco do metadata sem folha válida e todo zero.
|
||||
// Pick an all-zero metadata block for the new COW leaf generation.
|
||||
let free_slot = (0..img.len() / nodesize)
|
||||
.map(|n| n * nodesize)
|
||||
.find(|&o| {
|
||||
@@ -228,11 +228,11 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
&& o < fs_leaf.0 + (8 << 20)
|
||||
})
|
||||
.expect("bloco livre");
|
||||
// logical = bytenr da folha antiga + (offset físico - offset físico antigo): mesmo chunk
|
||||
// logical `bytenr` within the same metadata chunk
|
||||
let new_bytenr = to_logical(free_slot);
|
||||
let newleaf = fl.without(|o, _| o == ino, new_bytenr);
|
||||
img[free_slot..free_slot + nodesize].copy_from_slice(&newleaf.raw);
|
||||
// Aponta o ROOT_ITEM da árvore 5 para a nova versão (e sobe a geração), na folha da árvore raiz.
|
||||
// Update tree 5 (`FS_TREE`) `ROOT_ITEM` in the root tree to point to the new leaf and bump generation.
|
||||
let mut rl = Leaf {
|
||||
raw: img[root_leaf.0..root_leaf.0 + nodesize].to_vec(),
|
||||
};
|
||||
@@ -245,7 +245,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
}
|
||||
rl.seal();
|
||||
img[root_leaf.0..root_leaf.0 + nodesize].copy_from_slice(&rl.raw);
|
||||
// Libera o extent de dados no extent tree (em outro endereço da mesma folha).
|
||||
// Remove the file's data extent from the extent tree so its data blocks are marked free.
|
||||
let el = Leaf {
|
||||
raw: img[ext_leaf.0..ext_leaf.0 + nodesize].to_vec(),
|
||||
};
|
||||
@@ -290,7 +290,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
let rp = PathBuf::from(&rep.recovered[0].path);
|
||||
let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap();
|
||||
assert!(got == i.files[0].1);
|
||||
// Os arquivos que continuam existindo não aparecem como apagados.
|
||||
// Files that are still live in the current tree must not be reported as deleted.
|
||||
assert!(!c
|
||||
.found
|
||||
.iter()
|
||||
@@ -300,7 +300,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
|
||||
|
||||
#[test]
|
||||
fn lzo_literals_and_a_match() {
|
||||
// 20 literais + fim
|
||||
// 20 literals + EOF marker
|
||||
let mut s = vec![17 + 20];
|
||||
s.extend((0..20u8).map(|i| b'a' + i));
|
||||
s.extend_from_slice(&[0x11, 0, 0]);
|
||||
@@ -308,7 +308,7 @@ fn lzo_literals_and_a_match() {
|
||||
lzo1x_decompress(&s, 4096).unwrap(),
|
||||
(0..20u8).map(|i| b'a' + i).collect::<Vec<_>>()
|
||||
);
|
||||
// "abcd" + M2 (dist 4, len 4) + fim
|
||||
// "abcd" + M2 backreference (distance 4, length 4) + EOF marker
|
||||
let s = vec![17 + 4, b'a', b'b', b'c', b'd', 0x6C, 0x00, 0x11, 0, 0];
|
||||
assert_eq!(lzo1x_decompress(&s, 4096).unwrap(), b"abcdabcd");
|
||||
}
|
||||
@@ -334,7 +334,7 @@ fn packed_extents_decode_zlib_and_zstd() {
|
||||
codec: PackedCodec::Zlib,
|
||||
};
|
||||
assert_eq!(p.read(&MemSource(disk)).unwrap(), &plain[1000..6000]);
|
||||
// zstd, se o utilitário existir
|
||||
// zstd-compressed extent (if the `zstd` CLI is installed)
|
||||
if have("zstd") {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("p.bin");
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user