Compare commits
No files matched your search
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
target
|
||||
**/node_modules
|
||||
apps/gui/src-tauri
|
||||
docs
|
||||
scripts
|
||||
*.img
|
||||
*.guara-session.json
|
||||
.claude
|
||||
.wolf
|
||||
.superpowers
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Report a bug
|
||||
description: Something did not work as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
|
||||
validations: { required: true }
|
||||
- type: input
|
||||
id: system
|
||||
attributes: { label: "Operating system and Guará version", placeholder: "Ubuntu 24.04 / guara 1.3.2" }
|
||||
- type: input
|
||||
id: media
|
||||
attributes: { label: "Media type and filesystem", placeholder: "32 GB USB drive, FAT32" }
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes: { label: "Terminal output (guara inspect / scan)", render: shell }
|
||||
@@ -0,0 +1,8 @@
|
||||
## What changes and why
|
||||
|
||||
## Checklist
|
||||
- [ ] `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)
|
||||
@@ -0,0 +1,111 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# 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']
|
||||
pull_request:
|
||||
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']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
# 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: Format and clippy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: UI translation coverage
|
||||
run: node scripts/check-ui-i18n.mjs
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
# Linux-only runner (ARM64).
|
||||
test:
|
||||
name: Tests (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: 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: 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
|
||||
# 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: |
|
||||
if [ ! -f target/testdata/xfs-real-delete/disk.img ]; then
|
||||
sudo scripts/make-xfs-real-deletion.sh
|
||||
fi
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${RUNNER_OS}" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||
cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Dependency audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
|
||||
# needs nothing but the advisory database, and installing it once into the
|
||||
# persistent CARGO_HOME makes every later run skip compilation.
|
||||
- name: cargo audit
|
||||
run: |
|
||||
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
gui:
|
||||
name: Check desktop app (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- 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: 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:
|
||||
RUSTFLAGS: ""
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Release
|
||||
|
||||
# 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).
|
||||
#
|
||||
# No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which
|
||||
# this Linux ARM64 runner does not have.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
||||
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Release
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: |
|
||||
# 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 "release $TAG already exists"; exit 0
|
||||
fi
|
||||
scripts/release-notes.sh "$TAG" > notes.md
|
||||
pre=false; case "$TAG" in *-*) pre=true ;; esac
|
||||
jq -n --arg tag "$TAG" --arg name "Guará ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
|
||||
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
|
||||
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
|
||||
-H 'Content-Type: application/json' -d @release.json >/dev/null
|
||||
|
||||
cli:
|
||||
name: CLI (${{ matrix.platform }})
|
||||
needs: release
|
||||
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: "", 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: 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: Build and package
|
||||
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
|
||||
- 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: Desktop app (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-gui-${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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: 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: Tauri dependencies (Linux x86_64)
|
||||
if: matrix.platform == 'linux-x86_64'
|
||||
timeout-minutes: 25
|
||||
run: |
|
||||
if [ "$(uname -m)" = aarch64 ]; then
|
||||
dpkg --add-architecture amd64
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" = ubuntu ]; then
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
grep -q '^Architectures:' /etc/apt/sources.list.d/ubuntu.sources || sed -i '/^Types: deb/a Architectures: arm64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
printf 'Types: deb\nURIs: http://archive.ubuntu.com/ubuntu/\nSuites: %s %s-updates %s-security\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.sources
|
||||
elif [ -f /etc/apt/sources.list ]; then
|
||||
sed -i 's/^deb http/deb [arch=arm64] http/' /etc/apt/sources.list
|
||||
printf 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-updates main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-security main universe\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.list
|
||||
fi
|
||||
fi
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-amd64 gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config patchelf file squashfs-tools qemu-user libglib2.0-bin libwebkit2gtk-4.1-dev:amd64 libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64
|
||||
else
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
fi
|
||||
- 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: Build and package
|
||||
run: scripts/build-gui.sh "${{ matrix.platform }}"
|
||||
- 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/*
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
|
||||
@@ -1,17 +0,0 @@
|
||||
name: Relatar um problema
|
||||
description: Algo não funcionou como esperado
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: o-que
|
||||
attributes: { label: O que aconteceu?, description: "Passos, o que esperava e o que viu." }
|
||||
validations: { required: true }
|
||||
- type: input
|
||||
id: sistema
|
||||
attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" }
|
||||
- type: input
|
||||
id: midia
|
||||
attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" }
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes: { label: "Saída do terminal (guara inspect / scan)", render: shell }
|
||||
@@ -1,7 +0,0 @@
|
||||
## O que muda e por quê
|
||||
|
||||
## 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)
|
||||
@@ -1,11 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule: { interval: weekly }
|
||||
- package-ecosystem: cargo
|
||||
directory: /apps/gui/src-tauri
|
||||
schedule: { interval: weekly }
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule: { interval: weekly }
|
||||
@@ -1,80 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Formatação e clippy
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
test:
|
||||
name: Testes (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Ferramentas para imagens de teste (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip
|
||||
- name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux)
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||
cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Auditoria de dependências
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: rustsec/audit-check@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
gui:
|
||||
name: Compila o app desktop (Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
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:
|
||||
RUSTFLAGS: ""
|
||||
@@ -1,134 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Cria a release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
release_id: ${{ steps.rel.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Notas da versão (do CHANGELOG)
|
||||
id: notes
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v7
|
||||
id: rel
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const tag = context.ref.replace('refs/tags/', '');
|
||||
const r = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
|
||||
name: `Guará ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
|
||||
draft: true, prerelease: tag.includes('-'),
|
||||
});
|
||||
return r.data.id;
|
||||
|
||||
cli:
|
||||
name: CLI (${{ matrix.name }})
|
||||
needs: create-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
|
||||
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
|
||||
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
|
||||
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release --locked -p guara-cli --target ${{ matrix.target }}
|
||||
- name: Empacota
|
||||
shell: bash
|
||||
run: |
|
||||
name="guara-${GITHUB_REF_NAME}-${{ matrix.name }}"
|
||||
mkdir "$name"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
cp target/${{ matrix.target }}/release/guara.exe "$name/"
|
||||
else
|
||||
cp target/${{ matrix.target }}/release/guara "$name/"
|
||||
fi
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
|
||||
else
|
||||
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
|
||||
fi
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
|
||||
echo "ASSET=$asset" >> "$GITHUB_ENV"
|
||||
- name: Envia para a release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
gui:
|
||||
name: App desktop (${{ matrix.name }})
|
||||
needs: create-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
|
||||
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
|
||||
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
|
||||
- { name: windows-x86_64, os: windows-latest, args: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: apps/gui/src-tauri
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
publish:
|
||||
name: Publica a release
|
||||
needs: [create-release, cli, gui]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}, draft: false,
|
||||
});
|
||||
@@ -1,15 +1,42 @@
|
||||
# Material de trabalho local, não versionado
|
||||
.superpowers/
|
||||
docs/
|
||||
|
||||
# Estado de ferramentas locais
|
||||
.wolf/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Build
|
||||
# Build artifacts
|
||||
target/
|
||||
apps/gui/src-tauri/target/
|
||||
node_modules/
|
||||
*.img
|
||||
*.guara-session.json
|
||||
.DS_Store
|
||||
**/*.rs.bk
|
||||
|
||||
# Sabiá local captures and audit log
|
||||
*.pcap
|
||||
*.cap
|
||||
.sabia-audit.log
|
||||
|
||||
# Logo copied to the UI directory by build.rs
|
||||
/apps/gui/ui/logo.png
|
||||
|
||||
# Local AI agent configuration and state — not part of the product
|
||||
.claude/
|
||||
.wolf/
|
||||
.opencode/
|
||||
.codex/
|
||||
.cursor/
|
||||
.superpowers/
|
||||
.superset/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Local scratch files
|
||||
*.eml
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
|
||||
/guara-v*
|
||||
/sabia-v*
|
||||
/dist-gui
|
||||
@@ -1,18 +1,241 @@
|
||||
# Changelog
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
[English](CHANGELOG.md) · [Português](CHANGELOG.pt-BR.md)
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [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.
|
||||
|
||||
### Changed
|
||||
- **Updates now come from Gitea.** The desktop app's update card reads
|
||||
`latest.json` from `git.lucasrguerra.dev.br` instead of GitHub Releases.
|
||||
Versions up to 1.3.1 still look at GitHub, which is no longer reachable, so
|
||||
they cannot find this release: download 1.3.2 once, and later updates are
|
||||
automatic.
|
||||
- The CLI is published for Linux x86_64 and aarch64 only, built by the Gitea CI
|
||||
when a `v*` tag is pushed, with a `.sha256` next to each package. **There is
|
||||
no Windows CLI any more.** The desktop app still ships for Linux (AppImage,
|
||||
`.deb`) and Windows (NSIS `.exe`, cross-compiled from Linux). There is no
|
||||
macOS build: it needs Apple's SDK and code signing, which this project's CI
|
||||
(one Linux runner) cannot provide.
|
||||
|
||||
## [1.3.1] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
- **New official logo:** the maned wolf (guará) inside the orange ring, drawn by the project's author, with a transparent background so the same art works in the light and dark themes. It replaces the old track-disc logo in the app (sidebar, top bar and home screen), the favicon, the desktop app icons and the README.
|
||||
- `guara ui` serves the new `guara.png` and `guara-s.png`; `scripts/gerar-logo.py` derives the small logo and the app icon from the official art.
|
||||
- Development: `scripts/verificar.sh` runs locally what the CI runs, with an optional pre-push hook (`scripts/instalar-hooks.sh`), and the CI no longer runs on documentation-only changes.
|
||||
|
||||
### Notes
|
||||
- No change to scanning, recovery, signing or any other behavior. This is the first release after the in-app updater: an app on 1.3.0 should offer **Update now**.
|
||||
- The screenshots in `docs/img/` still show the old logo and will be redone.
|
||||
|
||||
## [1.3.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
- **Update from inside the desktop app.** On startup the app asks GitHub for the release's `latest.json`; if there is a newer version, a card offers **Update now**. It downloads the package, verifies its minisign signature against the public key built into the app, installs and reopens (the AppImage is replaced in place; Windows and macOS use the installer). Installs from `.deb` or `.rpm` only show the notice. **Settings** can turn the startup check off and has a **Check now** button. The check is one HTTP request and sends nothing of yours.
|
||||
- **Digital signatures (Ed25519):** `guara sign keygen | file | verify | recovery`. With a key configured, `recover` and `report` sign the manifest and the report by themselves. `guara sign recovery` checks the manifest signature and the hash of every recovered file (and refuses manifest paths that leave the folder). The signature is plain Ed25519 over a documented message, so `openssl` verifies it too. Comparing the key fingerprint (`--trust`) says whose key it is; the signed time is the local clock, not a third-party timestamp.
|
||||
- **Virtual disks:** QCOW2 with a backing file, VMDK snapshots (parent by name or, in VirtualBox, by UUID; the child is refused if the parent's CID changed) and DMG with LZFSE chunks.
|
||||
- **CI with real reference images** on Linux, macOS and Windows (they were skipped before), and an XFS image deleted by the real kernel generated with `sudo` on the Linux runner.
|
||||
- Release pipeline: update packages are signed with a key kept in the repository's `release` environment (tags `v*` only), and `latest.json` is built in one step by `scripts/make-latest-json.py`.
|
||||
|
||||
### Changed
|
||||
- **XFS is now 🟡.** On a real XFS (v5, kernel deletion) the inode and extent list are wiped, so the metadata path finds nothing; the content of files with a recognizable structure comes back by carving, without name or folder. The earlier path only worked on simulated deletions. The name-and-folder test is kept `ignore`d until the journal is read.
|
||||
- CI `apt` steps use cached `.deb`s, a time limit per attempt and retries (they took up to 28 minutes on a slow mirror day).
|
||||
- Dependencies: `ctutils` 0.4.5 (RUSTSEC-2026-0385); new `ed25519-compact`, `lzfse_rust`, `tauri-plugin-updater`.
|
||||
- The README and ROADMAP now say the update check is the only network use by default.
|
||||
|
||||
### Notes
|
||||
- Versions before 1.3.0 do not have the updater: from 1.2.0, replace the AppImage by hand one last time. The first update inside the app is 1.3.0 to the next release.
|
||||
- Still missing: password-protected DMG, an identity check for VHD, VHDX and QCOW2 parents, third-party timestamps (RFC 3161), XFS journal reading, and APFS validation with real deletion (needs an image from a real Mac).
|
||||
- CLI output remains in Portuguese.
|
||||
|
||||
## [1.2.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
- **Virtual disk containers (read-only):** QCOW2 (v2 and v3, compressed clusters with deflate or zstd), VMDK (sparse, `streamOptimized`, and text descriptors with flat and zero extents), VHD and VHDX (fixed, dynamic and differencing, following the parent chain) and DMG (UDIF with zero, raw, ADC, zlib and bzip2 chunks). They open through the same path as E01 and AFF4, so partitions, file systems and encrypted volumes work inside them. Images that would read wrong are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent or in SEsparse, password-protected DMG, DMG with LZFSE chunks. The VHDX log is not replayed.
|
||||
- **Audit trail** (`auditoria.jsonl`, `guara audit`): every action on evidence (`inspect`, `scan`, `recover`, `image`, `hash`, `search`, `verify`, exports and sessions) is recorded with UTC time, system user, host, version and details, chained by SHA-256. `guara audit --verify` reports the entry where the chain breaks. Set `GUARA_EXAMINER` to record the examiner; passwords and keys are never written.
|
||||
- **PDF report:** `guara report out.pdf` (and the interface's report button, by file extension) writes an A4 PDF with the same sections as the HTML, using a built-in writer with the standard fonts and no new dependency. Both formats now show the audit trail status and the hash of its last entry.
|
||||
- **APFS free-space map:** the container's space manager (spaceman), chunk-info blocks and bitmaps are read, so deep scans of an APFS volume cover only unallocated blocks. Containers that use chunk-info address blocks fall back to the whole volume, with a warning.
|
||||
- `scripts/fetch-testdata.sh` also downloads the dfvfs virtual disk images used by the container tests.
|
||||
|
||||
### Changed
|
||||
- The APFS partition warning no longer says metadata is unsupported; it says the free-space map is read and that encrypted (FileVault) volumes need the key.
|
||||
- The interface's report dialog explains that the file extension chooses PDF or HTML.
|
||||
|
||||
### Notes
|
||||
- Containers were checked by hash against an independent reader (7-Zip) on real images from dfvfs and VirtualBox, including differencing VHD and VHDX; the small fixtures in `tests/fixtures/vdisk` run in CI.
|
||||
- APFS deletion is still tested only in simulation, so APFS stays 🟡 in the roadmap.
|
||||
- CLI output remains in Portuguese.
|
||||
|
||||
## [1.1.0] - 2026-10-20
|
||||
|
||||
### Added
|
||||
- **Automated translation check** (`scripts/check-ui-i18n.mjs`): scans the interface JS, HTML and the labels emitted by the Rust core, fails on Portuguese text without an English entry and on duplicate dictionary keys. It runs in the CI lint job.
|
||||
- **Dependabot group for RustCrypto:** `sha2`, `sha1`, `md-5`, `hmac`, `pbkdf2`, `ripemd`, `aes`, `argon2`, `cipher` and `digest` now arrive in one update, because their traits are only compatible when the versions move together.
|
||||
|
||||
### Changed
|
||||
- **English interface for core messages:** the most common errors from the core now have `{n}` patterns in the dictionary, so paths and sizes are translated too.
|
||||
- **Notifications in the chosen language:** the core formats scan/recovery notifications using the saved interface language (`ui_lang` follows `lang` or the system locale when unset).
|
||||
- **ISO 8601 dates:** EXIF, PDF and MP4 metadata dates are emitted as `YYYY-MM-DDTHH:MM:SS` (MP4 with `Z`) and formatted by the interface in the selected locale, instead of a pre-formatted Brazilian date.
|
||||
- **RustCrypto on digest 0.11 / cipher 0.5:** `aes` 0.9, `sha2` 0.11, `sha1` 0.11, `md-5` 0.11, `hmac` 0.13, `pbkdf2` 0.13, `ripemd` 0.2 and `argon2` 0.6. BitLocker, LUKS and FileVault 2 were checked against the reference images.
|
||||
- **Release workflow:** `tauri-action` updated to v1; the publish job restores the release name and the CHANGELOG notes after the desktop builds, so v1 cannot overwrite them.
|
||||
- **Docker documentation:** `--device=...:r` was verified with a loop device (`inspect`, `scan`, `recover` read; writes are refused). The SMART/`SYS_RAWIO` wording now states what was and was not verified.
|
||||
- **Superset workspace:** setup, Run and teardown were verified in real workspaces; two interfaces get different ports, and deleting a workspace stops its `guara ui`.
|
||||
|
||||
### Fixed
|
||||
- Eight duplicated keys in `apps/gui/ui/i18n-en.js` (the last one won silently).
|
||||
- Untranslated fragments split by inline tags, metadata labels and preview messages that the previous manual pass missed.
|
||||
|
||||
### Notes
|
||||
- No change to scanning or recovery logic. The encrypted-volume tests pass with `GUARA_REQUIRE_FIXTURES=1`.
|
||||
- CLI output remains in Portuguese.
|
||||
|
||||
## [1.0.1] - 2026-10-20
|
||||
|
||||
### Added
|
||||
- **Docker image of the command line tool** (`Dockerfile`, [docs/docker.md](https://github.com/lucasrguerra/guara/blob/main/docs/docker.md)): for Linux hosts, servers without a desktop and forensic image files. The graphical interface is not served from the container.
|
||||
- **Interface screenshots** in the README and in the new [docs/interface.md](https://github.com/lucasrguerra/guara/blob/main/docs/interface.md), taken from the real interface on a synthetic test image.
|
||||
- **Superset workspace configuration** (`.superset/`): setup, run and teardown scripts for parallel workspaces.
|
||||
|
||||
### Changed
|
||||
- **Documentation:** English is now the main language. Portuguese versions live in `*.pt-BR.md` and `docs/pt-BR/`, with a language switch at the top of each file.
|
||||
- **English interface completed:** scan options, the disk image dialog, metadata labels (EXIF, PDF, MP4), file kinds, disk health fields, selection counters and preview messages were still showing in Portuguese.
|
||||
- **Language switch:** the explorer now redraws after changing the language in Settings, so dates and month headings follow it.
|
||||
- Dependencies: `getrandom` 0.4 and `windows-sys` 0.61. GitHub Actions updated (`checkout` 7, `setup-node` 7, `github-script` 9).
|
||||
|
||||
### Fixed
|
||||
- White text on a white background in the Language and Sort drop-down lists with the dark theme (including Auto with the system in dark mode).
|
||||
- The "Format" field of the disk image dialog was translated as "Form factor" in English.
|
||||
|
||||
### Notes
|
||||
- No change to scanning or recovery logic. The dependency updates touch the token of the local interface server (`getrandom`) and the Windows device bindings (`windows-sys`).
|
||||
- Error messages from the core, notifications and the terminal output are still in Portuguese.
|
||||
|
||||
## [1.0.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
- **File systems with metadata:** ext4 (deleted inodes and old copies in the journal), HFS+ (node leftovers and journal), Btrfs (old versions of the trees; zlib, zstd and lzo extents), XFS (freed inodes and names of freed entries) and APFS (snapshots and old nodes). ReFS is still only detected.
|
||||
- **Encrypted volumes:** BitLocker (password, recovery key, .BEK, clear key), LUKS1/LUKS2 and FileVault 2 (Core Storage) open with the password or key and are scanned inside. Passwords are never written to the session file.
|
||||
- **RAID and LVM:** RAID 0, 1, 4, 5, 6, 10 and linear from mdadm superblocks, including degraded; automatic discovery of level, stripe size, layout and order when there is no metadata; LVM2 (linear, striped, mirrored and RAID).
|
||||
- **Forensic images:** reading of E01/Ex01 and AFF4; writing of E01 and AFF4; `guara verify` command.
|
||||
- **Carving:** fragmented JPEG reassembled (up to three fragments), index reconstruction for MP4/MOV without moov (H.264/HEVC, with a reference video) and 24 formats recognized by structure.
|
||||
- **Interface:** fields for password, RAID, reference video and image format.
|
||||
|
||||
### Fixed
|
||||
- Reopening a recent session that is already open no longer creates identical copies in the sessions tab.
|
||||
|
||||
### Limitations
|
||||
- Storage Spaces and ReFS (metadata) are not supported; XFS and APFS tested with simulated deletions; audio is not reconstructed in MP4 without moov.
|
||||
|
||||
## [0.6.0] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- **CrystalDiskInfo-style disk health:** the device screen shows the status (Good, Caution, Bad) with the % of remaining life on SSDs, temperature, power-on hours, power cycles, total read and total written. It also shows the device sheet (model, firmware, serial number, interface, transfer mode, capacity, rotation, form factor and features such as TRIM) and the full table of SMART attributes, with ID, current value, worst, threshold and raw value, and an indicator per attribute. Works with ATA/SATA and NVMe disks (on NVMe, the health log fields become the table).
|
||||
- **Partitions:** when opening a partition (`/dev/sda1`, `/dev/nvme0n1p2`, `/dev/disk2s1`), SMART is read from the disk that contains it, and the screen says so.
|
||||
- **`guara health` command** in the terminal, with readable output or `--json`.
|
||||
|
||||
### Changed
|
||||
- A SMART attribute with a normalized value at or below the failure threshold now marks the disk as "Bad", even if the overall self-assessment still passes.
|
||||
- **New logo:** the head of a maned wolf cut out of the tracks of a disk, whole on one side (recovered) and broken on the other (lost), in the app colors. In the interface it follows the chosen theme (light or dark).
|
||||
|
||||
## [0.5.0] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- **Database viewer:** SQLite files (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) open in the preview with schema, paginated tables, blobs and recognized dates. Own reader, read-only, tolerant of truncated or corrupted files. It also **recovers deleted rows** from the free space of pages.
|
||||
- **Disk health (SMART):** before scanning, Guará queries `smartctl` (when installed) and the kernel error counter. If the disk looks faulty, it recommends making the image first and asks for confirmation before the deep scan.
|
||||
- **System notification** when a long scan or a recovery finishes, or pauses because of a disconnection.
|
||||
- **Resume an interrupted recovery:** a journal in the destination folder makes Guará skip what was already copied and checked; the result shows how many were skipped.
|
||||
- **Map of photos with GPS** in the details panel (optional, uses the internet) and a button to open it in OpenStreetMap.
|
||||
- **English interface**, with a language choice (Auto, Português, English) in the new Settings window, which also controls notifications and online maps.
|
||||
- **Closing the window during a scan** asks whether you want to pause and quit, quit without pausing, or continue.
|
||||
|
||||
### Notes
|
||||
- Error messages coming from the core and the terminal are still in Portuguese.
|
||||
|
||||
## [0.4.0] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- **Disk reconnection:** if the disk was disconnected and came back under another name (for example `/dev/sda` becoming `/dev/sdb`), Guará recognizes it by itself by its size and a fingerprint of the content (first and last MiB) and reattaches the session. If it cannot, the screen shows "Reconnect the disk" and "Choose the source...", with the list of connected disks and a "same disk" badge. A source with different content is refused, unless you tick "use anyway" (useful for copies).
|
||||
- **Disconnection in the middle of a scan:** if reads fail several times in a row, the scan pauses by itself with the exact point saved and tells you to reconnect and continue, instead of skipping blocks and finishing with holes.
|
||||
- **Sessions saved automatically** from time to time during the scan and when it finishes or pauses, in the user's configuration folder, and listed under "Recent" in the sidebar (`guara sessions` in the terminal). Atomic writes, with no risk of corrupting the session if the program closes midway.
|
||||
- **Organization when recovering:** original folders, by type, by date (year and month) or everything in the same folder (`--organize` in the terminal).
|
||||
- **Free space check** at the destination before copying, with a clear message if it will not fit.
|
||||
|
||||
### Fixed
|
||||
- In paused sessions there was no way to choose another source; only "Continue", which failed without the disk.
|
||||
|
||||
## [0.3.2] - 2026-10-18
|
||||
|
||||
### Fixed
|
||||
- **Freeze when clicking a video in the Linux app.** The AppImage ships WebKitGTK without the GStreamer media plugins: when a `<video>` or `<audio>` was created the page process died and the window froze. Now the Linux app never creates those elements; video and audio have an "Open in system player" button, which copies the file to a temporary folder of the user and opens it in the default player (even when Guará runs as administrator). In the browser (`guara ui`) and on other systems the video still plays inside the app, but only in the large view, never in the small panel.
|
||||
|
||||
### Changed
|
||||
- Clicking the preview in the details panel opens the large view (image, PDF, document and other types), now larger (up to 98% of the window width and 95% of its height).
|
||||
|
||||
## [0.3.1] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- "Hide small files (under 30 KB)" and "Over 1 megapixel" filters in the explorer, to separate photos from icons and thumbnails when there are thousands of images.
|
||||
|
||||
### Fixed
|
||||
- Opening a saved session failed entirely when the source (for example `/dev/sdb`) was not accessible, and the error message was vague. Now the session always opens: you can browse, export and generate a report, and the "Choose the source..." button reconnects the disk or an image to go back to previewing and recovering.
|
||||
- Clear messages when the session file cannot be read (for example, created as administrator in `/root`) or is not a valid session.
|
||||
- An automatic test ensures that sessions written by v0.1.0 still open.
|
||||
- PNG carving: ASCII text right after a truncated PNG was read as a block of about 1.9 GB and produced giant low-confidence "photos". Now the block must be plausible (valid type, reasonable size, inside the disk) and the file ends at the last valid block.
|
||||
- Resuming a scan of a session without the source available now warns immediately, instead of failing later.
|
||||
- When running as administrator (sudo/pkexec), the suggested folders are now those of the user who launched Guará, and everything it writes (sessions, CSV, reports, images and recovered files, with the folders created) keeps the owner and permissions of that user, not root.
|
||||
|
||||
## [0.3.0] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- **File explorer:** thumbnail grid and list with virtual scrolling (thousands of items), grouping by day/month, sidebar navigation by type, photo date, folder, camera and confidence, selection with Ctrl/Shift/keyboard, details panel and full-screen quick look.
|
||||
- **Fast thumbnails** generated in the core (JPEG with downscaling during decoding, PNG, GIF, BMP, WebP), with EXIF orientation and use of the embedded thumbnail when the image is damaged.
|
||||
- **Metadata:** EXIF (camera, lens, dates, GPS), PDF, Office/OpenDocument and MP4/MOV; creation, modification and access dates from NTFS, FAT and exFAT.
|
||||
- **Forensics:** MD5, SHA-1 and SHA-256 hashes, `guara hash`, `guara search` (text across the whole disk in UTF-8 and UTF-16), `guara export` (CSV), `guara report` (HTML report with chain of custody) and duplicate detection. All of it also in the graphical interface.
|
||||
- New visual identity: a pixel logo that comes together into a maned wolf with a smooth silhouette.
|
||||
|
||||
### Changed
|
||||
- The bottom bar and the progress panel are no longer recreated on every update.
|
||||
- Text without em dashes.
|
||||
|
||||
## [0.2.0] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- **Full preview:** image (zoom), video (MP4 and others), audio, PDF (pdf.js included, works offline), text, Word/Excel/PowerPoint/OpenDocument, ZIP contents and hex; full screen with keyboard navigation (↑ ↓ ← → Space Esc). Content is read on demand with Range support, without writing to disk.
|
||||
- **Auto / Light / Dark** theme selector; the choice is saved and also applies to the app restarted as administrator.
|
||||
- New teal visual identity, with a pixel logo that comes together into a maned wolf.
|
||||
|
||||
### Fixed
|
||||
- Restarting as administrator on Linux: in the AppImage the executable sits on a mount point inaccessible to root; now it uses the `.AppImage` itself, shows the reason if it fails and closes the old app only when the new one opens.
|
||||
- The bottom action bar flickered on every update; now it is created once and only the text updates. The progress panel also stopped being recreated on every cycle.
|
||||
- FAT32 on small media (fewer than 65,525 clusters) was not recognized.
|
||||
- The theme did not follow the choice when opened as root (root's WebView used another system theme).
|
||||
|
||||
## [0.1.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- Núcleo em Rust, somente leitura na origem: acesso a dispositivos e imagens (Windows, Linux, macOS).
|
||||
- Leitura de MBR/GPT, detecção de partições perdidas e de espaço não particionado.
|
||||
- Metadados de NTFS, FAT12/16/32 e exFAT: arquivos apagados com nome, pasta e data.
|
||||
- Carving por estrutura de JPEG, PNG, GIF, BMP, PDF, ZIP e derivados (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 e PE.
|
||||
- Mapa de blocos livres do ext2/3/4 para varrer apenas o espaço livre.
|
||||
- Fusão sem duplicatas e confiança (alta/média/baixa) por arquivo.
|
||||
- Detecção de volumes cifrados (BitLocker, LUKS) e aviso de TRIM em SSD.
|
||||
- Imagem forense tolerante a setores ruins, pausar/retomar varredura e sessões salvas.
|
||||
- Recuperação com SHA-256 e manifesto; recusa de destino no disco de origem.
|
||||
- CLI `guara` e interface gráfica (Tauri) com tema claro/escuro e layout responsivo; modo `guara ui` no navegador.
|
||||
- CI (Linux/Windows/macOS) e releases automáticas por tag.
|
||||
### Added
|
||||
- Rust core, read-only on the source: access to devices and images (Windows, Linux, macOS).
|
||||
- Reading of MBR/GPT, detection of lost partitions and unpartitioned space.
|
||||
- Metadata from NTFS, FAT12/16/32 and exFAT: deleted files with name, folder and date.
|
||||
- Structure-aware carving of JPEG, PNG, GIF, BMP, PDF, ZIP and derivatives (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 and PE.
|
||||
- Free block map of ext2/3/4 to scan only the free space.
|
||||
- Merging without duplicates and per-file confidence (high/medium/low).
|
||||
- Detection of encrypted volumes (BitLocker, LUKS) and TRIM warning on SSDs.
|
||||
- Forensic image tolerant of bad sectors, pause/resume of scans and saved sessions.
|
||||
- Recovery with SHA-256 and manifest; refusal of a destination on the source disk.
|
||||
- `guara` CLI and graphical interface (Tauri) with light/dark theme and responsive layout; `guara ui` mode in the browser.
|
||||
- CI (Linux/Windows/macOS) and automatic releases by tag.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Changelog
|
||||
|
||||
[English](CHANGELOG.md) · **Português**
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [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.
|
||||
|
||||
### Alterado
|
||||
- **As atualizações agora vêm do Gitea.** O cartão de atualização do app
|
||||
desktop lê o `latest.json` em `git.lucasrguerra.dev.br`, e não mais as
|
||||
Releases do GitHub. As versões até a 1.3.1 ainda olham o GitHub, que não
|
||||
responde mais, e por isso não enxergam esta release: baixe a 1.3.2 uma vez, e
|
||||
as próximas atualizações são automáticas.
|
||||
- O CLI é publicado só para Linux x86_64 e aarch64, compilado pelo CI do Gitea
|
||||
quando uma tag `v*` é enviada, com um `.sha256` ao lado de cada pacote. **Não
|
||||
há mais CLI para Windows.** O app desktop continua saindo para Linux
|
||||
(AppImage, `.deb`) e Windows (`.exe` NSIS, compilado a partir do Linux). Não
|
||||
há build para macOS: exige o SDK e a assinatura da Apple, que o CI deste
|
||||
projeto (um único runner Linux) não tem.
|
||||
|
||||
## [1.3.1] - 2026-10-23
|
||||
|
||||
### Alterado
|
||||
- **Logo oficial nova:** o guará dentro do anel laranja, desenhado pelo autor do projeto, com fundo transparente para a mesma arte servir aos temas claro e escuro. Ela substitui a logo antiga das trilhas de disco no app (barra lateral, topo e tela inicial), no favicon, nos ícones do app desktop e no README.
|
||||
- O `guara ui` serve os novos `guara.png` e `guara-s.png`; o `scripts/gerar-logo.py` deriva a logo pequena e o ícone do app da arte oficial.
|
||||
- Desenvolvimento: o `scripts/verificar.sh` roda localmente o que o CI roda, com um hook opcional de pre-push (`scripts/instalar-hooks.sh`), e o CI deixa de rodar em mudanças só de documentação.
|
||||
|
||||
### Notas
|
||||
- Nenhuma mudança na varredura, na recuperação, na assinatura ou em qualquer outro comportamento. É a primeira versão depois do atualizador do app: um app na 1.3.0 deve oferecer **Atualizar agora**.
|
||||
- Os screenshots em `docs/img/` ainda mostram a logo antiga e serão refeitos.
|
||||
|
||||
## [1.3.0] - 2026-10-23
|
||||
|
||||
### Adicionado
|
||||
- **Atualização dentro do app desktop.** Ao abrir, o app pede ao GitHub o `latest.json` da release; havendo versão nova, um cartão oferece **Atualizar agora**. Ele baixa o pacote, confere a assinatura minisign com a chave pública embutida no app, instala e reabre (o AppImage é substituído no lugar; Windows e macOS usam o instalador). Instalações por `.deb` ou `.rpm` só mostram o aviso. **Configurações** desliga a consulta ao abrir e tem o botão **Verificar agora**. A consulta é uma requisição HTTP e não envia nada seu.
|
||||
- **Assinatura digital (Ed25519):** `guara sign keygen | file | verify | recovery`. Com uma chave configurada, `recover` e `report` assinam sozinhos o manifesto e o relatório. `guara sign recovery` confere a assinatura do manifesto e o hash de cada arquivo recuperado (e recusa caminhos do manifesto que saem da pasta). É Ed25519 comum sobre uma mensagem documentada, então o `openssl` também confere. Comparar a impressão digital da chave (`--trust`) diz de quem ela é; a hora assinada é a do relógio local, não um carimbo de terceiros.
|
||||
- **Discos virtuais:** QCOW2 com arquivo de apoio, snapshots de VMDK (pai por nome ou, no VirtualBox, por UUID; o filho é recusado se o CID do pai mudou) e DMG com chunks LZFSE.
|
||||
- **CI com imagens de referência reais** em Linux, macOS e Windows (antes eram puladas) e uma imagem XFS apagada pelo kernel de verdade, gerada com `sudo` no runner Linux.
|
||||
- Pipeline de release: os pacotes de atualização são assinados com uma chave guardada no ambiente `release` do repositório (só tags `v*`), e o `latest.json` é montado de uma vez por `scripts/make-latest-json.py`.
|
||||
|
||||
### Alterado
|
||||
- **O XFS passa a 🟡.** Num XFS real (v5, exclusão do kernel) o inode e a lista de extensões são zerados, então o caminho por metadados não acha nada; o conteúdo de arquivos com estrutura reconhecível volta por carving, sem nome nem pasta. O caminho anterior só funcionava na exclusão simulada. O teste de nome e pasta fica `ignore`d até o diário ser lido.
|
||||
- Os passos de `apt` do CI usam `.deb` em cache, tempo limite por tentativa e nova tentativa (levavam até 28 minutos num dia de espelho lento).
|
||||
- Dependências: `ctutils` 0.4.5 (RUSTSEC-2026-0385); novas `ed25519-compact`, `lzfse_rust` e `tauri-plugin-updater`.
|
||||
- O README e o ROADMAP passam a dizer que a verificação de atualização é o único uso da rede por padrão.
|
||||
|
||||
### Notas
|
||||
- As versões anteriores à 1.3.0 não têm o atualizador: da 1.2.0, troque o AppImage à mão uma última vez. A primeira atualização dentro do app é da 1.3.0 para a próxima versão.
|
||||
- Ainda faltam: DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2, carimbo de tempo de terceiros (RFC 3161), leitura do diário do XFS e validação de APFS com exclusão real (precisa de imagem de um Mac de verdade).
|
||||
- A saída da CLI continua em português.
|
||||
|
||||
## [1.2.0] - 2026-10-22
|
||||
|
||||
### Adicionado
|
||||
- **Contêineres de disco virtual (somente leitura):** QCOW2 (v2 e v3, clusters comprimidos com deflate ou zstd), VMDK (esparso, `streamOptimized` e descritores de texto com extensões flat e zero), VHD e VHDX (fixo, dinâmico e diferencial, seguindo a cadeia de pais) e DMG (UDIF com chunks zerados, brutos, ADC, zlib e bzip2). Abrem pelo mesmo caminho do E01 e do AFF4, então partições, sistemas de arquivos e volumes cifrados funcionam dentro deles. Imagens que seriam lidas errado são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai ou em SEsparse, DMG protegido por senha, DMG com chunks LZFSE. O log do VHDX não é reproduzido.
|
||||
- **Trilha de auditoria** (`auditoria.jsonl`, `guara audit`): toda ação sobre a evidência (`inspect`, `scan`, `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões) é registrada com hora UTC, usuário do sistema, máquina, versão e detalhes, encadeada por SHA-256. `guara audit --verify` aponta a entrada onde a cadeia quebra. Defina `GUARA_EXAMINER` para registrar o perito; senhas e chaves nunca são gravadas.
|
||||
- **Relatório em PDF:** `guara report saida.pdf` (e o botão de relatório da interface, pela extensão do arquivo) gera um PDF A4 com as mesmas seções do HTML, com gerador próprio, fontes padrão e nenhuma dependência nova. Os dois formatos passam a mostrar a situação da trilha de auditoria e o hash da última entrada.
|
||||
- **Mapa de espaço livre do APFS:** o gerenciador de espaço do contêiner (spaceman), os chunk-info blocks e os bitmaps são lidos, e a varredura profunda de um volume APFS cobre só os blocos não alocados. Contêineres que usam blocos de endereço de chunk-info voltam a varrer o volume todo, com aviso.
|
||||
- `scripts/fetch-testdata.sh` também baixa as imagens de disco virtual do dfvfs usadas nos testes dos contêineres.
|
||||
|
||||
### Alterado
|
||||
- O aviso da partição APFS deixa de dizer que não há suporte a metadados; informa que o mapa de espaço livre é lido e que volumes cifrados (FileVault) precisam da chave.
|
||||
- O diálogo de relatório da interface explica que a extensão do arquivo escolhe PDF ou HTML.
|
||||
|
||||
### Notas
|
||||
- Os contêineres foram conferidos por hash contra um leitor independente (7-Zip) em imagens reais do dfvfs e do VirtualBox, inclusive VHD e VHDX diferenciais; os fixtures pequenos de `tests/fixtures/vdisk` rodam no CI.
|
||||
- A exclusão no APFS continua testada só em simulação, por isso o APFS segue 🟡 no roadmap.
|
||||
- A saída da CLI continua em português.
|
||||
|
||||
## [1.1.0] - 2026-10-20
|
||||
|
||||
### Adicionado
|
||||
- **Verificação automática da tradução** (`scripts/check-ui-i18n.mjs`): varre o JS e o HTML da interface e os rótulos emitidos pelo núcleo em Rust, falha quando há texto em português sem entrada em inglês e quando há chave duplicada no dicionário. Roda no job de lint do CI.
|
||||
- **Grupo RustCrypto no Dependabot:** `sha2`, `sha1`, `md-5`, `hmac`, `pbkdf2`, `ripemd`, `aes`, `argon2`, `cipher` e `digest` passam a chegar numa única atualização, porque as traits só são compatíveis quando as versões andam juntas.
|
||||
|
||||
### Alterado
|
||||
- **Interface em inglês para mensagens do núcleo:** os erros mais comuns do núcleo agora têm padrões com `{n}` no dicionário, então caminhos e tamanhos também são traduzidos.
|
||||
- **Notificações no idioma escolhido:** o núcleo monta as notificações de varredura e recuperação usando o idioma salvo da interface (`ui_lang` segue `lang` ou o locale do sistema quando não definido).
|
||||
- **Datas em ISO 8601:** as datas de metadados EXIF, PDF e MP4 saem como `YYYY-MM-DDTHH:MM:SS` (MP4 com `Z`) e são formatadas pela interface no locale escolhido, em vez de uma data brasileira já pronta.
|
||||
- **RustCrypto em digest 0.11 / cipher 0.5:** `aes` 0.9, `sha2` 0.11, `sha1` 0.11, `md-5` 0.11, `hmac` 0.13, `pbkdf2` 0.13, `ripemd` 0.2 e `argon2` 0.6. BitLocker, LUKS e FileVault 2 foram conferidos contra as imagens de referência.
|
||||
- **Fluxo de release:** `tauri-action` atualizado para v1; o job de publicação restaura o nome e as notas do CHANGELOG depois dos builds do desktop, para a v1 não sobrescrevê-las.
|
||||
- **Documentação do Docker:** `--device=...:r` foi verificado com um dispositivo de loop (`inspect`, `scan`, `recover` leem; escritas são recusadas). O texto sobre SMART/`SYS_RAWIO` agora diz o que foi e o que não foi verificado.
|
||||
- **Workspace do Superset:** setup, Run e teardown foram verificados em workspaces reais; duas interfaces recebem portas diferentes, e apagar um workspace encerra o `guara ui`.
|
||||
|
||||
### Corrigido
|
||||
- Oito chaves duplicadas em `apps/gui/ui/i18n-en.js` (a última vencia em silêncio).
|
||||
- Trechos sem tradução separados por tags inline, rótulos de metadados e mensagens da pré-visualização que a passada manual anterior não pegou.
|
||||
|
||||
### Notas
|
||||
- Nenhuma mudança na lógica de varredura ou recuperação. Os testes de volumes cifrados passam com `GUARA_REQUIRE_FIXTURES=1`.
|
||||
- A saída do terminal continua em português.
|
||||
|
||||
## [1.0.1] - 2026-10-20
|
||||
|
||||
### Adicionado
|
||||
- **Imagem Docker do terminal** (`Dockerfile`, [docs/pt-BR/docker.md](https://github.com/lucasrguerra/guara/blob/main/docs/pt-BR/docker.md)): para hospedeiros Linux, servidores sem desktop e arquivos de imagem forense. A interface gráfica não é servida pelo container.
|
||||
- **Telas da interface** no README e na nova [docs/pt-BR/interface.md](https://github.com/lucasrguerra/guara/blob/main/docs/pt-BR/interface.md), tiradas da interface real sobre uma imagem de teste sintética.
|
||||
- **Configuração de workspaces do Superset** (`.superset/`): scripts de setup, run e teardown para workspaces em paralelo.
|
||||
|
||||
### Alterado
|
||||
- **Documentação:** o inglês passa a ser o idioma principal. As versões em português ficam em `*.pt-BR.md` e `docs/pt-BR/`, com seletor de idioma no topo de cada arquivo.
|
||||
- **Interface em inglês completada:** opções de varredura, diálogo de imagem de disco, rótulos de metadados (EXIF, PDF, MP4), tipos de arquivo, campos de saúde do disco, contadores de seleção e mensagens da pré-visualização ainda apareciam em português.
|
||||
- **Troca de idioma:** o explorador agora se redesenha ao mudar o idioma em Configurações, e as datas e os títulos de mês acompanham.
|
||||
- Dependências: `getrandom` 0.4 e `windows-sys` 0.61. GitHub Actions atualizadas (`checkout` 7, `setup-node` 7, `github-script` 9).
|
||||
|
||||
### Corrigido
|
||||
- Texto branco sobre fundo branco nas listas de Idioma e Ordenar com o tema escuro (inclusive Auto com o sistema em escuro).
|
||||
- O campo "Formato" do diálogo de imagem de disco era traduzido como "Form factor" em inglês.
|
||||
|
||||
### Notas
|
||||
- Nenhuma mudança na lógica de varredura ou de recuperação. As atualizações de dependências tocam o token do servidor local da interface (`getrandom`) e as ligações de dispositivo do Windows (`windows-sys`).
|
||||
- Mensagens de erro do núcleo, notificações e a saída do terminal continuam em português.
|
||||
|
||||
## [1.0.0] - 2026-10-19
|
||||
|
||||
### Adicionado
|
||||
- **Sistemas de arquivos com metadados:** ext4 (inodes apagados e cópias antigas no diário), HFS+ (sobras de nós e diário), Btrfs (versões antigas das árvores; extensões zlib, zstd e lzo), XFS (inodes liberados e nomes de entradas liberadas) e APFS (instantâneos e nós antigos). ReFS ainda é só detectado.
|
||||
- **Volumes cifrados:** BitLocker (senha, chave de recuperação, .BEK, chave limpa), LUKS1/LUKS2 e FileVault 2 (Core Storage) abrem com a senha ou a chave e são varridos por dentro. As senhas nunca vão para o arquivo de sessão.
|
||||
- **RAID e LVM:** RAID 0, 1, 4, 5, 6, 10 e linear a partir de superblocos do mdadm, inclusive degradado; descoberta automática de nível, faixa, layout e ordem quando não há metadados; LVM2 (linear, em faixas, espelhado e RAID).
|
||||
- **Imagens forenses:** leitura de E01/Ex01 e AFF4; gravação de E01 e AFF4; comando `guara verify`.
|
||||
- **Carving:** JPEG fragmentado remontado (até três fragmentos), reconstrução do índice de MP4/MOV sem moov (H.264/HEVC, com vídeo de referência) e 24 formatos reconhecidos pela estrutura.
|
||||
- **Interface:** campos de senha, RAID, vídeo de referência e formato da imagem.
|
||||
|
||||
### Corrigido
|
||||
- Abrir de novo uma sessão recente já aberta não cria mais cópias iguais na aba de sessões.
|
||||
|
||||
### Limitações
|
||||
- Storage Spaces e ReFS (metadados) não suportados; XFS e APFS testados com exclusões simuladas; áudio não é reconstruído em MP4 sem moov.
|
||||
|
||||
## [0.6.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Saúde do disco no estilo do CrystalDiskInfo:** a tela do dispositivo mostra o estado (Bom, Atenção, Ruim) com a % de vida útil restante em SSD, temperatura, horas ligado, vezes ligado, total lido e total gravado. Também traz a ficha do dispositivo (modelo, firmware, nº de série, interface, modo de transferência, capacidade, rotação, formato e recursos como TRIM) e a tabela completa de atributos SMART, com ID, valor atual, pior, limite e bruto, e um indicador por atributo. Funciona com discos ATA/SATA e NVMe (nos NVMe, os campos do log de saúde viram a tabela).
|
||||
- **Partições:** ao abrir uma partição (`/dev/sda1`, `/dev/nvme0n1p2`, `/dev/disk2s1`), o SMART é lido do disco que a contém, e a tela avisa isso.
|
||||
- **Comando `guara health`** no terminal, com saída legível ou `--json`.
|
||||
|
||||
### Alterado
|
||||
- Um atributo SMART com valor normalizado no limite de falha (ou abaixo dele) agora marca o disco como "Ruim", mesmo que a autoavaliação geral ainda passe.
|
||||
- **Nova logo:** a cabeça do lobo-guará recortada das trilhas de um disco, inteiras de um lado (recuperado) e quebradas do outro (perdido), nas cores do app. Na interface ela acompanha o tema escolhido (claro ou escuro).
|
||||
|
||||
## [0.5.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Visualizador de bancos de dados:** arquivos SQLite (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) abrem na pré-visualização com esquema, tabelas paginadas, blobs e datas reconhecidas. Leitor próprio, só leitura, tolerante a arquivos truncados ou corrompidos. Também **recupera linhas apagadas** a partir do espaço livre das páginas.
|
||||
- **Saúde do disco (SMART):** antes de varrer, o Guará consulta o `smartctl` (quando instalado) e o contador de erros do kernel. Se o disco parece com defeito, recomenda criar a imagem primeiro e pede confirmação antes da varredura profunda.
|
||||
- **Notificação do sistema** quando uma varredura longa ou uma recuperação termina, ou pausa por desconexão.
|
||||
- **Retomar recuperação interrompida:** um diário na pasta de destino faz o Guará pular o que já foi copiado e conferido; o resultado mostra quantos foram pulados.
|
||||
- **Mapa das fotos com GPS** no painel de detalhes (opcional, usa a internet) e botão para abrir no OpenStreetMap.
|
||||
- **Interface em inglês**, com escolha de idioma (Auto, Português, English) na nova janela de Configurações, que também controla notificações e mapas online.
|
||||
- **Fechar a janela durante a varredura** pergunta se você quer pausar e sair, sair sem pausar ou continuar.
|
||||
|
||||
### Notas
|
||||
- Mensagens de erro vindas do núcleo e o terminal continuam em português.
|
||||
|
||||
## [0.4.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Reconexão do disco:** se o disco foi desconectado e voltou com outro nome (por exemplo `/dev/sda` virando `/dev/sdb`), o Guará o reconhece sozinho pelo tamanho e pela impressão digital do conteúdo (primeiro e último MiB) e religa a sessão. Se não conseguir, a tela mostra "Reconectar o disco" e "Escolher a origem…", com a lista dos discos conectados e o selo "mesmo disco". Uma origem com conteúdo diferente é recusada, a menos que você marque "usar mesmo assim" (útil para cópias).
|
||||
- **Desconexão no meio da varredura:** se as leituras falharem várias vezes seguidas, a varredura pausa sozinha com o ponto exato salvo e avisa para reconectar e continuar, em vez de seguir pulando blocos e terminar com buracos.
|
||||
- **Sessões salvas automaticamente** de tempos em tempos durante a varredura e ao terminar ou pausar, na pasta de configuração do usuário, e listadas em "Recentes" na barra lateral (`guara sessions` no terminal). Gravação atômica, sem risco de corromper a sessão se o programa fechar no meio.
|
||||
- **Organização ao recuperar:** pastas originais, por tipo, por data (ano e mês) ou tudo na mesma pasta (`--organize` no terminal).
|
||||
- **Verificação de espaço livre** no destino antes de copiar, com mensagem clara se não couber.
|
||||
|
||||
### Corrigido
|
||||
- Em sessões pausadas não havia como escolher outra origem; só "Continuar", que falhava sem o disco.
|
||||
|
||||
## [0.3.2] - 2026-10-18
|
||||
|
||||
### Corrigido
|
||||
- **Travamento ao clicar em um vídeo no app para Linux.** O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer: ao criar um `<video>` ou `<audio>` o processo da página morria e a janela ficava congelada. Agora o app para Linux nunca cria esses elementos; vídeo e áudio têm o botão "Abrir no player do sistema", que copia o arquivo para uma pasta temporária do usuário e o abre no player padrão (inclusive quando o Guará roda como administrador). No navegador (`guara ui`) e nos demais sistemas o vídeo continua tocando dentro do app, mas só na tela grande, nunca no painel pequeno.
|
||||
|
||||
### Alterado
|
||||
- Clicar na pré-visualização do painel de detalhes abre a visualização grande (imagem, PDF, documento e demais tipos), agora maior (até 98% da largura e 95% da altura da janela).
|
||||
|
||||
## [0.3.1] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
|
||||
|
||||
### Corrigido
|
||||
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
|
||||
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
|
||||
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
|
||||
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
|
||||
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
|
||||
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
|
||||
|
||||
## [0.3.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Explorador de arquivos:** grade de miniaturas e lista com rolagem virtual (milhares de itens), agrupamento por dia/mês, navegação lateral por tipo, data da foto, pasta, câmera e confiança, seleção com Ctrl/Shift/teclado, painel de detalhes e visualização rápida em tela cheia.
|
||||
- **Miniaturas rápidas** geradas no núcleo (JPEG com redução na decodificação, PNG, GIF, BMP, WebP), com orientação EXIF e uso da miniatura embutida quando a imagem está danificada.
|
||||
- **Metadados:** EXIF (câmera, lente, datas, GPS), PDF, Office/OpenDocument e MP4/MOV; datas de criação, modificação e acesso do NTFS, FAT e exFAT.
|
||||
- **Perícia:** hashes MD5, SHA-1 e SHA-256, `guara hash`, `guara search` (texto no disco inteiro em UTF-8 e UTF-16), `guara export` (CSV), `guara report` (relatório HTML com cadeia de custódia) e detecção de duplicadas. Tudo também na interface gráfica.
|
||||
- Nova identidade visual: logo de pixels que se juntam num lobo-guará de silhueta lisa.
|
||||
|
||||
### Alterado
|
||||
- A barra inferior e o painel de progresso não são mais recriados a cada atualização.
|
||||
- Textos sem travessões.
|
||||
|
||||
## [0.2.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Pré-visualização completa:** imagem (zoom), vídeo (MP4 e outros), áudio, PDF (pdf.js incluso, funciona offline), texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP e hexadecimal; tela cheia com navegação por teclado (↑ ↓ ← → Espaço Esc). O conteúdo é lido sob demanda com suporte a Range, sem gravar em disco.
|
||||
- Seletor de tema **Auto / Claro / Escuro**; a escolha é salva e vale também no app reiniciado como administrador.
|
||||
- Nova identidade visual em verde-azulado, com logo de pixels que se juntam até formar um lobo-guará.
|
||||
|
||||
### Corrigido
|
||||
- Reiniciar como administrador no Linux: no AppImage o executável fica num ponto de montagem inacessível ao root; agora usa o próprio `.AppImage`, mostra o motivo se falhar e fecha o app antigo só quando o novo abre.
|
||||
- Barra de ações inferior piscava a cada atualização; agora é criada uma vez e só atualiza o texto. O painel de progresso também deixou de ser recriado a cada ciclo.
|
||||
- FAT32 em mídias pequenas (menos de 65 525 clusters) não era reconhecido.
|
||||
- O tema não acompanhava a escolha ao abrir como root (o WebView do root usava outro tema do sistema).
|
||||
|
||||
## [0.1.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- Núcleo em Rust, somente leitura na origem: acesso a dispositivos e imagens (Windows, Linux, macOS).
|
||||
- Leitura de MBR/GPT, detecção de partições perdidas e de espaço não particionado.
|
||||
- Metadados de NTFS, FAT12/16/32 e exFAT: arquivos apagados com nome, pasta e data.
|
||||
- Carving por estrutura de JPEG, PNG, GIF, BMP, PDF, ZIP e derivados (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 e PE.
|
||||
- Mapa de blocos livres do ext2/3/4 para varrer apenas o espaço livre.
|
||||
- Fusão sem duplicatas e confiança (alta/média/baixa) por arquivo.
|
||||
- Detecção de volumes cifrados (BitLocker, LUKS) e aviso de TRIM em SSD.
|
||||
- Imagem forense tolerante a setores ruins, pausar/retomar varredura e sessões salvas.
|
||||
- Recuperação com SHA-256 e manifesto; recusa de destino no disco de origem.
|
||||
- CLI `guara` e interface gráfica (Tauri) com tema claro/escuro e layout responsivo; modo `guara ui` no navegador.
|
||||
- CI (Linux/Windows/macOS) e releases automáticas por tag.
|
||||
@@ -1,25 +1,52 @@
|
||||
# Contribuindo com o Guará
|
||||
# Contributing to Guará
|
||||
|
||||
Obrigado por ajudar! Regras de ouro:
|
||||
[English](CONTRIBUTING.md) · [Português](CONTRIBUTING.pt-BR.md)
|
||||
|
||||
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.
|
||||
Thanks for helping! Golden rules:
|
||||
|
||||
## Fluxo
|
||||
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
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test --workspace
|
||||
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
|
||||
```
|
||||
|
||||
No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` e `python3`. Sem essas ferramentas os testes com imagem são ignorados (defina `GUARA_REQUIRE_FIXTURES=1` para falhar).
|
||||
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.
|
||||
|
||||
## Adicionando um formato de arquivo
|
||||
On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` and `python3`. Without these tools the image tests are skipped (set `GUARA_REQUIRE_FIXTURES=1` to make them fail).
|
||||
|
||||
Edite `crates/guara-core/src/signatures.rs`: acrescente o detector em `detect`, o primeiro byte em `might_start`, a extensão em `category_for_ext`/`matches_ext` e um arquivo real ao `scripts/make-fixtures.sh` com teste de bit-exatidão.
|
||||
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.
|
||||
|
||||
## Releases and Versioning
|
||||
|
||||
Versions strictly follow **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH`), synchronized across `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml`, and `apps/gui/src-tauri/tauri.conf.json` (enforced by `scripts/check.sh`).
|
||||
|
||||
Pushing a `v*` tag makes the Gitea CI create the release (notes come from `CHANGELOG.md`, so update it first) 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.
|
||||
|
||||
## Where to make changes
|
||||
|
||||
A map of the modules is in [docs/architecture.md](docs/architecture.md). What is missing and what is welcome is in the [ROADMAP](ROADMAP.md).
|
||||
|
||||
## Adding a file format
|
||||
|
||||
Simple detectors live in `crates/guara-core/src/signatures.rs`; the newer ones in `formats.rs`. Add the detector to `detect`, the first byte to `might_start`, the extension to `category_for_ext`/`matches_ext` and a real file to `scripts/make-fixtures.sh` with a bit-exactness test.
|
||||
|
||||
## Adding a file system
|
||||
|
||||
Create the reader in `crates/guara-core/src/fs/`, register it in `fs/mod.rs` and bring a test with a real or reference image. **We do not ship a reader without validation**: that is why ReFS and Storage Spaces do not exist yet. Small images without personal data are the best gift you can give the project.
|
||||
|
||||
## Documentation
|
||||
|
||||
The main version is in English and every file has a Portuguese translation (`*.pt-BR.md`, `docs/pt-BR/`). No em dashes or en dashes. When you change a feature, update both versions (English and Portuguese) of [docs/support.md](docs/support.md), the [CHANGELOG](CHANGELOG.md) and, if applicable, the [ROADMAP](ROADMAP.md).
|
||||
|
||||
## Commits
|
||||
|
||||
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por 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`.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Contribuindo com o Guará
|
||||
|
||||
[English](CONTRIBUTING.md) · **Português**
|
||||
|
||||
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/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.
|
||||
|
||||
No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` e `python3`. Sem essas ferramentas os testes com imagem são ignorados (defina `GUARA_REQUIRE_FIXTURES=1` para falhar).
|
||||
|
||||
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.
|
||||
|
||||
## Releases e Versionamento
|
||||
|
||||
As versões seguem estritamente o **Versionamento Semântico 2.0.0** (`MAJOR.MINOR.PATCH`), sincronizadas entre `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml` e `apps/gui/src-tauri/tauri.conf.json` (validado por `scripts/check.sh`).
|
||||
|
||||
Enviar uma tag `v*` faz o CI do Gitea criar a release (as notas vêm do `CHANGELOG.md`, então atualize-o antes) 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.
|
||||
|
||||
## Onde mexer
|
||||
|
||||
Mapa dos módulos em [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). O que falta e o que é bem-vindo está no [ROADMAP](ROADMAP.pt-BR.md).
|
||||
|
||||
## Adicionando um formato de arquivo
|
||||
|
||||
Detectores simples ficam em `crates/guara-core/src/signatures.rs`; os mais novos, em `formats.rs`. Acrescente o detector em `detect`, o primeiro byte em `might_start`, a extensão em `category_for_ext`/`matches_ext` e um arquivo real ao `scripts/make-fixtures.sh` com teste de bit-exatidão.
|
||||
|
||||
## Adicionando um sistema de arquivos
|
||||
|
||||
Crie o leitor em `crates/guara-core/src/fs/`, registre-o em `fs/mod.rs` e traga um teste com imagem real ou de referência. **Não entregamos leitor sem validação**: é por isso que ReFS e Storage Spaces ainda não existem. Imagens pequenas e sem dados pessoais são o melhor presente que se pode dar ao projeto.
|
||||
|
||||
## Documentação
|
||||
|
||||
A versão principal é em inglês e cada arquivo tem uma tradução em português (`*.pt-BR.md`, `docs/pt-BR/`). Sem travessões. Ao mudar um recurso, atualize as duas versões (inglês e português) de [docs/support.md](docs/support.md), do [CHANGELOG](CHANGELOG.md) e, se for o caso, do [ROADMAP](ROADMAP.md).
|
||||
|
||||
## Commits
|
||||
|
||||
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`.
|
||||
@@ -3,10 +3,10 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "1.3.3"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
|
||||
authors = ["Guará contributors"]
|
||||
rust-version = "1.80"
|
||||
|
||||
@@ -16,3 +16,7 @@ codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
# Optimize dependencies in debug builds as well: cipher tests run millions of SHA-256 rounds.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Guará CLI image. Intended for Linux hosts, headless servers and forensic image files.
|
||||
# The graphical interface is not served from the container (see docs/docker.md).
|
||||
|
||||
FROM rust:1-slim-bookworm AS build
|
||||
WORKDIR /src
|
||||
# The CLI embeds the web interface at compile time (include_str!), so it needs apps/gui/ui.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates crates
|
||||
COPY apps/gui/ui apps/gui/ui
|
||||
RUN cargo build --release --locked -p guara-cli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
# smartmontools gives `guara health` access to SMART data.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends smartmontools ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/target/release/guara /usr/local/bin/guara
|
||||
LABEL org.opencontainers.image.title="Guará" \
|
||||
org.opencontainers.image.description="File recovery for hard drives, SSDs and USB sticks (CLI)" \
|
||||
org.opencontainers.image.source="https://git.lucasrguerra.dev.br/lucasrguerra/guara" \
|
||||
org.opencontainers.image.licenses="MIT OR Apache-2.0"
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["guara"]
|
||||
CMD ["--help"]
|
||||
@@ -1,91 +1,144 @@
|
||||
<p align="center"><img src="apps/gui/ui/guara.svg" width="96" alt="Guará"></p>
|
||||
<p align="center"><img src="logo.png" width="300" alt="Guará"></p>
|
||||
<h1 align="center">Guará</h1>
|
||||
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
|
||||
<p align="center">Free file recovery for hard drives, SSDs and USB sticks.<br>Terminal and graphical interface · Windows and Linux · read-only on the source.</p>
|
||||
|
||||
<p align="center"><a href="docs/support.md">What it supports</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/cli.md">Terminal</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
|
||||
|
||||
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
|
||||
|
||||
<p align="center"><img src="docs/img/explorer-light-en.png" width="900" alt="The Guará explorer showing recovered photos grouped by month, with a sidebar to filter and a details panel on the right"><br><sub>The real interface running on a synthetic test image (generated photos, no real data).</sub></p>
|
||||
|
||||
---
|
||||
|
||||
O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, TestDisk e Disk Drill fazem separadamente:
|
||||
In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately:
|
||||
|
||||
- **Metadados do sistema de arquivos** (NTFS, FAT12/16/32, exFAT): acha arquivos apagados **com nome, pasta e data**.
|
||||
- **Carving por assinatura/estrutura** no espaço livre, em partições danificadas e no **espaço não particionado**: acha arquivos mesmo quando o sistema de arquivos sumiu. Os detectores leem a estrutura real (JPEG, PNG, GIF, BMP, PDF, ZIP/DOCX/XLSX/PPTX/ODF/EPUB, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE (DOC/XLS/PPT), MP3, EXE/DLL) para saber o **tamanho exato** e a **confiança**.
|
||||
- **Partições perdidas:** lê MBR/GPT e procura volumes (NTFS, FAT, exFAT, ext, APFS, HFS+, BitLocker, LUKS) por assinatura onde a tabela falhou.
|
||||
- **Fusão sem duplicatas:** um arquivo achado pelos dois métodos aparece uma vez, com a melhor evidência.
|
||||
- **Confiança por arquivo** (alta / média / baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão.
|
||||
- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs and APFS (on XFS only the content comes back, by carving, see below).
|
||||
- **Structure-aware carving** in free space, damaged partitions and unpartitioned space. The detectors read the real structure of dozens of formats to learn the **exact size** and a **confidence** level.
|
||||
- **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside.
|
||||
- **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata.
|
||||
- **Fragmented files:** reassembles JPEGs stored in pieces and rebuilds the index of MP4/MOV videos that lost their `moov`.
|
||||
- **Forensic images:** reads and writes raw images, **E01/Ex01** and **AFF4**, and reads **QCOW2, VMDK, VHD, VHDX** and **DMG** virtual disks.
|
||||
- **Lost partitions:** reads MBR/GPT and searches for volumes by signature where the table failed.
|
||||
- **Merge without duplicates:** a file found by more than one method appears once, with the best evidence.
|
||||
- **Per-file confidence** (high, medium, low): intact structure, reused clusters, zeroed content (TRIM) or content that does not match the extension.
|
||||
|
||||
## Segurança dos dados
|
||||
The full matrix, with the limits of each item, is in [docs/support.md](docs/support.md).
|
||||
|
||||
- A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado.
|
||||
- A recuperação **recusa destinos no mesmo disco** da origem (Linux/macOS).
|
||||
- **Imagem forense** (`guara image`) tolerante a setores ruins, com mapa de erros e SHA-256: trabalhe sempre na cópia de um disco com defeito.
|
||||
- Cada recuperação grava `guara-manifesto.json` com o **SHA-256** de todos os arquivos.
|
||||
- **Volumes cifrados** (BitLocker, LUKS) são **detectados e avisados**; a v1 não desbloqueia nem varre dentro deles.
|
||||
## Data safety
|
||||
|
||||
## Instalação
|
||||
- The source is opened **read-only**. Nothing in the core writes to the device being recovered.
|
||||
- Recovery **refuses destinations on the same disk** as the source (Linux and macOS).
|
||||
- **Forensic imaging** tolerates bad sectors, with an error map and hash: always work on the copy of a failing disk.
|
||||
- Every recovery writes `guara-manifesto.json` with the **MD5, SHA-1 and SHA-256** of every file.
|
||||
- **Passwords are never written to the session file.**
|
||||
|
||||
Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
||||
## Installation
|
||||
|
||||
| | CLI | App gráfico |
|
||||
Download the package for your system from **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | Graphical app |
|
||||
|---|---|---|
|
||||
| Windows | `guara-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
|
||||
| macOS | `guara-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Windows | — | `.msi` / `.exe` installer |
|
||||
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
|
||||
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).
|
||||
|
||||
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||
**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.
|
||||
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
|
||||
> The binaries are **not signed yet**: Windows SmartScreen will warn you. Check the SHA-256.
|
||||
|
||||
## Uso (terminal)
|
||||
Reading raw disks requires privileges: **administrator** (Windows), `sudo` (Linux and macOS). Even as root, Guará writes sessions, reports and recovered files with the owner and permissions of the user who launched it.
|
||||
|
||||
There is also a Docker image of the command line tool, for Linux hosts and for working on image files: see [docs/docker.md](docs/docker.md).
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
sudo guara devices # discos e pendrives (avisa TRIM e volumes montados)
|
||||
sudo guara inspect /dev/sdb # partições, espaço não particionado, cifragem
|
||||
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
|
||||
sudo guara scan /dev/sdb --quick # só metadados (segundos)
|
||||
guara scan disco.img --category image,document --min-confidence medium
|
||||
guara scan disco.img --recover /mnt/outro-disco/Recuperados
|
||||
guara recover s.json /mnt/outro-disco/Recuperados --category image
|
||||
guara resume s.json # continua após Ctrl+C
|
||||
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros
|
||||
guara ui # interface no navegador (127.0.0.1 + token)
|
||||
sudo guara devices # which disks exist
|
||||
sudo guara scan /dev/sdb --session s.json --list # deep scan
|
||||
guara recover s.json /mnt/other-disk/Recovered # recover, onto ANOTHER disk
|
||||
```
|
||||
|
||||
`--json` está disponível em `devices`, `inspect` e `scan` para automação.
|
||||
Failing disk? Make the image first and work on the copy:
|
||||
|
||||
## Interface gráfica
|
||||
```bash
|
||||
sudo guara image /dev/sdb /mnt/other-disk/sdb.img
|
||||
guara scan /mnt/other-disk/sdb.img --session s.json
|
||||
```
|
||||
|
||||
App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, pré-visualização de imagens, confiança por arquivo, pausar/continuar e salvar sessão. Tema claro/escuro automático e layout responsivo. O mesmo front-end roda em `guara ui`.
|
||||
Other commands (`inspect`, `health`, `hash`, `search`, `export`, `report`, `verify`, `ui`, encrypted volumes, RAID) are in **[docs/cli.md](docs/cli.md)**.
|
||||
|
||||
## Limites conhecidos (v0.1)
|
||||
## Graphical interface
|
||||
|
||||
Seja realista sobre o que dá para esperar:
|
||||
A desktop app (Tauri) in the style of a file manager, designed for when Guará finds thousands of items. The same front end runs in the browser with `guara ui`. More screenshots and details in **[docs/interface.md](docs/interface.md)**.
|
||||
|
||||
- **Dados sobrescritos não voltam.** Em **SSD com TRIM** o próprio disco pode zerar blocos apagados; o Guará avisa, mas não faz milagre.
|
||||
- **Carving só acha arquivos contíguos.** Arquivos fragmentados sem metadados podem sair truncados (marcados com confiança baixa).
|
||||
- **ext2/3/4, APFS e HFS+:** só varredura por assinatura (no ext, apenas no espaço livre). Não há leitura de inodes/journal ainda.
|
||||
- **NTFS:** arquivos comprimidos/EFS e registros com lista de atributos (arquivos muito fragmentados) são ignorados ou marcados como incompletos.
|
||||
- **exFAT:** coberto por teste com imagem sintética; falta validar em mais mídias reais.
|
||||
- **Cifragem:** somente detecção.
|
||||
- **Windows/macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos.
|
||||
- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado — melhoria planejada.
|
||||
<p align="center"><img src="docs/img/explorer-dark-en.png" width="640" alt="The explorer in the dark theme"> <img src="docs/img/settings-language-en.png" width="250" alt="Settings with the language selector"></p>
|
||||
|
||||
## Desenvolvimento
|
||||
- **Thumbnail grid** or **list**, grouping by day or month and virtual scrolling for tens of thousands of files.
|
||||
- **Sidebar navigation** by type, real photo date (EXIF), folder, camera, confidence and GPS, with an option to hide duplicates.
|
||||
- **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click).
|
||||
- **Details panel** with preview, dates and metadata.
|
||||
- Fields for password, RAID, reference video and image format; disk health (SMART); pause and resume; Auto, Light and Dark themes; interface in English and Portuguese, chosen in Settings.
|
||||
|
||||
**Preview before recovering**, read straight from the disk without writing anything: images, video, audio, PDF, text, Word/Excel/PowerPoint/OpenDocument, ZIP contents, **SQLite databases** (with deleted rows) and hex. On Linux, video playback depends on the `gstreamer1.0-libav` and `gstreamer1.0-plugins-good` plugins; without them, the "Open in system player" button covers the case.
|
||||
|
||||
## Forensic features
|
||||
|
||||
- **Hashes** MD5, SHA-1 and SHA-256 of every recovered file, of the image and of the device.
|
||||
- **Metadata:** EXIF (camera, lens, serial number, GPS), PDF, Office and OpenDocument, MP4/MOV.
|
||||
- **Timeline:** creation, modification and access dates from NTFS, FAT and exFAT.
|
||||
- **Text search across the whole disk**, including free space, in UTF-8 and UTF-16.
|
||||
- **CSV** (opens in Excel) and a printable **HTML report** with case, examiner, source, partitions, summary and hashes.
|
||||
- **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name.
|
||||
- **Duplicate detection** by content signature.
|
||||
|
||||
> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no trusted timestamp (RFC 3161) on its signatures. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md).
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Overwritten data does not come back.** On an SSD with TRIM the disk may zero deleted blocks; Guará warns you, but it cannot work miracles.
|
||||
- **Fragments:** only JPEG (up to three pieces) and MP4 without an index are reassembled. Other formats, if fragmented, come out truncated with low confidence.
|
||||
- **ReFS and Storage Spaces** are not supported (ReFS is only detected).
|
||||
- **XFS:** on a real XFS (v5, kernel deletion) the inode and its extent list are wiped, so Guará recovers the **content** of files with a recognizable structure by carving, but not their name or folder; reading names needs the file system journal, which is not implemented. The old inode-based path only works on simulated deletions. **APFS:** deletion recovery tested only in simulation.
|
||||
- **NTFS:** compressed files, EFS and heavily fragmented records are skipped or marked as incomplete.
|
||||
- **exFAT:** validated with a synthetic image; real media are still missing.
|
||||
- **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
|
||||
- **Windows:** the code builds and is tested in CI, but the tests with real images run only on Linux. There is no macOS build: Tauri needs the macOS SDK and code signing, both tied to Apple hardware and an Apple Developer account, which this project's CI (a single Linux runner) does not have. The source still compiles for macOS; it is just not built or tested.
|
||||
- **GUI:** on Linux and macOS the app restarts entirely as administrator (`pkexec` or `osascript`) instead of using a helper process.
|
||||
|
||||
## Where it is going
|
||||
|
||||
Summary of the [ROADMAP](ROADMAP.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, Ed25519 signatures, APFS free map |
|
||||
| **Next** | Trusted timestamps (RFC 3161), more tests on Windows |
|
||||
| **Not planned** | A macOS build — see [Known limits](#known-limits) |
|
||||
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
|
||||
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
|
||||
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
crates/guara-core núcleo: leitura, partições, sistemas de arquivos, carving, fusão, recuperação
|
||||
crates/guara-cli binário `guara` (CLI + servidor local da UI)
|
||||
apps/gui interface (ui/ = HTML/CSS/JS; src-tauri/ = app desktop)
|
||||
scripts/ geração de imagens de teste (FAT32, NTFS, ext4, exFAT)
|
||||
crates/guara-core core: reading, partitions, file systems, encryption, RAID, carving, recovery
|
||||
crates/guara-cli `guara` binary (CLI + local UI server)
|
||||
apps/gui interface (ui/ = HTML, CSS and JS; src-tauri/ = desktop app)
|
||||
scripts/ test images and reference data
|
||||
docs/ support, terminal and architecture
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo test --workspace # no Linux gera imagens reais com mtools/ntfs-3g/e2fsprogs (veja scripts/)
|
||||
cargo test --workspace # on Linux this builds real images with mtools, ntfs-3g, e2fsprogs
|
||||
scripts/fetch-testdata.sh # optional: reference images (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver.org/lang/pt-BR/); mudanças em [CHANGELOG.md](CHANGELOG.md). Para publicar: `git tag vX.Y.Z && git push --tags` (o workflow `release` compila e publica tudo).
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/architecture.md](docs/architecture.md). Versions follow [SemVer](https://semver.org/); changes are in [CHANGELOG.md](CHANGELOG.md). To publish: `git tag vX.Y.Z && git push --tags` (the `release` workflow builds and publishes everything). Vulnerabilities: [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Licença
|
||||
## License
|
||||
|
||||
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
|
||||
[MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
@@ -0,0 +1,144 @@
|
||||
<p align="center"><img src="logo.png" width="300" alt="Guará"></p>
|
||||
<h1 align="center">Guará</h1>
|
||||
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows e Linux · somente leitura na origem.</p>
|
||||
|
||||
<p align="center"><a href="docs/pt-BR/suporte.md">O que suporta</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
|
||||
|
||||
<p align="center"><img src="docs/img/explorer-light-pt.png" width="900" alt="O explorador do Guará mostrando fotos recuperadas agrupadas por mês, com a barra lateral para filtrar e o painel de detalhes à direita"><br><sub>A interface de verdade rodando sobre uma imagem de teste sintética (fotos geradas, sem dados reais).</sub></p>
|
||||
|
||||
---
|
||||
|
||||
O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, TestDisk e Disk Drill fazem separadamente:
|
||||
|
||||
- **Metadados do sistema de arquivos:** acha arquivos apagados **com nome, pasta e data** em NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs, XFS e APFS.
|
||||
- **Carving por estrutura** no espaço livre, em partições danificadas e no espaço não particionado. Os detectores leem a estrutura real de dezenas de formatos para saber o **tamanho exato** e a **confiança**.
|
||||
- **Volumes cifrados:** abre BitLocker, LUKS e FileVault 2 com a sua senha ou chave e varre por dentro.
|
||||
- **RAID e LVM:** remonta arrays a partir de discos soltos (RAID 0, 1, 4, 5, 6, 10, LVM2), descobrindo nível, faixa e ordem sozinho quando não há metadados.
|
||||
- **Arquivos fragmentados:** remonta JPEG em pedaços e refaz o índice de vídeos MP4/MOV sem `moov`.
|
||||
- **Imagens forenses:** lê e grava imagem bruta, **E01/Ex01** e **AFF4**, e lê discos virtuais **QCOW2, VMDK, VHD, VHDX** e **DMG**.
|
||||
- **Partições perdidas:** lê MBR/GPT e procura volumes por assinatura onde a tabela falhou.
|
||||
- **Fusão sem duplicatas:** um arquivo achado por mais de um método aparece uma vez, com a melhor evidência.
|
||||
- **Confiança por arquivo** (alta, média, baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão.
|
||||
|
||||
Matriz completa, com limites de cada item, em [docs/pt-BR/suporte.md](docs/pt-BR/suporte.md).
|
||||
|
||||
## Segurança dos dados
|
||||
|
||||
- A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado.
|
||||
- A recuperação **recusa destinos no mesmo disco** da origem (Linux e macOS).
|
||||
- **Imagem forense** tolerante a setores ruins, com mapa de erros e hash: trabalhe sempre na cópia de um disco com defeito.
|
||||
- Cada recuperação grava `guara-manifesto.json` com **MD5, SHA-1 e SHA-256** de todos os arquivos.
|
||||
- **Senhas nunca vão para o arquivo de sessão.**
|
||||
|
||||
## Instalação
|
||||
|
||||
Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | App gráfico |
|
||||
|---|---|---|
|
||||
| Windows | — | instalador `.msi` / `.exe` |
|
||||
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
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 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.
|
||||
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux e macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
||||
|
||||
Há também uma imagem Docker do terminal, para hospedeiros Linux e para trabalhar com arquivos de imagem: veja [docs/pt-BR/docker.md](docs/pt-BR/docker.md).
|
||||
|
||||
## Começando
|
||||
|
||||
```bash
|
||||
sudo guara devices # que discos existem
|
||||
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
|
||||
guara recover s.json /mnt/outro-disco/Recuperados # recupera, em OUTRO disco
|
||||
```
|
||||
|
||||
Disco com defeito? Faça a imagem primeiro e trabalhe na cópia:
|
||||
|
||||
```bash
|
||||
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img
|
||||
guara scan /mnt/outro-disco/sdb.img --session s.json
|
||||
```
|
||||
|
||||
Outros comandos (`inspect`, `health`, `hash`, `search`, `export`, `report`, `verify`, `ui`, volumes cifrados, RAID) estão em **[docs/pt-BR/cli.md](docs/pt-BR/cli.md)**.
|
||||
|
||||
## Interface gráfica
|
||||
|
||||
App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando o Guará acha milhares de itens. O mesmo front-end roda no navegador com `guara ui`. Mais telas e detalhes em **[docs/pt-BR/interface.md](docs/pt-BR/interface.md)**.
|
||||
|
||||
<p align="center"><img src="docs/img/explorer-dark-pt.png" width="640" alt="O explorador no tema escuro"> <img src="docs/img/settings-language-pt.png" width="250" alt="Configurações com o seletor de idioma"></p>
|
||||
|
||||
- **Grade de miniaturas** ou **lista**, agrupamento por dia ou mês e rolagem virtual para dezenas de milhares de arquivos.
|
||||
- **Navegação lateral** por tipo, data real da foto (EXIF), pasta, câmera, confiança e GPS, com opção de ocultar duplicadas.
|
||||
- **Seleção como no Finder ou Explorer** e **visualização rápida** em tela cheia (Espaço, Enter ou duplo clique).
|
||||
- **Painel de detalhes** com pré-visualização, datas e metadados.
|
||||
- Campos para senha, RAID, vídeo de referência e formato da imagem; saúde do disco (SMART); pausar e continuar; temas Auto, Claro e Escuro; interface em português e inglês.
|
||||
|
||||
**Pré-visualização antes de recuperar**, lida direto do disco sem gravar nada: imagens, vídeo, áudio, PDF, texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP, **bancos SQLite** (com linhas apagadas) e hexadecimal. No Linux, a reprodução de vídeo depende dos plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`; sem eles, o botão "Abrir no player do sistema" cobre o caso.
|
||||
|
||||
## Recursos periciais
|
||||
|
||||
- **Hashes** MD5, SHA-1 e SHA-256 de cada arquivo recuperado, da imagem e do dispositivo.
|
||||
- **Metadados:** EXIF (câmera, lente, número de série, GPS), PDF, Office e OpenDocument, MP4/MOV.
|
||||
- **Linha do tempo:** datas de criação, modificação e acesso do NTFS, FAT e exFAT.
|
||||
- **Busca de texto no disco inteiro**, inclusive espaço livre, em UTF-8 e UTF-16.
|
||||
- **CSV** (abre no Excel) e **relatório HTML** imprimível com caso, examinador, origem, partições, resumo e hashes.
|
||||
- **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome.
|
||||
- **Detecção de duplicadas** por assinatura de conteúdo.
|
||||
|
||||
> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e suas assinaturas não têm carimbo de tempo de terceiros (RFC 3161). Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md).
|
||||
|
||||
## Limites conhecidos
|
||||
|
||||
- **Dados sobrescritos não voltam.** Em SSD com TRIM o disco pode zerar blocos apagados; o Guará avisa, mas não faz milagre.
|
||||
- **Fragmentos:** só JPEG (até três pedaços) e MP4 sem índice são remontados. Os demais formatos, se fragmentados, saem truncados com confiança baixa.
|
||||
- **ReFS e Storage Spaces** não são suportados (ReFS só é detectado).
|
||||
- **APFS:** sem mapa de espaço livre. **XFS e APFS:** recuperação de exclusão testada só em simulação.
|
||||
- **NTFS:** arquivos comprimidos, EFS e registros muito fragmentados são ignorados ou marcados como incompletos.
|
||||
- **exFAT:** validado com imagem sintética; faltam mídias reais.
|
||||
- **Celulares:** o Guará trabalha com mídias de armazenamento e **não** desbloqueia aparelhos nem contorna senhas.
|
||||
- **Windows:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Não há build para macOS: o Tauri precisa do SDK do macOS e de assinatura, os dois presos a hardware Apple e a uma conta Apple Developer, que o CI deste projeto (um único runner Linux) não tem. O código-fonte continua compilando para macOS; só não é compilado nem testado.
|
||||
- **GUI:** no Linux e macOS o app reinicia inteiro como administrador (`pkexec` ou `osascript`) em vez de usar um processo auxiliar.
|
||||
|
||||
## Para onde vai
|
||||
|
||||
Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, assinaturas Ed25519, mapa livre do APFS |
|
||||
| **Próximo** | Carimbo de tempo de terceiros (RFC 3161), mais testes em Windows |
|
||||
| **Fora de plano** | Um build para macOS — veja [Limites conhecidos](#limites-conhecidos) |
|
||||
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
|
||||
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
|
||||
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
```
|
||||
crates/guara-core núcleo: leitura, partições, sistemas de arquivos, cifra, RAID, carving, recuperação
|
||||
crates/guara-cli binário `guara` (CLI + servidor local da UI)
|
||||
apps/gui interface (ui/ = HTML, CSS e JS; src-tauri/ = app desktop)
|
||||
scripts/ imagens de teste e dados de referência
|
||||
docs/ suporte, terminal e arquitetura
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo test --workspace # no Linux gera imagens reais com mtools, ntfs-3g, e2fsprogs
|
||||
scripts/fetch-testdata.sh # opcional: imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
Veja [CONTRIBUTING.md](CONTRIBUTING.pt-BR.md) e [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). Versões seguem [SemVer](https://semver.org/lang/pt-BR/); mudanças em [CHANGELOG.md](CHANGELOG.pt-BR.md). Para publicar: `git tag vX.Y.Z && git push --tags` (o workflow `release` compila e publica tudo). Vulnerabilidades: [SECURITY.md](SECURITY.pt-BR.md).
|
||||
|
||||
## Licença
|
||||
|
||||
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Roadmap
|
||||
|
||||
[English](ROADMAP.md) · [Português](ROADMAP.pt-BR.md)
|
||||
|
||||
Where Guará has been, where it is and where it is going. What is marked as done exists in the code and has a test; what is planned is intent, not a promise, and has no date.
|
||||
|
||||
Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ out of scope
|
||||
|
||||
> Items marked done for macOS reflect what the GitHub CI used to build and test,
|
||||
> before the move to Gitea. The Gitea CI has one Linux runner; it builds and
|
||||
> tests Linux and Windows. macOS is not built or tested any more — see the
|
||||
> README's [Known limits](README.md#known-limits).
|
||||
|
||||
## Principles that do not change
|
||||
|
||||
1. **The source is read-only.** Nothing in the core writes to the device being recovered.
|
||||
2. **Do not ship without validating.** A feature only lands with a test against a real or reference image. That is why ReFS and Storage Spaces do not exist yet: there is no reference image to check against.
|
||||
3. **Honest confidence.** If the content could not be checked, confidence is low or medium, never high.
|
||||
4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry (the only thing that touches the network by default is the update check, a single request you can turn off), and every result can be reproduced.
|
||||
|
||||
## History
|
||||
|
||||
| Version | Date | Milestone |
|
||||
|---|---|---|
|
||||
| 0.1.0 | 2026-10-18 | Read-only core, MBR/GPT, NTFS, FAT, exFAT, structure-aware carving, forensic image, sessions, CLI and interface |
|
||||
| 0.2.0 | 2026-10-18 | Full preview (image, video, audio, PDF, Office, ZIP, hex) and themes |
|
||||
| 0.3.0 | 2026-10-18 | Explorer with thumbnails, metadata (EXIF, GPS), hashes, disk search, CSV, HTML report and duplicates |
|
||||
| 0.4.0 | 2026-10-18 | Disk reconnection, automatic sessions, organization when recovering |
|
||||
| 0.5.0 | 2026-10-18 | SQLite viewer with deleted rows, SMART, English interface, recovery resume |
|
||||
| 0.6.0 | 2026-10-18 | Full disk health (ATA and NVMe SMART), `guara health`, new logo |
|
||||
| 1.0.0 | 2026-10-19 | ext4, HFS+, Btrfs, XFS, APFS; BitLocker, LUKS, FileVault 2; RAID and LVM; E01, Ex01, AFF4; fragmented JPEG; MP4 without moov |
|
||||
| 1.0.1 | 2026-10-20 | Documentation in English with Portuguese translations, complete English interface, screenshots, Docker image of the CLI |
|
||||
| 1.1.0 | 2026-10-20 | Core messages and dates in English, automated translation check, RustCrypto on digest 0.11, verified Docker device access and Superset workspaces |
|
||||
| 1.2.0 | 2026-10-22 | QCOW2, VMDK, VHD, VHDX and DMG virtual disks, hash-chained audit trail, PDF report, APFS free-space map |
|
||||
| 1.3.0 | 2026-10-23 | Update from inside the app, Ed25519 signatures, differencing QCOW2/VMDK and LZFSE DMG, reference images tested on Linux, macOS and Windows, XFS real-deletion test |
|
||||
| 1.3.1 | 2026-10-23 | New official logo |
|
||||
|
||||
Details of each version in the [CHANGELOG](CHANGELOG.md).
|
||||
|
||||
## Current status (1.3.1) against what is expected from a forensic suite
|
||||
|
||||
### 1. File systems with metadata
|
||||
|
||||
| Item | Status | Note |
|
||||
|---|---|---|
|
||||
| NTFS, FAT12/16/32, exFAT | ✅ | Since 0.1.0. NTFS skips compressed and EFS files |
|
||||
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
|
||||
| HFS+ | ✅ | Tree node leftovers and journal |
|
||||
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents |
|
||||
| XFS | 🟡 | Freed inodes and names of freed entries work on simulated deletion only. With real kernel deletion (tested in CI) the metadata is wiped: content comes back by carving, names do not. **Journal reading is missing** |
|
||||
| APFS | 🟡 | Container, object map, free-space map (spaceman and bitmaps), snapshots and old nodes. Deletion tested only in simulation; containers with chunk-info address blocks (large ones) fall back to scanning the whole volume |
|
||||
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
|
||||
| Storage Spaces | 🔜 | Not done. Reference image missing |
|
||||
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
|
||||
| XFS: names and folders after real deletion (read the journal) | 🔜 | The CI test exists and is ignored until this lands |
|
||||
| APFS validation with real deletion | 🔜 | Needs an image from a real Mac |
|
||||
|
||||
### 2. Encrypted volumes
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| BitLocker (password, recovery key, .BEK, clear key) | ✅ |
|
||||
| LUKS1 and LUKS2 | ✅ |
|
||||
| FileVault 2 (Core Storage) | ✅ |
|
||||
| FileVault 2 on APFS with an encrypted volume | 🔜 |
|
||||
| Key from memory (hibernation, RAM dump) | 🔭 |
|
||||
| Dictionary and brute-force attacks (BitLocker, LUKS, FileVault, RAR, ZIP and other encrypted volumes/files) | 🔜 |
|
||||
|
||||
Today Guará opens volumes when you **already have** the password or key. Password recovery by dictionary and brute force (with a GPU, like Hashcat) is a planned feature, central to forensic use.
|
||||
|
||||
### 3. Forensic images
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Raw image (dd) tolerant of bad sectors, with an error map | ✅ |
|
||||
| E01 and Ex01: read, write and `guara verify` | ✅ |
|
||||
| AFF4: read and write | ✅ |
|
||||
| Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ |
|
||||
| QCOW2 with a backing file, VMDK snapshots (by name or VirtualBox UUID), DMG with LZFSE | ✅ |
|
||||
| Password-protected DMG, parent identity check for VHD, VHDX and QCOW2 | 🔜 |
|
||||
| Mobile device image (see item 7) | 🔭 |
|
||||
|
||||
### 4. Assembling loose disks
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| RAID 0, 1, 4, 5, 6, 10 and linear (mdadm), including degraded | ✅ |
|
||||
| Automatic discovery of level, stripe size, layout and order without metadata | ✅ |
|
||||
| LVM2 (linear, striped, mirrored and RAID) | ✅ |
|
||||
| Storage Spaces | 🔜 |
|
||||
| Multi-disk Btrfs and ZFS (the file system's own RAID) | 🔭 |
|
||||
| Proprietary controller RAID (Dell PERC, HP Smart Array) | 🔭 |
|
||||
|
||||
### 5. Carving
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Detection by real structure, with exact size and confidence | ✅ Since 0.1.0 (JPEG, PNG, GIF, BMP, PDF, ZIP and derivatives, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3, PE) and, in 1.0.0, 24 more (gzip, zstd, Matroska/WebM, Ogg, MPEG-TS, FLV, ASF, MIDI, RAR, ar, SWF, ELF, TIFF, ICO, Windows registry, EVTX, PST, RTF, pcap and others) |
|
||||
| Fragmented JPEG reassembled (up to three fragments) | ✅ |
|
||||
| MP4/MOV without `moov` rebuilt (H.264 and HEVC) from a reference video | ✅ |
|
||||
| Fragments: more than three, and formats beyond JPEG (PNG, DOC, ZIP, SQLite) | 🔜 |
|
||||
| Video reconstruction without a reference, audio in MP4 without `moov`, drone and action camera videos in other containers | 🔜 |
|
||||
| More formats validated by structure | 🔜 ongoing |
|
||||
|
||||
### 6. Artifact interpretation
|
||||
|
||||
Guará **identifies and recovers** these files, but only interprets some of them.
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| SQLite database: schema, tables, blobs, dates and deleted rows | ✅ |
|
||||
| Metadata: EXIF and GPS, PDF, Office and OpenDocument, MP4/MOV | ✅ |
|
||||
| Carving of Windows registry (`regf`), EVTX, PST, pcap | 🟡 only recognizes and recovers, does not interpret |
|
||||
| Interpret the Windows registry (users, programs, USB, execution history) | 🔜 |
|
||||
| Browser history, cookies and downloads (Chrome, Firefox, Safari) | 🔜 built on the SQLite reader |
|
||||
| Messages, contacts and location from apps (WhatsApp, Telegram, Signal and others) | 🔜 built on the SQLite reader |
|
||||
| System logs and Windows events (EVTX) | 🔜 |
|
||||
| **Unified timeline** of all artifacts | 🔜 |
|
||||
|
||||
### 7. Mobile device extraction
|
||||
|
||||
Here the leap is one of kind, not of feature. Today Guará works with **storage media**.
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Android through ADB and backups | 🔭 |
|
||||
| iOS through iTunes or Finder backups (including password-encrypted ones) | 🔭 |
|
||||
| Physical access through bootloader and chip exploits | 🔭 |
|
||||
|
||||
Physical access is the differentiator of commercial tools like Cellebrite. It depends on full-time vulnerability research teams, and many of those techniques cannot be published as open source, which makes this the most distant item on the roadmap. The closest direction is to read what the user or examiner can legitimately export (backups and ADB) and interpret that data with item 6; physical access by exploit stays as a long-term idea, to pursue as the project grows.
|
||||
|
||||
### 8. Chain of custody and validity in court
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| MD5, SHA-1 and SHA-256 of every file, of the image and of the device | ✅ |
|
||||
| `guara-manifesto.json` manifest in every recovery | ✅ |
|
||||
| HTML report with case, examiner, source, partitions, summary and hashes | ✅ |
|
||||
| Verification of an E01/Ex01 image against the stored hash | ✅ |
|
||||
| **Audit trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ |
|
||||
| **Digital signatures** (Ed25519) of manifests, reports and any file; `guara sign recovery` checks a whole recovery folder | ✅ |
|
||||
| Trusted timestamp (RFC 3161) on signatures | 🔜 |
|
||||
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
|
||||
| PDF report (A4, built in) | ✅ |
|
||||
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
|
||||
| Signed binaries (Windows) | 🔜 |
|
||||
|
||||
External validation is what makes a report accepted. Without it, Guará is good for triage, recovery and support, and the examiner must check the results by other means.
|
||||
|
||||
### 9. Analysis
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Text search across the whole disk, UTF-8 and UTF-16, including free space | ✅ `guara search` |
|
||||
| Duplicate detection | ✅ |
|
||||
| Regular expression and keyword search in **file content** (Office, PDF, e-mail) | 🔜 |
|
||||
| Known hashes: **NSRL** to ignore system files | 🔜 |
|
||||
| Hash lists of illicit material (imported by the examiner) | 🔜 |
|
||||
| Image detection by content (similarity, classification) | 🔭 |
|
||||
| **OCR** on scanned images and PDFs | 🔭 |
|
||||
|
||||
### 10. Platform and distribution
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| CLI (Linux) and interface for Windows and Linux | ✅ |
|
||||
| CI on Linux and Windows, automatic release by tag | ✅ |
|
||||
| Tests with real reference images on Linux and Windows | ✅ |
|
||||
| Update from inside the desktop app (signed packages, one click) | ✅ |
|
||||
| GUI: privileged helper process instead of restarting the whole app as administrator | 🔜 |
|
||||
| Signed and notarized binaries | 🔜 |
|
||||
| Packages in package managers (winget, Homebrew, Flatpak) | 🔭 |
|
||||
|
||||
## Suggested next milestones
|
||||
|
||||
No dates, in the order that makes the most sense.
|
||||
|
||||
**1.4: trust and custody**
|
||||
Trusted timestamps, XFS journal reading, APFS validation with real deletions.
|
||||
|
||||
**1.5: analysis**
|
||||
NSRL and hash lists, search in file content, unified timeline.
|
||||
|
||||
**1.6: artifacts**
|
||||
Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader.
|
||||
|
||||
**1.7: more structure**
|
||||
ReFS and Storage Spaces (once there is a reference image), password-protected DMG, identity check of VHD, VHDX and QCOW2 parents, expanded fragmented carving.
|
||||
|
||||
**2.0: mobile devices and validation**
|
||||
iTunes backups, ADB and Android backups; submission to the NIST CFTT tests.
|
||||
|
||||
## How to help
|
||||
|
||||
The most common bottleneck is a **reference image**: a ReFS volume, a Storage Spaces pool or an XFS with real deletion, preferably small and without personal data. If you have one, open an issue. See also [CONTRIBUTING](CONTRIBUTING.md).
|
||||
@@ -0,0 +1,195 @@
|
||||
# Roadmap
|
||||
|
||||
[English](ROADMAP.md) · **Português**
|
||||
|
||||
Onde o Guará esteve, onde está e para onde vai. O que está marcado como feito existe no código e tem teste; o que está planejado é intenção, não promessa, e não tem data.
|
||||
|
||||
Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo · ⛔ fora do escopo
|
||||
|
||||
> Itens marcados como feitos para macOS refletem o que o CI do GitHub compilava e
|
||||
> testava, antes da mudança para o Gitea. O CI do Gitea tem um único runner Linux;
|
||||
> ele compila e testa Linux e Windows. O macOS deixou de ser compilado e testado —
|
||||
> veja os [Limites conhecidos](README.pt-BR.md#limites-conhecidos) do README.
|
||||
|
||||
## Princípios que não mudam
|
||||
|
||||
1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado.
|
||||
2. **Não entregar sem validar.** Um recurso só entra com teste contra imagem real ou de referência. Por isso ReFS e Storage Spaces ainda não existem: não há imagem de referência para conferir.
|
||||
3. **Confiança honesta.** Se o conteúdo não pôde ser conferido, a confiança é baixa ou média, nunca alta.
|
||||
4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria (a única coisa que usa a rede por padrão é a verificação de atualização, uma requisição só que você pode desligar), e cada resultado pode ser reproduzido.
|
||||
|
||||
## Histórico
|
||||
|
||||
| Versão | Data | Marco |
|
||||
|---|---|---|
|
||||
| 0.1.0 | 2026-10-18 | Núcleo somente leitura, MBR/GPT, NTFS, FAT, exFAT, carving por estrutura, imagem forense, sessões, CLI e interface |
|
||||
| 0.2.0 | 2026-10-18 | Pré-visualização completa (imagem, vídeo, áudio, PDF, Office, ZIP, hexadecimal) e temas |
|
||||
| 0.3.0 | 2026-10-18 | Explorador com miniaturas, metadados (EXIF, GPS), hashes, busca no disco, CSV, relatório HTML e duplicadas |
|
||||
| 0.4.0 | 2026-10-18 | Reconexão de disco, sessões automáticas, organização ao recuperar |
|
||||
| 0.5.0 | 2026-10-18 | Visualizador de SQLite com linhas apagadas, SMART, interface em inglês, retomada de recuperação |
|
||||
| 0.6.0 | 2026-10-18 | Saúde do disco completa (SMART ATA e NVMe), `guara health`, nova logo |
|
||||
| 1.0.0 | 2026-10-19 | ext4, HFS+, Btrfs, XFS, APFS; BitLocker, LUKS, FileVault 2; RAID e LVM; E01, Ex01, AFF4; JPEG fragmentado; MP4 sem moov |
|
||||
| 1.0.1 | 2026-10-20 | Documentação em inglês com tradução em português, interface completa em inglês, telas, imagem Docker do terminal |
|
||||
| 1.1.0 | 2026-10-20 | Mensagens do núcleo e datas em inglês, verificação automática da tradução, RustCrypto em digest 0.11, acesso a dispositivo no Docker e workspaces do Superset verificados |
|
||||
| 1.2.0 | 2026-10-22 | Discos virtuais QCOW2, VMDK, VHD, VHDX e DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa de espaço livre do APFS |
|
||||
| 1.3.0 | 2026-10-23 | Atualização pelo próprio app, assinaturas Ed25519, QCOW2/VMDK diferenciais e DMG com LZFSE, imagens de referência testadas em Linux, macOS e Windows, teste de exclusão real do XFS |
|
||||
| 1.3.1 | 2026-10-23 | Nova logo oficial |
|
||||
|
||||
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
||||
|
||||
## Situação atual (1.3.1) contra o que se espera de uma suíte forense
|
||||
|
||||
### 1. Sistemas de arquivos com metadados
|
||||
|
||||
| Item | Estado | Observação |
|
||||
|---|---|---|
|
||||
| NTFS, FAT12/16/32, exFAT | ✅ | Desde a 0.1.0. NTFS ignora arquivos comprimidos e EFS |
|
||||
| ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário |
|
||||
| HFS+ | ✅ | Sobras de nós da árvore e diário |
|
||||
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo |
|
||||
| XFS | 🟡 | Inodes liberados e nomes de entradas liberadas só funcionam na exclusão simulada. Com exclusão real do kernel (testada no CI) os metadados são zerados: o conteúdo volta por carving, o nome não. **Falta ler o diário** |
|
||||
| APFS | 🟡 | Contêiner, mapa de objetos, mapa de espaço livre (spaceman e bitmaps), instantâneos e nós antigos. Exclusão testada só em simulação; contêineres com blocos de endereço de chunk-info (grandes) voltam a varrer o volume inteiro |
|
||||
| ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência |
|
||||
| Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
|
||||
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
|
||||
| XFS: nomes e pastas após exclusão real (ler o diário) | 🔜 | O teste no CI existe e fica ignorado até isso entrar |
|
||||
| Validação de APFS com exclusão real | 🔜 | Precisa de imagem de um Mac de verdade |
|
||||
|
||||
### 2. Volumes cifrados
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| BitLocker (senha, chave de recuperação, .BEK, chave limpa) | ✅ |
|
||||
| LUKS1 e LUKS2 | ✅ |
|
||||
| FileVault 2 (Core Storage) | ✅ |
|
||||
| FileVault 2 em APFS com volume cifrado | 🔜 |
|
||||
| Chave a partir de memória (hibernação, despejo de RAM) | 🔭 |
|
||||
| Ataque de dicionário e força bruta (BitLocker, LUKS, FileVault, RAR, ZIP e demais volumes/arquivos cifrados) | 🔜 |
|
||||
|
||||
Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recuperação de senha por dicionário e força bruta (com GPU, como Hashcat) é um recurso planejado, central no uso pericial.
|
||||
|
||||
### 3. Imagens forenses
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| Imagem bruta (dd) tolerante a setores ruins, com mapa de erros | ✅ |
|
||||
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
|
||||
| AFF4: leitura e gravação | ✅ |
|
||||
| Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ |
|
||||
| QCOW2 com arquivo de apoio, snapshots de VMDK (por nome ou UUID do VirtualBox), DMG com LZFSE | ✅ |
|
||||
| DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2 | 🔜 |
|
||||
| Imagem de dispositivo móvel (ver item 7) | 🔭 |
|
||||
|
||||
### 4. Montagem de discos soltos
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| RAID 0, 1, 4, 5, 6, 10 e linear (mdadm), inclusive degradado | ✅ |
|
||||
| Descoberta automática de nível, faixa, layout e ordem sem metadados | ✅ |
|
||||
| LVM2 (linear, em faixas, espelhado e RAID) | ✅ |
|
||||
| Storage Spaces | 🔜 |
|
||||
| Btrfs e ZFS multidisco (RAID do próprio sistema de arquivos) | 🔭 |
|
||||
| RAID de controladoras proprietárias (Dell PERC, HP Smart Array) | 🔭 |
|
||||
|
||||
### 5. Carving
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| Detecção pela estrutura real, com tamanho exato e confiança | ✅ Desde a 0.1.0 (JPEG, PNG, GIF, BMP, PDF, ZIP e derivados, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3, PE) e, na 1.0.0, mais 24 (gzip, zstd, Matroska/WebM, Ogg, MPEG-TS, FLV, ASF, MIDI, RAR, ar, SWF, ELF, TIFF, ICO, registro do Windows, EVTX, PST, RTF, pcap e outros) |
|
||||
| JPEG fragmentado remontado (até três fragmentos) | ✅ |
|
||||
| MP4/MOV sem `moov` reconstruído (H.264 e HEVC) a partir de um vídeo de referência | ✅ |
|
||||
| Fragmentos: mais de três, e formatos além de JPEG (PNG, DOC, ZIP, SQLite) | 🔜 |
|
||||
| Reconstrução de vídeo sem referência, áudio em MP4 sem `moov`, vídeos de drone e câmeras de ação em outros contêineres | 🔜 |
|
||||
| Mais formatos validados por estrutura | 🔜 contínuo |
|
||||
|
||||
### 6. Interpretação de artefatos
|
||||
|
||||
O Guará **identifica e recupera** esses arquivos, mas só interpreta alguns.
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| Banco SQLite: esquema, tabelas, blobs, datas e linhas apagadas | ✅ |
|
||||
| Metadados: EXIF e GPS, PDF, Office e OpenDocument, MP4/MOV | ✅ |
|
||||
| Carving de registro do Windows (`regf`), EVTX, PST, pcap | 🟡 só reconhece e recupera, não interpreta |
|
||||
| Interpretar o registro do Windows (usuários, programas, USB, histórico de execução) | 🔜 |
|
||||
| Histórico, cookies e downloads de navegadores (Chrome, Firefox, Safari) | 🔜 base no leitor de SQLite |
|
||||
| Mensagens, contatos e localização de apps (WhatsApp, Telegram, Signal e outros) | 🔜 base no leitor de SQLite |
|
||||
| Logs do sistema e eventos do Windows (EVTX) | 🔜 |
|
||||
| **Linha do tempo unificada** de todos os artefatos | 🔜 |
|
||||
|
||||
### 7. Extração de dispositivos móveis
|
||||
|
||||
Aqui o salto é de natureza, não de recurso. Hoje o Guará trabalha com **mídias de armazenamento**.
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| Android por ADB e backups | 🔭 |
|
||||
| iOS por backups do iTunes ou Finder (inclusive cifrados com senha) | 🔭 |
|
||||
| Acesso físico por exploits de bootloader e de chip | 🔭 |
|
||||
|
||||
O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. Depende de equipes de pesquisa de vulnerabilidades em tempo integral e muitas dessas técnicas não são publicáveis em código aberto, o que torna esse item o mais distante do roadmap. A direção mais próxima é ler o que o usuário ou o perito consegue exportar de forma legítima (backups e ADB) e interpretar esses dados com o item 6; o acesso físico por exploit fica como ideia de longo prazo, a perseguir conforme o projeto crescer.
|
||||
|
||||
### 8. Cadeia de custódia e validade em tribunal
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| MD5, SHA-1 e SHA-256 de cada arquivo, da imagem e do dispositivo | ✅ |
|
||||
| Manifesto `guara-manifesto.json` em toda recuperação | ✅ |
|
||||
| Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ |
|
||||
| Verificação de imagem E01/Ex01 contra o hash gravado | ✅ |
|
||||
| **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ |
|
||||
| **Assinatura digital** (Ed25519) de manifestos, relatórios e qualquer arquivo; `guara sign recovery` confere uma pasta de recuperação inteira | ✅ |
|
||||
| Carimbo de tempo de terceiros (RFC 3161) nas assinaturas | 🔜 |
|
||||
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
|
||||
| Relatório em PDF (A4, próprio) | ✅ |
|
||||
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
|
||||
| Binários assinados (Windows) | 🔜 |
|
||||
|
||||
A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve para triagem, recuperação e apoio, e o perito deve conferir os resultados por outro meio.
|
||||
|
||||
### 9. Análise
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| Busca por texto em todo o disco, UTF-8 e UTF-16, inclusive espaço livre | ✅ `guara search` |
|
||||
| Detecção de duplicadas | ✅ |
|
||||
| Busca por expressão regular e por palavra-chave em **conteúdo de arquivos** (Office, PDF, e-mail) | 🔜 |
|
||||
| Hashes conhecidos: **NSRL** para ignorar arquivos de sistema | 🔜 |
|
||||
| Listas de hashes de material ilícito (importadas pelo perito) | 🔜 |
|
||||
| Detecção de imagens por conteúdo (similaridade, classificação) | 🔭 |
|
||||
| **OCR** em imagens e PDF digitalizados | 🔭 |
|
||||
|
||||
### 10. Plataforma e distribuição
|
||||
|
||||
| Item | Estado |
|
||||
|---|---|
|
||||
| CLI (Linux) e interface para Windows e Linux | ✅ |
|
||||
| CI em Linux e Windows, release automática por tag | ✅ |
|
||||
| Testes com imagens de referência reais em Linux e Windows | ✅ |
|
||||
| Atualização dentro do app desktop (pacotes assinados, um clique) | ✅ |
|
||||
| GUI: processo auxiliar privilegiado em vez de reiniciar o app inteiro como administrador | 🔜 |
|
||||
| Binários assinados e notarizados | 🔜 |
|
||||
| Pacotes em gerenciadores (winget, Homebrew, Flatpak) | 🔭 |
|
||||
|
||||
## Próximos marcos sugeridos
|
||||
|
||||
Sem datas, na ordem em que fazem mais sentido.
|
||||
|
||||
**1.4: confiança e custódia**
|
||||
Carimbo de tempo de terceiros, leitura do diário do XFS, validação de APFS com exclusões reais.
|
||||
|
||||
**1.5: análise**
|
||||
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
|
||||
|
||||
**1.6: artefatos**
|
||||
Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite.
|
||||
|
||||
**1.7: mais estrutura**
|
||||
ReFS e Storage Spaces (quando houver imagem de referência), DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2, carving fragmentado ampliado.
|
||||
|
||||
**2.0: dispositivos móveis e validação**
|
||||
Backups do iTunes, ADB e backups do Android; submissão aos testes do NIST CFTT.
|
||||
|
||||
## Como ajudar
|
||||
|
||||
O gargalo mais comum é **imagem de referência**: um volume ReFS, um pool de Storage Spaces ou um XFS com exclusão real, de preferência pequeno e sem dados pessoais. Se você tem um, abra uma issue. Veja também o [CONTRIBUTING](CONTRIBUTING.pt-BR.md).
|
||||
@@ -1,3 +1,5 @@
|
||||
# Segurança
|
||||
# Security
|
||||
|
||||
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades — corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
|
||||
[English](SECURITY.md) · [Português](SECURITY.pt-BR.md)
|
||||
|
||||
Guará opens devices in read-only mode and processes potentially corrupted or malicious data (forged file systems and files). Vulnerabilities (memory corruption in parsers, improper writes to the source, path escape during recovery) must be reported privately through the private vulnerability reporting feature of the repository on Gitea. Do not open a public issue before the fix.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Segurança
|
||||
|
||||
[English](SECURITY.md) · **Português**
|
||||
|
||||
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades (corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação) devem ser reportadas em privado pelo recurso de relato privado de vulnerabilidade do repositório no Gitea. Não abra issue pública antes da correção.
|
||||
@@ -1,12 +1,13 @@
|
||||
# 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 = "0.1.0"
|
||||
description = "Guará: graphical file recovery interface"
|
||||
version = "1.3.3"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
authors = ["Guará contributors"]
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
|
||||
rust-version = "1.80"
|
||||
|
||||
[workspace]
|
||||
@@ -17,9 +18,13 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
guara-core = { path = "../../../crates/guara-core" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../../../logo.png");
|
||||
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/schema/desktop-schema.json",
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Permissões da janela principal",
|
||||
"description": "Main window permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:default"]
|
||||
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
|
||||
}
|
||||
@@ -0,0 +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
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close","dialog:default"]}}
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 416 KiB |
@@ -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,51 +6,283 @@ 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())?
|
||||
}
|
||||
|
||||
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
||||
/// manifesto já exige elevação.
|
||||
#[tauri::command]
|
||||
fn relaunch_elevated() -> Result<(), String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "linux")]
|
||||
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())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
|
||||
state.0.update_apply().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `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)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("pkexec");
|
||||
cmd.arg("env");
|
||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
cmd.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
cmd.arg(exe);
|
||||
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
|
||||
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
|
||||
std::process::exit(0);
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = exe;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
{
|
||||
do_relaunch_elevated(app)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = app;
|
||||
Err("o Guará já roda como administrador no Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let exe = elevated_exe()?;
|
||||
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 err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let pid = std::process::id().to_string();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
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",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("GUARA_CONFIG_DIR={}", cfg.display()));
|
||||
c.arg(format!("GUARA_REPLACE_PID={pid}"));
|
||||
c.arg(&exe);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let shell = format!(
|
||||
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
|
||||
cfg.display(),
|
||||
pid,
|
||||
exe.display()
|
||||
);
|
||||
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}"))?;
|
||||
std::thread::spawn(move || {
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
let detail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
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()
|
||||
),
|
||||
};
|
||||
let _ = app.emit("elevate-failed", msg);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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())
|
||||
{
|
||||
return Ok(p);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 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 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 Some(id) = id else { return err(400) };
|
||||
return match eng.thumb(job, id, size) {
|
||||
Ok(b) => Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("Cache-Control", "private, max-age=3600")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(b.as_ref().clone())
|
||||
.unwrap_or_else(|_| err(500)),
|
||||
Err(code) => err(code),
|
||||
};
|
||||
}
|
||||
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 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) {
|
||||
Ok(s) => s,
|
||||
Err(code) => return err(code),
|
||||
};
|
||||
let total = slice.total;
|
||||
let start = slice.start;
|
||||
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 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.body(data).unwrap_or_else(|_| err(500))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let engine = Arc::new(Engine::new());
|
||||
let for_protocol = engine.clone();
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(State(Arc::new(Engine::new())))
|
||||
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
|
||||
.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| {
|
||||
// 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())
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
// SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
|
||||
unsafe {
|
||||
if libc::geteuid() == 0 {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reading raw disks requires root: request elevation as soon as the app launches,
|
||||
// without waiting for the user to look for a button. If authorization fails,
|
||||
// the window stays open and the UI displays a warning.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = do_relaunch_elevated(handle);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.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,9 +1,11 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "0.1.0",
|
||||
"productName": "Guará",
|
||||
"version": "1.3.3",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": { "frontendDist": "../ui" },
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
@@ -16,7 +18,9 @@
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": { "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost https://tile.openstreetmap.org; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
@@ -24,6 +28,12 @@
|
||||
"category": "Utility",
|
||||
"shortDescription": "Recuperação de arquivos",
|
||||
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@
|
||||
--text-2: #6e6e73;
|
||||
--text-3: #8e8e93;
|
||||
--line: rgba(0, 0, 0, 0.09);
|
||||
--accent: #d9541e;
|
||||
--accent-2: #ff8a3d;
|
||||
--accent: #0f766e;
|
||||
--accent-2: #14b8a6;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: rgba(217, 84, 30, 0.1);
|
||||
--accent-soft: rgba(15, 118, 110, 0.11);
|
||||
--ok: #1a8f3c;
|
||||
--warn: #b26a00;
|
||||
--bad: #d12b2b;
|
||||
@@ -34,10 +34,10 @@
|
||||
--text-2: #a1a1a6;
|
||||
--text-3: #7d7d82;
|
||||
--line: rgba(255, 255, 255, 0.1);
|
||||
--accent: #ff7a3d;
|
||||
--accent-2: #ff9d5c;
|
||||
--accent-ink: #1a0b03;
|
||||
--accent-soft: rgba(255, 122, 61, 0.15);
|
||||
--accent: #2dd4bf;
|
||||
--accent-2: #5eead4;
|
||||
--accent-ink: #042f2e;
|
||||
--accent-soft: rgba(45, 212, 191, 0.15);
|
||||
--ok: #32d74b;
|
||||
--warn: #ffb340;
|
||||
--bad: #ff6961;
|
||||
@@ -48,6 +48,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #000000;
|
||||
--surface: #1c1c1e;
|
||||
--surface-2: #232326;
|
||||
--side: rgba(28, 28, 30, 0.8);
|
||||
--text: #f5f5f7;
|
||||
--text-2: #a1a1a6;
|
||||
--text-3: #7d7d82;
|
||||
--line: rgba(255, 255, 255, 0.1);
|
||||
--accent: #2dd4bf;
|
||||
--accent-2: #5eead4;
|
||||
--accent-ink: #042f2e;
|
||||
--accent-soft: rgba(45, 212, 191, 0.15);
|
||||
--ok: #32d74b;
|
||||
--warn: #ffb340;
|
||||
--bad: #ff6961;
|
||||
--ok-soft: rgba(50, 215, 75, 0.15);
|
||||
--warn-soft: rgba(255, 179, 64, 0.15);
|
||||
--bad-soft: rgba(255, 105, 97, 0.15);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root { color-scheme: light dark; }
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
@@ -103,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; }
|
||||
@@ -168,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; }
|
||||
@@ -178,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; }
|
||||
@@ -186,6 +212,8 @@ 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; }
|
||||
/* 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); }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; align-items: start; }
|
||||
@@ -211,7 +239,8 @@ input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px;
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
|
||||
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; animation: rise .3s; }
|
||||
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; }
|
||||
.actionbar.enter { animation: rise .3s; }
|
||||
.actionbar .info { color: var(--text-2); font-size: 14px; }
|
||||
|
||||
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
|
||||
@@ -226,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; }
|
||||
@@ -255,3 +284,246 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 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; }
|
||||
|
||||
/* ------------------------------------------------------------ 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%; }
|
||||
.pv-img { display: block; max-width: 100%; max-height: 46vh; object-fit: contain; margin: auto; }
|
||||
.viewer.large .pv-img { max-height: 100%; }
|
||||
.viewer.zoomable .pv-img { cursor: zoom-in; }
|
||||
.viewer.actual .pv-img { max-width: none; max-height: none; cursor: zoom-out; }
|
||||
.viewer.actual { align-items: flex-start; }
|
||||
.pv-video { width: 100%; max-height: 46vh; background: #000; border-radius: 12px; }
|
||||
.viewer.large .pv-video { max-height: 100%; height: 100%; }
|
||||
.pv-audio { width: calc(100% - 24px); margin: auto 12px; }
|
||||
.pv-note { width: 100%; padding: 8px 12px; font-size: 12.5px; color: var(--text-2); text-align: center; }
|
||||
.pv-note.bad { text-align: left; color: var(--text); background: var(--warn-soft); border-radius: 10px; margin: 8px; width: calc(100% - 16px); font-size: 13.5px; }
|
||||
.pv-loading { height: 160px; display: grid; place-items: center; width: 100%; }
|
||||
.pv-loading i { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.pv-text, .pv-hex { margin: 0; padding: 12px 14px; width: 100%; font: 12.5px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); text-align: left; }
|
||||
.pv-hex { white-space: pre; overflow-x: auto; overflow-wrap: normal; font-size: 12px; }
|
||||
.pv-hex .o { color: var(--text-3); } .pv-hex .a { color: var(--accent); }
|
||||
.pv-page { width: 100%; background: var(--surface); padding: 18px 22px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.6; text-align: left; }
|
||||
.pv-page p { margin: 0 0 .8em; white-space: pre-wrap; } .pv-page h4 { margin: 1em 0 .4em; font-family: var(--font); font-size: 16px; }
|
||||
.pv-sheet { width: 100%; overflow: auto; text-align: left; }
|
||||
.pv-sheet table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
|
||||
.pv-sheet th, .pv-sheet td { border: 1px solid var(--line); padding: 4px 8px; white-space: nowrap; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pv-sheet th { background: var(--surface); color: var(--text-3); font-weight: 500; position: sticky; top: 0; }
|
||||
.pv-slide { width: calc(100% - 20px); margin: 10px; padding: 14px 16px; background: var(--surface); border-radius: 10px; box-shadow: 0 0 0 1px var(--line); text-align: left; }
|
||||
.pv-slide small { color: var(--text-3); } .pv-slide h4 { margin: 4px 0 6px; font-size: 16px; } .pv-slide p { margin: 0 0 4px; font-size: 13.5px; }
|
||||
.pv-zip { list-style: none; margin: 0; padding: 4px 0; width: 100%; text-align: left; font-size: 13px; }
|
||||
.pv-zip li { display: flex; justify-content: space-between; gap: 12px; padding: 5px 14px; border-bottom: 1px solid var(--line); }
|
||||
.pv-zip li span { overflow-wrap: anywhere; } .pv-zip small { color: var(--text-3); flex: none; }
|
||||
.pv-pdfbar { position: sticky; top: 0; z-index: 2; display: flex; gap: 10px; align-items: center; justify-content: center; padding: 6px; width: 100%; background: var(--side); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
|
||||
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
|
||||
|
||||
dialog.lightbox { width: min(1760px, 98vw); height: min(95vh, 1100px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox[open] { display: grid; }
|
||||
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
|
||||
.lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); }
|
||||
.lb-body { padding: 12px; min-height: 0; }
|
||||
|
||||
.hero-logo { width: min(340px, 70%); height: auto; margin: 0 0 14px -6px; }
|
||||
.pill.accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* ------------------------------------------------------------ file explorer */
|
||||
.main.fixed { overflow: hidden; }
|
||||
.page.wide { max-width: none; padding: 14px 18px 0; }
|
||||
.page.fill { height: 100%; display: flex; flex-direction: column; }
|
||||
.xp-top { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.xp-top h1 { font-size: 24px; }
|
||||
.xp-top .sub { font-size: 12.5px; }
|
||||
.xp-host { flex: 1; min-height: 0; padding-bottom: 12px; }
|
||||
.progress-card.slim { padding: 14px 18px; border-radius: 16px; gap: 10px; margin-bottom: 10px; }
|
||||
.stats.compact { gap: 22px; }
|
||||
.stats.compact .stat b { font-size: 20px; }
|
||||
.done-bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 8px 14px; border-radius: 12px; background: var(--accent-soft); margin-bottom: 10px; font-size: 13.5px; }
|
||||
.done-bar .grow { flex: 1; }
|
||||
|
||||
.xp { position: relative; display: grid; grid-template-columns: 236px minmax(0, 1fr) 340px; grid-template-rows: auto minmax(0, 1fr); gap: 10px 14px; height: 100%; }
|
||||
.xp-bar { grid-column: 1 / -1; display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||||
.xp-crumb { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; white-space: nowrap; overflow: hidden; }
|
||||
.xp-crumb b { font-size: 15px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.xp-crumb small { color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.xp-q { flex: 0 1 300px !important; min-width: 120px; }
|
||||
.xp-bar .theme-switch button { display: inline-grid; place-items: center; padding: 5px 9px; }
|
||||
.xp-bar input[type="range"] { width: 110px; accent-color: var(--accent); }
|
||||
.xp-navbtn { display: none; }
|
||||
|
||||
.xp-nav { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 8px 6px 14px; min-height: 0; }
|
||||
.xp-sec { padding: 4px 0 6px; }
|
||||
.xp-sec h4 { margin: 8px 10px 4px; font-size: 11.5px; letter-spacing: .02em; color: var(--text-3); font-weight: 600; }
|
||||
.xp-node { display: flex; align-items: center; gap: 4px; width: 100%; border: 0; background: transparent; cursor: pointer; text-align: left; padding: 6px 8px 6px calc(8px + var(--d, 0) * 14px); border-radius: 9px; font-size: 13.5px; color: var(--text); }
|
||||
.xp-node:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.xp-node.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.xp-node .l { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.xp-node small { color: var(--text-3); font-variant-numeric: tabular-nums; font-weight: 400; }
|
||||
.xp-node .tw { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; flex: none; transition: transform .15s; }
|
||||
.xp-node .tw:hover { background: color-mix(in srgb, var(--text) 10%, transparent); }
|
||||
|
||||
.xp-main { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.xp-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; padding: 0 12px; }
|
||||
.xp-chips:not(:empty) { padding: 10px 12px 2px; }
|
||||
.xp-chips .chip { display: inline-flex; gap: 6px; align-items: center; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.xp-scroll { position: relative; flex: 1; min-height: 0; overflow-y: auto; outline: none; }
|
||||
.xp-sizer { position: relative; width: 100%; }
|
||||
.xp-row { position: absolute; left: 0; right: 0; display: flex; gap: 10px; padding: 0 12px; box-sizing: border-box; }
|
||||
.xp-row.head { align-items: flex-end; }
|
||||
.xp-head { display: flex; align-items: baseline; gap: 10px; width: 100%; padding: 0 2px 6px; border-bottom: 1px solid var(--line); }
|
||||
.xp-head b::first-letter { text-transform: uppercase; }
|
||||
.xp-head b { font-size: 14px; } .xp-head small { color: var(--text-3); } .xp-hsel { margin-left: auto; border: 0; background: transparent; color: var(--accent); cursor: pointer; font-size: 12.5px; opacity: 0; }
|
||||
.xp-head:hover .xp-hsel, .xp-hsel:focus-visible { opacity: 1; }
|
||||
|
||||
.xp-tile { position: relative; flex: none; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; }
|
||||
.xp-thumb { position: relative; border-radius: 12px; overflow: hidden; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.xp-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--surface-2); }
|
||||
.xp-tile[aria-selected="true"] .xp-thumb { box-shadow: 0 0 0 3px var(--accent); }
|
||||
.xp-tile[data-cur="1"]:focus-visible .xp-thumb, .xp-tile[data-cur="1"] .xp-thumb { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: 2px; }
|
||||
.xp-ico { width: 100%; height: 100%; display: grid; place-content: center; text-align: center; gap: 2px; color: #fff; }
|
||||
.xp-ico span { font-size: 34px; line-height: 1; } .xp-ico b { font-size: 12px; letter-spacing: .04em; opacity: .9; }
|
||||
.c-document { background: linear-gradient(145deg, #3b82f6, #1d4ed8); } .c-video { background: linear-gradient(145deg, #8b5cf6, #5b21b6); }
|
||||
.c-audio { background: linear-gradient(145deg, #ec4899, #9d174d); } .c-archive { background: linear-gradient(145deg, #f59e0b, #b45309); }
|
||||
.c-database { background: linear-gradient(145deg, #14b8a6, #0f766e); } .c-other { background: linear-gradient(145deg, #64748b, #334155); } .c-image { background: linear-gradient(145deg, #0ea5e9, #0369a1); }
|
||||
.c-bad { background: linear-gradient(145deg, #6b7280, #374151); }
|
||||
.xp-chk { position: absolute; top: 7px; left: 7px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, .35); box-shadow: inset 0 0 0 2px #fff; opacity: 0; transition: opacity .12s; backdrop-filter: blur(4px); }
|
||||
.xp-tile:hover .xp-chk, .xp-tile[aria-selected="true"] .xp-chk, .xp-lrow:hover .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { opacity: 1; }
|
||||
.xp-tile[aria-selected="true"] .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 12.5l4 4 8-9'/%3E%3C/svg%3E") center / 14px no-repeat; }
|
||||
.xp-dot { position: absolute; right: 7px; bottom: 7px; width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(255, 255, 255, .85); }
|
||||
.xp-dot.ok { background: var(--ok); } .xp-dot.warn { background: var(--warn); } .xp-dot.bad { background: var(--bad); }
|
||||
.xp-gps { position: absolute; left: 7px; bottom: 6px; color: #fff; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
|
||||
.xp-cap { padding: 5px 2px 0; min-width: 0; }
|
||||
.xp-cap b { display: block; font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.xp-cap small { display: block; font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.xp-lrow { position: relative; display: grid; grid-template-columns: 26px 40px minmax(0, 1fr) 132px 76px 58px; gap: 12px; align-items: center; width: 100%; height: 50px; padding: 0 10px; border-radius: 10px; cursor: pointer; user-select: none; }
|
||||
.xp-lrow:hover { background: var(--surface-2); }
|
||||
.xp-lrow[aria-selected="true"] { background: var(--accent-soft); }
|
||||
.xp-lrow .xp-chk { position: static; opacity: .0; }
|
||||
.xp-lth { width: 40px; height: 40px; border-radius: 8px; overflow: hidden; background: var(--surface-2); display: grid; place-items: center; }
|
||||
.xp-lth img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.xp-lth .xp-ico span { font-size: 18px; } .xp-lth .xp-ico b { display: none; }
|
||||
.xp-lico { font-size: 18px; width: 100%; height: 100%; display: grid; place-items: center; color: #fff; }
|
||||
.xp-lnm { min-width: 0; } .xp-lnm b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xp-lnm small { display: block; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.xp-lcol { color: var(--text-2); font-size: 13px; white-space: nowrap; } .xp-lcol.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.xp-actions { display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--surface); }
|
||||
.xp-actions .info { color: var(--text-2); font-size: 13.5px; margin-right: auto; }
|
||||
|
||||
.xp-insp { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
|
||||
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
|
||||
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
|
||||
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
|
||||
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
|
||||
.xp-insp-x { display: none; align-self: flex-end; }
|
||||
.xp-veil { display: none; }
|
||||
.kv dd { overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 1280px) { .xp { grid-template-columns: 208px minmax(0, 1fr) 300px; } }
|
||||
@media (max-width: 1100px) {
|
||||
.xp { grid-template-columns: minmax(0, 1fr); }
|
||||
.xp-bar { flex-wrap: wrap; }
|
||||
.xp-navbtn { display: inline-grid; }
|
||||
.xp-nav { position: fixed; inset: 0 auto 0 0; width: min(86vw, 310px); z-index: 40; border-radius: 0 var(--radius) var(--radius) 0; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
|
||||
.xp.show-nav .xp-nav { transform: none; }
|
||||
.xp-insp { position: fixed; left: 0; right: 0; bottom: 0; max-height: 80vh; z-index: 40; border-radius: 20px 20px 0 0; transform: translateY(104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
|
||||
.xp.show-insp .xp-insp { transform: none; }
|
||||
.xp-insp-x { display: inline-grid; }
|
||||
.xp-veil { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .32); opacity: 0; pointer-events: none; transition: opacity .2s; display: block; }
|
||||
.xp.show-nav .xp-veil, .xp.show-insp .xp-veil { opacity: 1; pointer-events: auto; }
|
||||
.xp-q { flex: 1 1 140px !important; }
|
||||
.xp-crumb { flex: 1 1 100%; order: -1; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.page.wide { padding: 10px 10px 0; }
|
||||
.xp-lrow { grid-template-columns: 26px 40px minmax(0, 1fr) 64px; } .xp-lrow .xp-lcol { display: none; }
|
||||
.xp-bar input[type="range"] { display: none !important; }
|
||||
}
|
||||
|
||||
.fld { display: grid; gap: 4px; margin: 10px 0; font-size: 13px; color: var(--text-2); }
|
||||
.fld textarea { resize: vertical; font: inherit; }
|
||||
.hits { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.hit { display: grid; grid-template-columns: 130px 80px 90px minmax(0, 1fr); gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--line); font-size: 13px; align-items: start; }
|
||||
.hit.head { background: var(--surface-2); color: var(--text-3); font-size: 12px; }
|
||||
.hit .ctx { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; color: var(--text-2); }
|
||||
.hit mark { background: var(--accent-soft); color: var(--accent); padding: 0 2px; border-radius: 3px; font-weight: 600; }
|
||||
@media (max-width: 860px) { .hit { grid-template-columns: 100px 1fr; } .hit span:nth-child(2), .hit span:nth-child(3) { display: none; } }
|
||||
|
||||
.xp-insp #xpPv { cursor: zoom-in; }
|
||||
.pv-card { width: 100%; min-height: 220px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; text-align: center; }
|
||||
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
|
||||
.pv-bigicon { font-size: 52px; line-height: 1; }
|
||||
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
.cand { display: grid; gap: 4px; margin: 6px 0 12px; max-height: 240px; overflow: auto; }
|
||||
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
|
||||
.cand .s { white-space: normal; }
|
||||
|
||||
/* ------------------------------------------------------------ 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); }
|
||||
.viewer.db-large { display: block; overflow: hidden; }
|
||||
.db-wrap { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: 100%; min-height: 0; width: 100%; text-align: left; }
|
||||
.db-tables { overflow: auto; padding: 10px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
|
||||
.db-t { display: flex; justify-content: space-between; gap: 8px; border: 0; background: transparent; cursor: pointer; padding: 7px 10px; border-radius: 9px; color: var(--text); font-size: 13.5px; text-align: left; }
|
||||
.db-t:hover { background: color-mix(in srgb, var(--text) 7%, transparent); } .db-t[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.db-t small { color: var(--text-3); font-weight: 400; }
|
||||
.db-meta { margin-top: 12px; display: grid; gap: 2px; font-size: 12.5px; color: var(--text-2); } .db-meta b { margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
|
||||
.db-main { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; }
|
||||
.db-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); }
|
||||
.db-bar .input { flex: 0 1 220px; }
|
||||
.db-grid { overflow: auto; }
|
||||
.db-foot { display: flex; justify-content: space-between; align-items: center; padding: 8px 14px; border-top: 1px solid var(--line); }
|
||||
.db-table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
|
||||
.db-table th, .db-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
|
||||
.db-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; color: var(--text-3); font-weight: 600; text-align: left; }
|
||||
.db-table tbody th { color: var(--text-3); font-weight: 400; text-align: right; }
|
||||
.db-table tr.del td { background: var(--warn-soft); } .db-table tr.del th { color: var(--warn); }
|
||||
.db-null { color: var(--text-3); font-style: italic; } .db-blob { color: var(--accent); }
|
||||
.db-ts { margin-left: 6px; font-style: normal; font-size: 11px; color: var(--accent); background: var(--accent-soft); padding: 0 5px; border-radius: 6px; }
|
||||
@media (max-width: 860px) { .db-wrap { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } .db-tables { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } }
|
||||
|
||||
.xp-map { position: relative; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 10px; margin-top: 8px; background: var(--surface-2, #e6eeee); }
|
||||
.xp-tiles { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; height: 100%; }
|
||||
.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); }
|
||||
|
||||
/* 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; }
|
||||
.h-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.h-tile.warning b { color: var(--warn); } .h-tile.failing b { color: var(--bad); }
|
||||
.h-status.ok { background: var(--ok-soft); } .h-status.ok b { color: var(--ok); }
|
||||
.h-status.warning { background: var(--warn-soft); } .h-status.failing { background: var(--bad-soft); }
|
||||
.h-status b span { font-weight: 500; opacity: .8; }
|
||||
.h-info { margin-top: 14px; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 14px 16px; }
|
||||
.h-scroll { overflow-x: auto; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); }
|
||||
.h-table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.h-table th { text-align: left; color: var(--text-3); font-weight: 500; padding: 8px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.2)); white-space: nowrap; }
|
||||
.h-table td { padding: 6px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.12)); white-space: nowrap; }
|
||||
.h-table tr:last-child td { border-bottom: 0; }
|
||||
.h-table .dim { color: var(--text-3); }
|
||||
.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: a single artwork (orange on transparent) works for both light and dark themes */
|
||||
.logo-img { display: block; flex: none; }
|
||||
|
||||
/* 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); }
|
||||
.update-card .upd-url { word-break: break-all; font-size: 12.5px; }
|
||||
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.update-card .sub { margin: 0; }
|
||||
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
|
||||
@@ -1,5 +1,8 @@
|
||||
// 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';
|
||||
|
||||
const tauri = window.__TAURI__;
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -7,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 });
|
||||
@@ -21,7 +24,42 @@ async function rpc(method, p = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ utilidades
|
||||
const isWin = /Windows/i.test(navigator.userAgent);
|
||||
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 || '')}`);
|
||||
|
||||
// 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;
|
||||
el.innerHTML = html;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ theme
|
||||
function applyTheme(t) {
|
||||
const root = document.documentElement;
|
||||
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
|
||||
document.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === (t || 'auto'))));
|
||||
}
|
||||
try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { applyTheme('auto'); }
|
||||
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
|
||||
const t = b.dataset.themePick;
|
||||
applyTheme(t);
|
||||
try { localStorage.setItem('guara-theme', t); } catch { /* no storage */ }
|
||||
saveSettings({ theme: t });
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------------------ 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 { /* continue with local storage only */ }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ utilities
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const human = (n) => {
|
||||
@@ -35,7 +73,7 @@ const dur = (s) => {
|
||||
s = Math.round(s);
|
||||
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
|
||||
};
|
||||
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—');
|
||||
const date = (t) => (t ? new Date(t * 1000).toLocaleString(LOC(), { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data');
|
||||
const CATS = [
|
||||
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
|
||||
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
|
||||
@@ -75,6 +113,31 @@ function ask({ title, text, value, placeholder, ok = 'OK', pick }) {
|
||||
});
|
||||
}
|
||||
|
||||
function askForm({ title, text, fields, ok = 'OK' }) {
|
||||
return new Promise((resolve) => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p>
|
||||
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${
|
||||
f.options ? `<select class="select" name="${f.name}">${f.options.map(([v, l]) => `<option value="${esc(v)}" ${v === f.value ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`
|
||||
: f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>`
|
||||
: `<span class="row" style="flex-wrap:nowrap"><input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">${f.pick && tauri?.dialog ? `<button type="button" class="btn" data-pick="${f.name}">Escolher…</button>` : ''}</span>`}</label>`).join('')}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
|
||||
d.querySelectorAll('[data-pick]').forEach((b) => (b.onclick = async () => {
|
||||
const f = fields.find((x) => x.name === b.dataset.pick);
|
||||
const r = await tauri.dialog.open(f.pick);
|
||||
if (r) d.querySelector(`[name="${f.name}"]`).value = Array.isArray(r) ? r[0] : r;
|
||||
}));
|
||||
d.onclose = () => {
|
||||
if (d.returnValue !== 'ok') return resolve(null);
|
||||
const out = {};
|
||||
d.querySelectorAll('[name]').forEach((el) => (out[el.name] = el.value.trim()));
|
||||
resolve(out);
|
||||
};
|
||||
d.showModal();
|
||||
d.querySelector('[name]')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function info({ title, html }) {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2>${html}<menu><button class="btn primary" value="ok">Entendi</button></menu></form>`;
|
||||
@@ -84,19 +147,20 @@ function info({ title, html }) {
|
||||
|
||||
// ------------------------------------------------------------------ estado
|
||||
const S = {
|
||||
elevated: true,
|
||||
devices: [],
|
||||
defaults: {},
|
||||
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,
|
||||
};
|
||||
@@ -125,7 +189,7 @@ function renderSide() {
|
||||
dl.innerHTML = S.devices.map((d, i) => `
|
||||
<li><button class="dev" data-i="${i}" aria-current="${S.device?.path === d.path}">
|
||||
<span class="ico">${icon(d.kind)}</span>
|
||||
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '—'}${d.readable ? '' : ' · requer admin'}</span></span>
|
||||
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '?'}${d.readable ? '' : ' · requer admin'}</span></span>
|
||||
</button></li>`).join('');
|
||||
dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); }));
|
||||
const sl = $('#sessionList');
|
||||
@@ -134,6 +198,31 @@ function renderSide() {
|
||||
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span>
|
||||
<span><span class="t">${esc(s.label)}</span><span class="s">${esc(s.target)}</span></span></button></li>`).join('');
|
||||
sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); }));
|
||||
const rl = $('#recentList');
|
||||
rl.dataset.empty = 'Nada salvo ainda';
|
||||
rl.innerHTML = (S.recent || []).map((r, i) => `
|
||||
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
|
||||
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
|
||||
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
||||
renderSide();
|
||||
}
|
||||
|
||||
async function openRecent(r) {
|
||||
setMenu(false);
|
||||
try {
|
||||
const { job } = await rpc('session_load', { path: r.path });
|
||||
const st = await rpc('status', { job });
|
||||
let s = S.sessions.find((x) => x.job === job);
|
||||
if (!s) {
|
||||
s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
}
|
||||
openJob(s);
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
|
||||
$('#openImageBtn').onclick = async () => {
|
||||
@@ -161,40 +250,104 @@ $('#loadSessionBtn').onclick = async () => {
|
||||
try {
|
||||
const { job } = await rpc('session_load', { path });
|
||||
const st = await rpc('status', { job });
|
||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
let s = S.sessions.find((x) => x.job === job);
|
||||
if (!s) {
|
||||
s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
}
|
||||
openJob(s);
|
||||
} catch (e) { toast(e.message); }
|
||||
} 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();
|
||||
S.device = null; S.job = null;
|
||||
renderSide();
|
||||
main.innerHTML = `<div class="page">
|
||||
<section class="hero"><h1>Encontre o que você achou que tinha perdido.</h1>
|
||||
<p>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.</p></section>
|
||||
<section class="hero"><img class="logo-img hero-logo" src="logo.png" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
|
||||
<p>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.</p></section>
|
||||
<div class="cards">
|
||||
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
|
||||
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
|
||||
<div class="card"><h3>Origem protegida</h3><p>Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.</p></div>
|
||||
</div>
|
||||
${S.devices.length ? '' : `<div class="notice info" style="margin-top:22px">${warnSvg}<div><b>Nenhum disco apareceu.</b> Discos brutos exigem administrador. Rode <code>sudo guara ui</code> (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.</div></div>`}
|
||||
${!S.elevated ? `<div class="notice bad" style="margin-top:22px">${warnSvg}<div>Este disco exige permissão de administrador. <button class="btn sm" id="elevateHome" style="margin-left:6px">Reiniciar como administrador</button></div></div>` : S.devices.length ? '' : `<div class="notice info" style="margin-top:22px">${warnSvg}<div><b>Nenhum disco apareceu.</b> Discos brutos exigem administrador. Rode <code>sudo guara ui</code> (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.</div></div>`}
|
||||
</div>`;
|
||||
$('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dispositivo
|
||||
// ------------------------------------------------------------------ device
|
||||
async function openDevice(d) {
|
||||
main.classList.remove('fixed');
|
||||
stopPoll();
|
||||
S.device = d; S.job = null; S.layout = null;
|
||||
renderSide();
|
||||
main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`;
|
||||
main.scrollTop = 0;
|
||||
let layout = null, err = null;
|
||||
S.health = null;
|
||||
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
|
||||
S.layout = layout;
|
||||
renderDevice(err);
|
||||
// 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(() => {});
|
||||
}
|
||||
|
||||
const HEALTH_WORD = { ok: 'Bom', warning: 'Atenção', failing: 'Ruim' };
|
||||
|
||||
function paintHealth() {
|
||||
const el = $('#healthArea');
|
||||
const h = S.health;
|
||||
if (!el || !h) { if (el) el.innerHTML = ''; return; }
|
||||
if (!h.available) {
|
||||
el.innerHTML = `<div class="section"><h2>Saúde do disco</h2><p class="sub">${esc(h.summary)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
const bad = h.status === 'failing' || h.status === 'warning';
|
||||
const fmtN = (n) => Number(n).toLocaleString(LOC());
|
||||
const tile = (label, value, cls = '') => value == null ? '' : `<div class="h-tile ${cls}"><small>${label}</small><b>${value}</b></div>`;
|
||||
const temp = h.temperature_c;
|
||||
const tiles = [
|
||||
`<div class="h-tile h-status ${esc(h.status)}"><small>Estado</small><b>${HEALTH_WORD[h.status] || '?'}${h.health_pct != null ? ` <span>${h.health_pct}%</span>` : ''}</b></div>`,
|
||||
tile('Temperatura', temp == null ? null : `${temp} °C`, temp >= 60 ? 'failing' : temp >= 50 ? 'warning' : ''),
|
||||
tile('Horas ligado', h.power_on_hours == null ? null : `${fmtN(h.power_on_hours)} h`),
|
||||
tile('Vezes ligado', h.power_cycles == null ? null : fmtN(h.power_cycles)),
|
||||
tile('Total lido', h.host_reads == null ? null : human(h.host_reads)),
|
||||
tile('Total gravado', h.host_writes == null ? null : human(h.host_writes)),
|
||||
].join('');
|
||||
const info = (h.info || []).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
|
||||
const dash = (x) => x == null ? '<span class="dim">-</span>' : x;
|
||||
const attrs = h.attributes || [];
|
||||
const table = attrs.length ? `<details class="adv h-attrs"${bad ? ' open' : ''}><summary>Atributos SMART (${attrs.length})</summary>
|
||||
<div class="h-scroll"><table class="h-table"><thead><tr><th></th><th>ID</th><th>Atributo</th><th>Atual</th><th>Pior</th><th>Limite</th><th>Bruto</th></tr></thead><tbody>
|
||||
${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>` : '';
|
||||
// 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>
|
||||
${why ? `<dl class="kv" style="margin-top:6px">${why}</dl>` : ''}
|
||||
${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
|
||||
<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div></div></div>` : ''}
|
||||
<div class="section"><h2>Saúde do disco</h2>${scope}
|
||||
<div class="h-tiles">${tiles}</div>
|
||||
${info ? `<dl class="kv h-info">${info}</dl>` : ''}${table}</div>`;
|
||||
$('#healthImg')?.addEventListener('click', startImage);
|
||||
}
|
||||
|
||||
/** Disk showing signs of failure: offer to create a disk image before scanning. */
|
||||
function confirmRisky() {
|
||||
return new Promise((resolve) => {
|
||||
const h = S.health;
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Este disco parece estar com defeito</h2><p>${esc(h.summary)}</p>${(h.advice || []).map((a) => `<p class="sub">${esc(a)}</p>`).join('')}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn" value="scan">Varrer mesmo assim</button><button class="btn primary" value="image">Criar a imagem primeiro</button></menu></form>`;
|
||||
d.onclose = () => resolve(d.returnValue === 'image' ? 'image' : d.returnValue === 'scan' ? 'scan' : null);
|
||||
d.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function fsClass(p) {
|
||||
@@ -224,13 +377,14 @@ function renderDevice(err) {
|
||||
const notes = [];
|
||||
if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '<button class="btn sm" id="elevate" style="margin-left:6px">Reiniciar como administrador</button>' : 'Feche e rode de novo com <code>sudo guara ui</code> (Linux/macOS) ou como administrador (Windows).')]);
|
||||
if (err) notes.push(['bad', esc(err)]);
|
||||
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera — qualquer escrita pode sobrescrever o que você quer salvar.`]);
|
||||
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.`]);
|
||||
if (d.trim) notes.push(['warn', '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.']);
|
||||
(l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)]));
|
||||
|
||||
main.innerHTML = `<div class="page">
|
||||
<div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div>
|
||||
${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')}
|
||||
<div id="healthArea"></div>
|
||||
${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)}
|
||||
<div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''}
|
||||
${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')}
|
||||
@@ -244,24 +398,65 @@ function renderDevice(err) {
|
||||
<div class="mode"><h3>Criar imagem do disco</h3><p>Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.</p>
|
||||
<button class="btn lg" id="goImage" ${d.readable ? '' : 'disabled'}>Criar imagem…</button></div>
|
||||
</div>
|
||||
<h2 style="margin-top:26px">Ferramentas de perícia</h2>
|
||||
<div class="modes">
|
||||
<div class="mode"><h3>Pesquisar texto no disco</h3><p>Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.</p>
|
||||
<input class="input" id="srchTerms" placeholder="termos separados por vírgula" aria-label="Termos a pesquisar" style="margin-bottom:8px">
|
||||
<button class="btn lg" id="goSearch" ${d.readable ? '' : 'disabled'}>Pesquisar</button></div>
|
||||
<div class="mode"><h3>Hashes da origem</h3><p>Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.</p>
|
||||
<button class="btn lg" id="goHash" ${d.readable ? '' : 'disabled'}>Calcular hashes</button></div>
|
||||
</div>
|
||||
<details class="adv"><summary>Opções avançadas</summary>
|
||||
<label class="check"><input type="checkbox" id="optAlloc"><span><b>Incluir áreas ainda em uso</b><br><span class="sub">Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.</span></span></label>
|
||||
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
|
||||
<div class="sub">Sem nenhuma categoria marcada, todos os tipos são procurados.</div>
|
||||
</details>
|
||||
<details class="adv"><summary>Volumes cifrados, RAID e vídeos</summary>
|
||||
<label class="fld"><span>Senha do volume (BitLocker, LUKS, FileVault 2)</span><input class="input" id="optPw" type="password" autocomplete="off"></label>
|
||||
<label class="fld"><span>Chave de recuperação do BitLocker</span><input class="input" id="optRk" placeholder="000000-000000-... (8 grupos)" autocomplete="off"></label>
|
||||
<label class="fld"><span>Arquivo de chave (.BEK ou keyfile)</span><input class="input" id="optKf" placeholder="/caminho/chave.BEK"></label>
|
||||
<label class="fld"><span>Outros discos do mesmo conjunto (RAID/LVM), um caminho por linha</span><textarea class="input" id="optMembers" rows="3"></textarea></label>
|
||||
<label class="check"><input type="checkbox" id="optRaid"><span><b>Estes discos formam um RAID sem metadados</b><br><span class="sub">O Guará descobre sozinho o nível, a faixa, o layout e a ordem dos discos.</span></span></label>
|
||||
<label class="fld"><span>Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)</span><input class="input" id="optRef" placeholder="/caminho/video-bom.mp4"></label>
|
||||
<label class="check"><input type="checkbox" id="optRemember"><span><b>Lembrar as chaves dos volumes abertos na sessão</b><br><span class="sub">A senha nunca é gravada; só a chave derivada.</span></span></label>
|
||||
</details>
|
||||
</div></div>`;
|
||||
$('#elevate')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
|
||||
const opts = (mode) => ({
|
||||
mode,
|
||||
assembly: (() => {
|
||||
const v = (id) => ($(id)?.value || '').trim();
|
||||
const secrets = [];
|
||||
if (v('#optPw')) secrets.push({ kind: 'password', value: v('#optPw') });
|
||||
if (v('#optRk')) secrets.push({ kind: 'recovery', value: v('#optRk') });
|
||||
if (v('#optKf')) secrets.push({ kind: 'keyfile', path: v('#optKf') });
|
||||
return {
|
||||
secrets,
|
||||
members: v('#optMembers').split('\n').map((x) => x.trim()).filter(Boolean),
|
||||
raid: $('#optRaid')?.checked ? {} : null,
|
||||
remember_keys: $('#optRemember')?.checked || false,
|
||||
};
|
||||
})(),
|
||||
references: $('#optRef')?.value.trim() ? [$('#optRef').value.trim()] : [],
|
||||
include_allocated: $('#optAlloc')?.checked || false,
|
||||
categories: [...document.querySelectorAll('#optCats input:checked')].map((i) => i.value),
|
||||
});
|
||||
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
|
||||
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
|
||||
$('#goImage')?.addEventListener('click', startImage);
|
||||
paintHealth();
|
||||
$('#goSearch')?.addEventListener('click', startSearch);
|
||||
$('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); });
|
||||
$('#goHash')?.addEventListener('click', startHash);
|
||||
}
|
||||
|
||||
async function startScan(options) {
|
||||
if (S.health && (S.health.status === 'failing' || S.health.status === 'warning') && options.mode === 'deep' && !S.healthAck?.[S.device.path]) {
|
||||
const c = await confirmRisky();
|
||||
if (c === 'image') return startImage();
|
||||
if (!c) return;
|
||||
(S.healthAck ??= {})[S.device.path] = true;
|
||||
}
|
||||
try {
|
||||
const { job } = await rpc('scan_start', { path: S.device.path, options });
|
||||
const s = { job, target: S.device.path, label: S.device.name };
|
||||
@@ -272,10 +467,18 @@ async function startScan(options) {
|
||||
|
||||
async function startImage() {
|
||||
const base = (S.defaults.home || '') + (S.defaults.sep || '/');
|
||||
const dest = await ask({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', value: base + 'disco-guara.img', ok: 'Criar imagem' });
|
||||
if (!dest) return;
|
||||
const f = await askForm({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', ok: 'Criar imagem', fields: [
|
||||
{ name: 'dest', label: 'Arquivo de destino', value: base + 'disco-guara.img' },
|
||||
{ name: 'format', label: 'Formato da imagem', value: 'raw', options: [['raw', 'Bruta (.img)'], ['e01', 'Expert Witness (.E01, com hash e metadados)'], ['aff4', 'AFF4 (.aff4)']] },
|
||||
{ name: 'case', label: 'Número do caso (E01/AFF4)', value: '' },
|
||||
{ name: 'examiner', label: 'Examinador (E01/AFF4)', value: '' },
|
||||
] });
|
||||
if (!f || !f.dest) return;
|
||||
const dest = f.dest;
|
||||
const format = f.format === 'e01' ? { kind: 'e01', case_number: f.case, examiner: f.examiner }
|
||||
: f.format === 'aff4' ? { kind: 'aff4', case_number: f.case, examiner: f.examiner } : { kind: 'raw' };
|
||||
try {
|
||||
const { job } = await rpc('image_start', { path: S.device.path, dest });
|
||||
const { job } = await rpc('image_start', { path: S.device.path, dest, format });
|
||||
watchAux(job, 'Criando imagem do disco', (st) => {
|
||||
const r = st.report || {};
|
||||
const bad = (r.bad_ranges || []).length;
|
||||
@@ -284,77 +487,97 @@ async function startImage() {
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ varredura
|
||||
async function startHash() {
|
||||
try {
|
||||
const { job } = await rpc('hash_start', { path: S.device.path });
|
||||
watchAux(job, 'Calculando hashes da origem', (st) => {
|
||||
const r = st.report || {};
|
||||
S.sourceHashes = { ...r, path: S.device.path };
|
||||
return `<p>Hashes do conteúdo inteiro de <code>${esc(S.device.path)}</code>:</p><dl class="kv"><dt>MD5</dt><dd class="mono">${esc(r.md5)}</dd><dt>SHA-1</dt><dd class="mono">${esc(r.sha1)}</dd><dt>SHA-256</dt><dd class="mono">${esc(r.sha256)}</dd></dl><p class="sub">Eles serão incluídos nos relatórios gerados nesta sessão.</p>`;
|
||||
});
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function startSearch() {
|
||||
const terms = ($('#srchTerms')?.value || '').split(',').map((t) => t.trim()).filter(Boolean);
|
||||
if (!terms.length) { toast('Digite pelo menos um termo.'); $('#srchTerms')?.focus(); return; }
|
||||
try {
|
||||
const { job } = await rpc('search_start', { path: S.device.path, terms, ignore_case: true, utf16: true });
|
||||
openSearch(job, terms);
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
function openSearch(job, terms) {
|
||||
stopPoll();
|
||||
EX?.destroy(); EX = null;
|
||||
S.job = null;
|
||||
main.classList.remove('fixed');
|
||||
main.innerHTML = `<div class="page"><button class="back" id="backBtn">‹ ${esc(S.device?.name || 'Dispositivos')}</button>
|
||||
<h1 id="shTitle">Procurando…</h1><div class="sub">Termos: ${terms.map((t) => `<code>${esc(t)}</code>`).join(', ')}</div>
|
||||
<div id="shProg" style="margin:14px 0"></div><div class="hits" id="hits"></div></div>`;
|
||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||
const termRe = new RegExp(terms.map((t) => esc(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi');
|
||||
const tick2 = async () => {
|
||||
let st, res;
|
||||
try { st = await rpc('status', { job }); res = await rpc('search_results', { job, offset: 0, limit: 1000 }); } catch { return false; }
|
||||
const run = st.status === 'running';
|
||||
const p = st.progress || {};
|
||||
const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0;
|
||||
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString(LOC())} ocorrência${res.total === 1 ? '' : 's'}`;
|
||||
setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString(LOC())} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString(LOC())}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
|
||||
return run;
|
||||
};
|
||||
$('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); };
|
||||
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ scan
|
||||
let EX = null; // file explorer (grid, navigation, details)
|
||||
|
||||
function openJob(s) {
|
||||
stopPoll();
|
||||
EX?.destroy();
|
||||
EX = null;
|
||||
S.job = s.job;
|
||||
S.filters = { category: null, minConfidence: null, query: '', sort: 'confidence' };
|
||||
S.items = []; S.total = 0; S.selected = new Set(); S.allFiltered = false; S.current = null; S.status = null;
|
||||
S.status = null;
|
||||
renderSide();
|
||||
renderScan();
|
||||
tick();
|
||||
S.poll = setInterval(tick, 500);
|
||||
main.scrollTop = 0;
|
||||
}
|
||||
|
||||
function stopPoll() { clearInterval(S.poll); S.poll = null; }
|
||||
|
||||
let lastFound = -1, lastFilterKey = '';
|
||||
async function tick() {
|
||||
if (!S.job) return;
|
||||
let st;
|
||||
try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; }
|
||||
S.status = st;
|
||||
const key = JSON.stringify(S.filters);
|
||||
if (st.found !== lastFound || key !== lastFilterKey) {
|
||||
lastFound = st.found; lastFilterKey = key;
|
||||
await loadResults(true);
|
||||
}
|
||||
paintStatus();
|
||||
if (st.status !== 'running') {
|
||||
EX?.onStatus(st);
|
||||
// Keep polling while the scan is running or dates/EXIF are still being enriched.
|
||||
if (st.status !== 'running' && !st.enriching) {
|
||||
stopPoll();
|
||||
loadRecent();
|
||||
if (st.status === 'error') toast(st.error);
|
||||
await loadResults(true);
|
||||
paintStatus();
|
||||
} else if (!S.poll) {
|
||||
S.poll = setInterval(tick, 700);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResults(reset) {
|
||||
const f = S.filters;
|
||||
const query = { sort: f.sort, offset: reset ? 0 : S.items.length, limit: 200 };
|
||||
if (f.category) query.category = f.category;
|
||||
if (f.minConfidence) query.min_confidence = f.minConfidence;
|
||||
if (f.query) query.query = f.query;
|
||||
const r = await rpc('results', { job: S.job, query });
|
||||
S.items = reset ? r.items : S.items.concat(r.items);
|
||||
S.total = r.total;
|
||||
paintList();
|
||||
}
|
||||
|
||||
function renderScan() {
|
||||
main.innerHTML = `<div class="page">
|
||||
<button class="back" id="backBtn">‹ Dispositivos</button>
|
||||
<div class="dev-head"><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
||||
main.classList.add('fixed');
|
||||
main.innerHTML = `<div class="page wide fill">
|
||||
<div class="xp-top"><button class="back" id="backBtn">‹ Dispositivos</button><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
||||
<div id="progressArea"></div>
|
||||
<div id="warnArea"></div>
|
||||
<div class="toolbar">
|
||||
<div class="chips" id="chips" role="group" aria-label="Filtrar por tipo"></div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:0">
|
||||
<input class="input" id="q" type="search" placeholder="Buscar por nome ou pasta" value="${esc(S.filters.query)}" aria-label="Buscar">
|
||||
<select class="select" id="conf" aria-label="Confiança mínima"><option value="">Qualquer confiança</option><option value="medium">Média ou alta</option><option value="high">Só alta</option></select>
|
||||
<select class="select" id="sort" aria-label="Ordenar"><option value="confidence">Mais confiáveis</option><option value="size">Maiores</option><option value="name">Nome</option></select>
|
||||
</div>
|
||||
<div class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
|
||||
</div><div id="barHost"></div>`;
|
||||
<div class="xp-host" id="xpHost"></div>
|
||||
</div>`;
|
||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||
let t;
|
||||
$('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); };
|
||||
$('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); };
|
||||
$('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); };
|
||||
paintPreview();
|
||||
$('#progressArea').onclick = onProgressClick;
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent), getSettings: () => SETTINGS });
|
||||
}
|
||||
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
|
||||
|
||||
function paintStatus() {
|
||||
const st = S.status;
|
||||
@@ -366,146 +589,129 @@ function paintStatus() {
|
||||
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
||||
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
||||
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||
$('#progressArea').innerHTML = `<div class="progress-card">
|
||||
<div class="row spread"><div><b>${esc(p.phase || (running ? 'Iniciando' : ''))}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div>
|
||||
<div class="row">${running ? '<button class="btn sm" id="pauseBtn">Pausar</button><button class="btn sm danger" id="stopBtn">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" id="resumeBtn">Continuar</button>' : ''}
|
||||
${!running && st.found ? '<button class="btn sm" id="saveBtn">Salvar sessão…</button>' : ''}</div></div>
|
||||
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
|
||||
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>confiança alta</span></div>
|
||||
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||
</div></div>`;
|
||||
$('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); });
|
||||
$('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); });
|
||||
$('#resumeBtn')?.addEventListener('click', async () => { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); });
|
||||
$('#saveBtn')?.addEventListener('click', async () => {
|
||||
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
|
||||
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (e) { toast(e.message); } }
|
||||
});
|
||||
$('#warnArea').innerHTML = (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||
// chips
|
||||
const total = st.found;
|
||||
$('#chips').innerHTML = [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
|
||||
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).join('');
|
||||
$('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); }));
|
||||
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
|
||||
paintBar();
|
||||
}
|
||||
|
||||
function paintList() {
|
||||
const el = $('#table');
|
||||
if (!el) return;
|
||||
const st = S.status;
|
||||
if (!S.items.length) {
|
||||
el.innerHTML = `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${st && st.status === 'running' ? 'Os arquivos aparecem aqui assim que são encontrados.' : (S.filters.category || S.filters.query || S.filters.minConfidence) ? 'Nenhum arquivo combina com os filtros.' : 'Não encontramos arquivos recuperáveis neste disco.'}</p></div>`;
|
||||
return;
|
||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
|
||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
|
||||
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||
if (running || st.status === 'paused') {
|
||||
setHTML($('#progressArea'), `<div class="progress-card slim">
|
||||
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
|
||||
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||
<div class="stats compact">
|
||||
<div class="stat"><b>${st.found.toLocaleString(LOC())}</b><span>arquivos</span></div>
|
||||
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
|
||||
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||
</div></div>`);
|
||||
} else {
|
||||
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString(LOC())}</b> arquivos em ${dur(st.elapsed)}</span>
|
||||
<span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`);
|
||||
}
|
||||
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
|
||||
el.innerHTML = `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
|
||||
+ S.items.map((i) => {
|
||||
const [cl, cc] = CONF[i.confidence];
|
||||
const on = S.allFiltered || S.selected.has(i.id);
|
||||
return `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
|
||||
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
|
||||
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
|
||||
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
|
||||
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
|
||||
}).join('')
|
||||
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : '');
|
||||
$('#selAll').onchange = (e) => {
|
||||
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
|
||||
paintList(); paintBar();
|
||||
};
|
||||
el.querySelectorAll('.tr').forEach((row) => {
|
||||
const id = +row.dataset.id;
|
||||
const cb = $('input', row);
|
||||
cb.onclick = (e) => { e.stopPropagation(); if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } cb.checked ? S.selected.add(id) : S.selected.delete(id); paintBar(); row.setAttribute('aria-selected', cb.checked); };
|
||||
row.onclick = () => focusItem(S.items.find((i) => i.id === id));
|
||||
row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusItem(S.items.find((i) => i.id === id)); } };
|
||||
});
|
||||
$('#moreBtn')?.addEventListener('click', () => loadResults(false));
|
||||
paintBar();
|
||||
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
|
||||
}
|
||||
|
||||
async function focusItem(item) {
|
||||
S.current = item;
|
||||
document.querySelectorAll('.tr').forEach((r) => r.setAttribute('aria-selected', (+r.dataset.id === item.id) || S.selected.has(+r.dataset.id) || S.allFiltered));
|
||||
paintPreview();
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(item.ext) && item.size < 8 * 1024 * 1024) {
|
||||
async function chooseSource() {
|
||||
let cands = [];
|
||||
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');
|
||||
const badge = (c) => (c.same_content === true ? '<span class="pill ok">mesmo disco</span>'
|
||||
: c.same_content === false ? '<span class="pill warn">tamanho igual, conteúdo diferente</span>'
|
||||
: c.same_size ? '<span class="pill accent">mesmo tamanho (precisa de administrador para conferir)</span>' : '');
|
||||
d.innerHTML = `<form method="dialog"><h2>Escolher a origem</h2>
|
||||
<p>Esta sessão foi feita sobre <code>${esc(st.target || '')}</code>${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.</p>
|
||||
<div class="cand">${cands.map((c, i) => `<button type="button" class="dev" data-c="${i}"><span class="ico">${icon(c.device.kind)}</span><span><span class="t">${esc(c.device.name)}</span><span class="s">${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)}</span></span></button>`).join('') || '<p class="sub">Nenhum disco encontrado. Informe o caminho abaixo.</p>'}</div>
|
||||
<label class="fld"><span>Ou o caminho de um dispositivo ou imagem</span><input class="input" id="srcPath" name="path" placeholder="/dev/sdb ou /caminho/imagem.img" value="${esc(st.source_path || st.target || '')}"></label>
|
||||
<label class="check"><input type="checkbox" name="force"><span>Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)</span></label>
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Usar esta origem</button></menu></form>`;
|
||||
d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => {
|
||||
d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false'));
|
||||
b.setAttribute('aria-current', 'true');
|
||||
$('#srcPath', d).value = cands[+b.dataset.c].device.path;
|
||||
}));
|
||||
d.onclose = async () => {
|
||||
if (d.returnValue !== 'ok') return;
|
||||
const path = $('#srcPath', d).value.trim();
|
||||
const force = d.querySelector('[name=force]').checked;
|
||||
if (!path) return;
|
||||
try {
|
||||
const p = await rpc('preview', { job: S.job, id: item.id });
|
||||
if (S.current?.id === item.id && p.mime) $('.thumb', $('#preview')).innerHTML = `<img alt="Pré-visualização de ${esc(item.name)}" src="data:${p.mime};base64,${p.base64}">`;
|
||||
} catch { /* sem prévia */ }
|
||||
await rpc('session_relink', { job: S.job, path, force });
|
||||
toast('Origem reconectada');
|
||||
const cur = { job: S.job, target: st.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
};
|
||||
d.showModal();
|
||||
}
|
||||
|
||||
async function onProgressClick(e) {
|
||||
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||
if (!act) return;
|
||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||
else if (act === 'resume') {
|
||||
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
catch (err) { toast(err.message); chooseSource(); }
|
||||
} else if (act === 'reconnect') {
|
||||
try {
|
||||
const r = await rpc('session_reconnect', { job: S.job });
|
||||
toast(r.renamed ? `Disco encontrado como ${r.renamed}` : 'Disco reconectado');
|
||||
const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch { chooseSource(); }
|
||||
}
|
||||
else if (act === 'relink') { chooseSource();
|
||||
} else if (act === 'csv') {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
} else if (act === 'report') {
|
||||
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.', ok: 'Gerar relatório', fields: [
|
||||
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
|
||||
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
|
||||
{ name: 'notes', label: 'Observações', multiline: true } ] });
|
||||
if (f) {
|
||||
try {
|
||||
const hashes = S.sourceHashes && S.sourceHashes.path === (S.status?.target) ? { md5: S.sourceHashes.md5, sha1: S.sourceHashes.sha1, sha256: S.sourceHashes.sha256 } : null;
|
||||
await rpc('export_report', { job: S.job, path: f.path, case: { case_id: f.case_id, examiner: f.examiner, organization: f.organization, notes: f.notes }, hashes });
|
||||
toast('Relatório gravado');
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
} else if (act === 'save') {
|
||||
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
|
||||
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (err) { toast(err.message); } }
|
||||
}
|
||||
}
|
||||
|
||||
function paintPreview() {
|
||||
const el = $('#preview');
|
||||
if (!el) return;
|
||||
const i = S.current;
|
||||
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
|
||||
const [cl, cc] = CONF[i.confidence];
|
||||
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
|
||||
<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
|
||||
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
|
||||
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>
|
||||
<div class="row"><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
|
||||
$('#recOne').onclick = () => recover({ ids: [i.id] });
|
||||
}
|
||||
|
||||
function selCount() { return S.allFiltered ? S.total : S.selected.size; }
|
||||
|
||||
function paintBar() {
|
||||
const host = $('#barHost');
|
||||
if (!host) return;
|
||||
const st = S.status;
|
||||
const n = selCount();
|
||||
const canAll = st && st.found > 0 && st.status !== 'error';
|
||||
if (!n && !canAll) { host.innerHTML = ''; return; }
|
||||
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
|
||||
? `<button class="btn sm" id="selFiltered">Selecionar todos os ${S.total.toLocaleString('pt-BR')}</button>` : '';
|
||||
host.innerHTML = `<div class="actionbar"><span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
|
||||
${n ? '<button class="btn primary" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
|
||||
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
|
||||
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
|
||||
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
|
||||
}
|
||||
|
||||
function currentFilter() {
|
||||
const f = S.filters;
|
||||
const q = { sort: f.sort, limit: 1 };
|
||||
if (f.category) q.category = f.category;
|
||||
if (f.minConfidence) q.min_confidence = f.minConfidence;
|
||||
if (f.query) q.query = f.query;
|
||||
return q;
|
||||
}
|
||||
|
||||
async function recover(sel) {
|
||||
const dest = await ask({
|
||||
const f = await askForm({
|
||||
title: 'Recuperar para…',
|
||||
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.',
|
||||
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
|
||||
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
|
||||
ok: 'Recuperar',
|
||||
pick: { directory: true, multiple: false },
|
||||
fields: [
|
||||
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
||||
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
|
||||
],
|
||||
});
|
||||
if (!dest) return;
|
||||
if (!f || !f.dest) return;
|
||||
const dest = f.dest;
|
||||
try {
|
||||
const { job } = await rpc('recover_start', { job: S.job, dest, ...sel });
|
||||
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
|
||||
watchAux(job, 'Recuperando arquivos', (st) => {
|
||||
const r = st.report || {};
|
||||
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
|
||||
${r.skipped ? `<p class="sub">${r.skipped} já estavam recuperados e conferidos (pulados).</p>` : ''}
|
||||
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista o SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
});
|
||||
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
|
||||
// Modal de progresso de tarefas auxiliares (recuperação / imagem).
|
||||
// Progress modal for auxiliary jobs (recovery / disk imaging).
|
||||
function watchAux(job, title, doneHtml) {
|
||||
const d = $('#dlg');
|
||||
d.onclose = null;
|
||||
@@ -534,16 +740,130 @@ function watchAux(job, title, doneHtml) {
|
||||
|
||||
async function openJobFromList(s) { openJob(s); }
|
||||
|
||||
// ------------------------------------------------------------------ início do app
|
||||
// ------------------------------------------------------------------ settings modal
|
||||
$('#settingsBtn')?.addEventListener('click', () => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
|
||||
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
||||
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
||||
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
|
||||
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
|
||||
d.querySelector('[name=lang]').value = SETTINGS.lang;
|
||||
d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); });
|
||||
d.onclose = () => {
|
||||
if (d.returnValue !== 'ok') return;
|
||||
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
|
||||
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
||||
setLang(patch.lang);
|
||||
patch.ui_lang = currentLang();
|
||||
saveSettings(patch);
|
||||
EX?.relang?.();
|
||||
toast('Salvo');
|
||||
};
|
||||
d.showModal();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ 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 {
|
||||
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() {
|
||||
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); }
|
||||
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>
|
||||
<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">
|
||||
<button class="btn sm" id="updLater">Depois</button>
|
||||
</div>`;
|
||||
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ close while scan is running
|
||||
function askClose() {
|
||||
return new Promise((resolve) => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Há uma varredura em andamento</h2><p>A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.</p>
|
||||
<menu><button class="btn" value="stay">Continuar varrendo</button><button class="btn" value="quit">Sair sem pausar</button><button class="btn primary" value="pause">Pausar e sair</button></menu></form>`;
|
||||
d.onclose = () => resolve(d.returnValue);
|
||||
d.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function watchClose() {
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (S.status?.status === 'running' && !tauri) { e.preventDefault(); e.returnValue = ''; }
|
||||
});
|
||||
const win = tauri?.window?.getCurrentWindow?.();
|
||||
if (!win?.onCloseRequested) return;
|
||||
win.onCloseRequested(async (ev) => {
|
||||
if (S.status?.status !== 'running') return;
|
||||
ev.preventDefault();
|
||||
const c = await askClose();
|
||||
if (c === 'stay' || !c) return;
|
||||
if (c === 'pause') {
|
||||
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)); // allow auto-save to flush
|
||||
} catch { /* exit anyway */ }
|
||||
}
|
||||
win.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ app startup
|
||||
(async function init() {
|
||||
try {
|
||||
const v = await rpc('version');
|
||||
$('#versionTag').textContent = `v${v.version}`;
|
||||
S.defaults = await rpc('defaults');
|
||||
try {
|
||||
const st = await rpc('settings_get');
|
||||
Object.assign(SETTINGS, st);
|
||||
applyTheme(st.theme);
|
||||
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
|
||||
} catch { /* usa o local */ }
|
||||
setLang(SETTINGS.lang);
|
||||
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
||||
watchClose();
|
||||
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')); });
|
||||
} catch (e) {
|
||||
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
||||
return;
|
||||
}
|
||||
await loadDevices();
|
||||
await loadRecent();
|
||||
renderHome();
|
||||
})();
|
||||
@@ -0,0 +1,679 @@
|
||||
// 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';
|
||||
|
||||
const CAT = {
|
||||
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
|
||||
archive: ['Compactados', '🗜'], database: ['Bancos de dados', '🗄'], other: ['Outros', '📦'],
|
||||
};
|
||||
const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] };
|
||||
const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];
|
||||
const SORTS = [
|
||||
['date', 'Data (mais recentes)'], ['date_asc', 'Data (mais antigas)'], ['name', 'Nome'], ['size', 'Tamanho'], ['confidence', 'Confiança'],
|
||||
];
|
||||
const GAP = 10;
|
||||
const PAD = 12;
|
||||
|
||||
const svg = (d, w = 18) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
||||
const I = {
|
||||
grid: svg('<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>'),
|
||||
list: svg('<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>'),
|
||||
filter: svg('<path d="M4 5h16l-6 8v6l-4-2v-4z"/>'),
|
||||
chev: svg('<path d="M9 6l6 6-6 6"/>', 14),
|
||||
x: svg('<path d="M6 6l12 12M18 6L6 18"/>', 14),
|
||||
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
||||
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
||||
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
|
||||
};
|
||||
|
||||
export function createExplorer(deps) {
|
||||
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
|
||||
const inlineMedia = !deps.noInlineMedia;
|
||||
const dbApi = (it) => ({
|
||||
info: () => rpc('db_info', { job, id: it.id }),
|
||||
rows: (table, offset, limit, deleted) => rpc('db_rows', { job, id: it.id, table, offset, limit, deleted }),
|
||||
});
|
||||
const openExternal = (it) => {
|
||||
toast('Preparando para abrir no player do sistema…');
|
||||
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
|
||||
};
|
||||
const E = {
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
|
||||
sort: 'date',
|
||||
view: 'grid',
|
||||
tile: 150,
|
||||
items: [],
|
||||
total: 0,
|
||||
sel: new Set(),
|
||||
allFiltered: false,
|
||||
anchor: -1,
|
||||
curId: null,
|
||||
facets: null,
|
||||
expanded: new Set(),
|
||||
rows: [],
|
||||
height: 0,
|
||||
rendered: new Map(),
|
||||
reloadToken: 0,
|
||||
lastReload: 0,
|
||||
lastFound: -1,
|
||||
wasEnriching: false,
|
||||
status: null,
|
||||
};
|
||||
try {
|
||||
E.view = localStorage.getItem('guara-view') || 'grid';
|
||||
E.tile = +localStorage.getItem('guara-tile') || 150;
|
||||
} catch { /* no storage */ }
|
||||
|
||||
host.innerHTML = `<div class="xp" data-view="${E.view}">
|
||||
<div class="xp-bar">
|
||||
<button class="icon-btn xp-navbtn" id="xpNavBtn" aria-label="Filtros">${I.filter}</button>
|
||||
<div class="xp-crumb" id="xpCrumb"></div>
|
||||
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
||||
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
||||
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
|
||||
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
||||
</div>
|
||||
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
||||
<section class="xp-main">
|
||||
<div class="xp-chips" id="xpChips"></div>
|
||||
<div class="xp-scroll" id="xpScroll" tabindex="0" role="listbox" aria-multiselectable="true" aria-label="Arquivos encontrados"><div class="xp-sizer" id="xpSizer"></div></div>
|
||||
<div class="xp-actions" id="xpActions"></div>
|
||||
</section>
|
||||
<aside class="xp-insp" id="xpInsp" aria-label="Detalhes"></aside>
|
||||
<div class="xp-veil" id="xpVeil"></div>
|
||||
</div>`;
|
||||
const $ = (s) => host.querySelector(s);
|
||||
const root = $('.xp'), scroll = $('#xpScroll'), sizer = $('#xpSizer'), nav = $('#xpNav'), insp = $('#xpInsp'), actions = $('#xpActions');
|
||||
$('#xpSort').value = E.sort;
|
||||
$('#xpSize').value = E.tile;
|
||||
|
||||
// ------------------------------------------------------------ data
|
||||
const toQuery = () => {
|
||||
const f = E.filters;
|
||||
const q = {};
|
||||
if (f.category) q.category = f.category;
|
||||
if (f.year) q.year = f.year;
|
||||
if (f.month) q.month = f.month;
|
||||
if (f.undated) q.undated = true;
|
||||
if (f.folder) q.folder = f.folder;
|
||||
if (f.camera) q.camera = f.camera;
|
||||
if (f.origin) q.origin = f.origin;
|
||||
if (f.minConf) q.min_confidence = f.minConf;
|
||||
if (f.gps) q.has_gps = true;
|
||||
if (f.dedupe) q.dedupe = true;
|
||||
if (f.bigOnly) q.min_size = 30 * 1024;
|
||||
if (f.mp1) q.min_mp = 1;
|
||||
if (f.query) q.query = f.query;
|
||||
return q;
|
||||
};
|
||||
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
|
||||
|
||||
async function reload() {
|
||||
const token = ++E.reloadToken;
|
||||
E.lastReload = Date.now();
|
||||
const base = { ...toQuery(), sort: E.sort };
|
||||
let items = [], total = 0;
|
||||
for (;;) {
|
||||
let r;
|
||||
try { r = await rpc('results', { job, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
|
||||
if (token !== E.reloadToken) return;
|
||||
items = items.concat(r.items);
|
||||
total = r.total;
|
||||
if (!r.items.length || items.length >= total || items.length >= 30000) break;
|
||||
if (items.length === 2000) { E.items = items; E.total = total; relayout(); paintAll(); }
|
||||
}
|
||||
E.items = items;
|
||||
E.total = total;
|
||||
relayout();
|
||||
paintAll();
|
||||
}
|
||||
|
||||
async function loadFacets() {
|
||||
try { E.facets = await rpc('facets', { job }); } catch { return; }
|
||||
paintNav();
|
||||
}
|
||||
|
||||
const reloadAll = () => { reload(); loadFacets(); };
|
||||
|
||||
// ------------------------------------------------------------ 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) => {
|
||||
if (k === 'Sem data') return k;
|
||||
return groupBy === 'day'
|
||||
? new Date(k + 'T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
|
||||
};
|
||||
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
|
||||
// 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' }));
|
||||
|
||||
function geometry() {
|
||||
const W = Math.max(200, scroll.clientWidth - PAD * 2);
|
||||
if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
|
||||
const T = E.tile;
|
||||
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
|
||||
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
|
||||
const cap = tw >= 128 ? 42 : 0;
|
||||
return { W, cols, tw, cap, rowH: tw + cap + GAP };
|
||||
}
|
||||
|
||||
let G = geometry();
|
||||
function relayout() {
|
||||
G = geometry();
|
||||
const grouped = E.view === 'grid' && (E.sort === 'date' || E.sort === 'date_asc');
|
||||
if (grouped) {
|
||||
groupBy = 'day';
|
||||
const days = new Set(E.items.map((it) => dayKey(it.when)));
|
||||
if (E.items.length / Math.max(1, days.size) < 2.5) groupBy = 'month';
|
||||
}
|
||||
const rows = [];
|
||||
let y = PAD;
|
||||
let cur = null, rowItems = [];
|
||||
const flush = () => {
|
||||
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
|
||||
};
|
||||
E.items.forEach((it, i) => {
|
||||
if (grouped) {
|
||||
const k = dayKey(it.when);
|
||||
if (k !== cur) {
|
||||
flush();
|
||||
cur = k;
|
||||
rows.push({ t: 'head', y, h: 40, key: k, start: i });
|
||||
y += 40;
|
||||
}
|
||||
}
|
||||
rowItems.push(i);
|
||||
if (rowItems.length === G.cols) flush();
|
||||
});
|
||||
flush();
|
||||
if (grouped) {
|
||||
// count per group
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
if (rows[r].t !== 'head') continue;
|
||||
let n = 0;
|
||||
for (let i = rows[r].start; i < E.items.length && dayKey(E.items[i].when) === rows[r].key; i++) n++;
|
||||
rows[r].count = n;
|
||||
}
|
||||
}
|
||||
E.rows = rows;
|
||||
E.height = y + PAD;
|
||||
sizer.style.height = `${E.height}px`;
|
||||
for (const el of E.rendered.values()) el.remove();
|
||||
E.rendered.clear();
|
||||
renderWindow();
|
||||
}
|
||||
|
||||
function rowAt(top) {
|
||||
let lo = 0, hi = E.rows.length - 1, ans = 0;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (E.rows[mid].y + E.rows[mid].h < top) lo = mid + 1; else { ans = mid; hi = mid - 1; }
|
||||
}
|
||||
return ans;
|
||||
}
|
||||
|
||||
function renderWindow() {
|
||||
if (!E.rows.length) { for (const el of E.rendered.values()) el.remove(); E.rendered.clear(); return; }
|
||||
const top = Math.max(0, scroll.scrollTop - 400), bottom = scroll.scrollTop + scroll.clientHeight + 400;
|
||||
const a = rowAt(top);
|
||||
let b = a;
|
||||
while (b < E.rows.length && E.rows[b].y < bottom) b++;
|
||||
for (const [ri, el] of E.rendered) {
|
||||
if (ri < a || ri >= b) { el.remove(); E.rendered.delete(ri); }
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let ri = a; ri < b; ri++) {
|
||||
if (E.rendered.has(ri)) continue;
|
||||
const row = E.rows[ri];
|
||||
const el = document.createElement('div');
|
||||
el.className = `xp-row ${row.t}`;
|
||||
el.style.top = `${row.y}px`;
|
||||
el.style.height = `${row.h}px`;
|
||||
el.dataset.ri = ri;
|
||||
el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
|
||||
E.rendered.set(ri, el);
|
||||
frag.appendChild(el);
|
||||
}
|
||||
sizer.appendChild(frag);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ items
|
||||
const isSel = (id) => E.allFiltered || E.sel.has(id);
|
||||
|
||||
function headHtml(row) {
|
||||
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
||||
}
|
||||
|
||||
function thumbSrc(it, px) {
|
||||
const s = px <= 200 ? 256 : px <= 330 ? 384 : 640;
|
||||
return thumbUrl(job, it.id, s);
|
||||
}
|
||||
|
||||
function tileHtml(i) {
|
||||
const it = E.items[i];
|
||||
const [cl, cc] = CONF[it.confidence];
|
||||
const on = isSel(it.id);
|
||||
const [catName, catIco] = CAT[it.category] || CAT.other;
|
||||
const media = it.category === 'image'
|
||||
? `<img loading="lazy" decoding="async" alt="" src="${thumbSrc(it, G.tw * (window.devicePixelRatio > 1 ? 1.5 : 1))}" data-th="1">`
|
||||
: `<div class="xp-ico c-${it.category}"><span>${catIco}</span><b>${esc((it.ext || '?').toUpperCase().slice(0, 5))}</b></div>`;
|
||||
return `<div class="xp-tile" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''} style="width:${G.tw}px;height:${G.tw + G.cap}px" title="${esc(it.name)}">
|
||||
<div class="xp-thumb" style="height:${G.tw}px">${media}
|
||||
<span class="xp-chk" data-chk="1" aria-hidden="true"></span>
|
||||
<span class="xp-dot ${cc}" title="Confiança ${cl}"></span>
|
||||
${it.gps ? `<span class="xp-gps" title="Tem localização GPS">${I.pin}</span>` : ''}
|
||||
</div>
|
||||
${G.cap ? `<div class="xp-cap"><b>${esc(it.name)}</b><small>${human(it.size)}${it.when != null ? ' · ' + esc(fmtDay(it.when)) : ''}</small></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function listHtml(i) {
|
||||
const it = E.items[i];
|
||||
const [cl, cc] = CONF[it.confidence];
|
||||
const on = isSel(it.id);
|
||||
const [, catIco] = CAT[it.category] || CAT.other;
|
||||
const th = it.category === 'image' ? `<img loading="lazy" decoding="async" alt="" src="${thumbUrl(job, it.id, 128)}" data-th="1">` : `<span class="xp-lico c-${it.category}">${catIco}</span>`;
|
||||
return `<div class="xp-lrow" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''}>
|
||||
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth">${th}</div>
|
||||
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</small></div>
|
||||
<span class="xp-lcol">${esc(fmtDate(it.when))}</span><span class="xp-lcol num">${human(it.size)}</span><span class="pill ${cc}">${cl}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// failed images become a warning (file possibly damaged)
|
||||
scroll.addEventListener('error', (e) => {
|
||||
const img = e.target;
|
||||
if (img.tagName !== 'IMG' || !img.dataset.th) return;
|
||||
const wrap = img.closest('.xp-thumb, .xp-lth');
|
||||
img.remove();
|
||||
const offline = E.status && E.status.source === false;
|
||||
wrap?.insertAdjacentHTML('afterbegin', offline
|
||||
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
|
||||
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
}, true);
|
||||
|
||||
// ------------------------------------------------------------ selection
|
||||
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
|
||||
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
|
||||
|
||||
function paintSel() {
|
||||
const set = (el) => {
|
||||
const id = +el.dataset.id;
|
||||
el.setAttribute('aria-selected', String(isSel(id)));
|
||||
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
|
||||
};
|
||||
scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set);
|
||||
paintActions();
|
||||
}
|
||||
|
||||
function selectRange(a, b) {
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
for (let i = lo; i <= hi; i++) E.sel.add(E.items[i].id);
|
||||
}
|
||||
|
||||
function setCursor(i, { extend = false, toggle = false, reveal = false } = {}) {
|
||||
if (i < 0 || i >= E.items.length) return;
|
||||
const it = E.items[i];
|
||||
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(E.items.map((x) => x.id)); }
|
||||
if (extend && E.anchor >= 0) {
|
||||
E.sel = new Set();
|
||||
selectRange(E.anchor, i);
|
||||
} else if (toggle) {
|
||||
if (E.sel.has(it.id)) E.sel.delete(it.id); else E.sel.add(it.id);
|
||||
E.anchor = i;
|
||||
} else {
|
||||
E.sel = new Set([it.id]);
|
||||
E.anchor = i;
|
||||
}
|
||||
E.curId = it.id;
|
||||
if (reveal) revealIndex(i);
|
||||
paintSel();
|
||||
schedInspector();
|
||||
if (matchMedia('(max-width: 1100px)').matches && !extend && !toggle) root.classList.add('show-insp');
|
||||
}
|
||||
|
||||
function revealIndex(i) {
|
||||
const ri = E.rows.findIndex((r) => r.t === 'row' && r.idx.includes(i));
|
||||
if (ri < 0) return;
|
||||
const r = E.rows[ri];
|
||||
if (r.y < scroll.scrollTop + 4) scroll.scrollTop = r.y - 8;
|
||||
else if (r.y + r.h > scroll.scrollTop + scroll.clientHeight) scroll.scrollTop = r.y + r.h - scroll.clientHeight + 8;
|
||||
renderWindow();
|
||||
}
|
||||
|
||||
scroll.addEventListener('scroll', () => renderWindow(), { passive: true });
|
||||
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
|
||||
|
||||
scroll.addEventListener('click', (e) => {
|
||||
const hs = e.target.closest('[data-hsel]');
|
||||
if (hs) {
|
||||
const start = +hs.dataset.hsel, n = +hs.dataset.n;
|
||||
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(); }
|
||||
for (let i = start; i < start + n; i++) E.sel.add(E.items[i].id);
|
||||
paintSel();
|
||||
return;
|
||||
}
|
||||
const el = e.target.closest('.xp-tile, .xp-lrow');
|
||||
if (!el) return;
|
||||
const i = +el.dataset.i;
|
||||
if (e.target.closest('[data-chk]')) setCursor(i, { toggle: true });
|
||||
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
|
||||
});
|
||||
scroll.addEventListener('dblclick', (e) => {
|
||||
const el = e.target.closest('.xp-tile, .xp-lrow');
|
||||
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
|
||||
});
|
||||
scroll.addEventListener('keydown', (e) => {
|
||||
if (!E.items.length) return;
|
||||
let i = idxOf(E.curId);
|
||||
if (i < 0) i = 0;
|
||||
const cols = G.cols;
|
||||
const move = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, PageDown: cols * 4, PageUp: -cols * 4 }[e.key];
|
||||
if (move != null) {
|
||||
e.preventDefault();
|
||||
setCursor(Math.max(0, Math.min(E.items.length - 1, idxOf(E.curId) < 0 ? 0 : i + move)), { extend: e.shiftKey, reveal: true });
|
||||
} else if (e.key === 'Home') { e.preventDefault(); setCursor(0, { extend: e.shiftKey, reveal: true }); }
|
||||
else if (e.key === 'End') { e.preventDefault(); setCursor(E.items.length - 1, { extend: e.shiftKey, reveal: true }); }
|
||||
else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (E.curId == null) setCursor(0); openLightbox(idxOf(E.curId)); }
|
||||
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); E.allFiltered = true; paintSel(); }
|
||||
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ action bar
|
||||
function paintActions() {
|
||||
const n = selCount();
|
||||
const html = n
|
||||
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
||||
<button class="btn sm" data-a="clear">Limpar seleção</button>
|
||||
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString(LOC())}</button>` : ''}
|
||||
<button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>`
|
||||
: `<span class="info">${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString(LOC())})` : ''}</span>
|
||||
${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`;
|
||||
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
|
||||
}
|
||||
actions.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||
else if (a === 'all') { E.allFiltered = true; paintSel(); }
|
||||
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
||||
else if (a === 'recall') onRecover({ filter: toQuery() });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ sidebar navigation
|
||||
function paintNav() {
|
||||
const f = E.facets;
|
||||
if (!f) return;
|
||||
const F = E.filters;
|
||||
const node = (label, count, attrs, active, depth = 0, extra = '') =>
|
||||
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString(LOC())}</small></button>`;
|
||||
const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : '');
|
||||
|
||||
const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join('');
|
||||
const years = f.years.map((y) => {
|
||||
const open = E.expanded.has('y' + y.year) || F.year === y.year;
|
||||
return node(String(y.year), y.count, `data-f="year" data-v="${y.year}"`, F.year === y.year && !F.month, 0,
|
||||
`<span class="tw" data-tw="y${y.year}" aria-label="Expandir" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>`)
|
||||
+ (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) => {
|
||||
// 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;
|
||||
}).map((d) => {
|
||||
const hasKids = f.folders.some((o) => o.path.startsWith(d.path + '/'));
|
||||
const open = E.expanded.has('d' + d.path) || (F.folder || '').startsWith(d.path + '/');
|
||||
return node(esc(d.name), d.count, `data-f="folder" data-v="${esc(d.path)}"`, F.folder === d.path, d.depth,
|
||||
hasKids ? `<span class="tw" data-tw="d${esc(d.path)}" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>` : '<span class="tw"></span>');
|
||||
}).join('')
|
||||
+ (f.root ? node('/ (raiz)', f.root, 'data-f="folder" data-v="@raiz"', F.folder === '@raiz') : '')
|
||||
+ (f.carved ? node('Por assinatura', f.carved, 'data-f="folder" data-v="@carved"', F.folder === '@carved') : '');
|
||||
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
|
||||
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
|
||||
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
|
||||
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
|
||||
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
|
||||
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
|
||||
|
||||
const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
|
||||
+ sec('Tipos', cats) + sec('Datas', years) + sec('Pastas', folders) + sec('Câmeras', cams) + sec('Confiança', conf) + sec('Outros', extra);
|
||||
if (nav._h !== html) { nav._h = html; const st = nav.scrollTop; nav.innerHTML = html; nav.scrollTop = st; }
|
||||
}
|
||||
|
||||
nav.addEventListener('click', (e) => {
|
||||
const tw = e.target.closest('[data-tw]');
|
||||
if (tw) {
|
||||
const k = tw.dataset.tw;
|
||||
if (E.expanded.has(k)) E.expanded.delete(k); else E.expanded.add(k);
|
||||
nav._h = null; paintNav();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const b = e.target.closest('.xp-node');
|
||||
if (!b) return;
|
||||
const F = E.filters;
|
||||
const f = b.dataset.f, v = b.dataset.v;
|
||||
if (f === 'all') clearFilters(false);
|
||||
else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); }
|
||||
else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; }
|
||||
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
|
||||
else if (f === 'gps') F.gps = !F.gps;
|
||||
else if (f === 'dedupe') F.dedupe = !F.dedupe;
|
||||
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
|
||||
else if (f === 'mp1') F.mp1 = !F.mp1;
|
||||
else F[f] = F[f] === v ? null : v;
|
||||
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
|
||||
nav._h = null;
|
||||
root.classList.remove('show-nav');
|
||||
changed();
|
||||
});
|
||||
|
||||
function clearFilters(refresh = true) {
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
|
||||
$('#xpQ').value = '';
|
||||
if (refresh) changed(); else changed();
|
||||
}
|
||||
|
||||
function changed() {
|
||||
E.sel = new Set(); E.allFiltered = false; E.curId = null; E.anchor = -1;
|
||||
scroll.scrollTop = 0;
|
||||
paintCrumb(); paintChips(); paintNav(); paintInspector();
|
||||
reload();
|
||||
}
|
||||
|
||||
function paintCrumb() {
|
||||
const F = E.filters, parts = [];
|
||||
if (F.category) parts.push(CAT[F.category][0]);
|
||||
if (F.year) parts.push(String(F.year));
|
||||
if (F.month) parts.push(MONTHS[F.month - 1]);
|
||||
if (F.undated) parts.push('Sem data');
|
||||
if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › '));
|
||||
if (F.camera) parts.push(F.camera);
|
||||
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString(LOC())}</small>`;
|
||||
}
|
||||
|
||||
function paintChips() {
|
||||
const F = E.filters, chips = [];
|
||||
const add = (label, key) => chips.push(`<button class="chip" data-x="${key}">${label} ${I.x}</button>`);
|
||||
if (F.category) add(CAT[F.category][0], 'category');
|
||||
if (F.year) add(String(F.year), 'year');
|
||||
if (F.month) add(MONTHS[F.month - 1], 'month');
|
||||
if (F.undated) add('Sem data', 'undated');
|
||||
if (F.folder) add(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : esc(F.folder), 'folder');
|
||||
if (F.camera) add(esc(F.camera), 'camera');
|
||||
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
|
||||
if (F.gps) add('Com GPS', 'gps');
|
||||
if (F.dedupe) add('Sem duplicadas', 'dedupe');
|
||||
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
|
||||
if (F.mp1) add('Mais de 1 MP', 'mp1');
|
||||
if (F.query) add(`“${esc(F.query)}”`, 'query');
|
||||
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
|
||||
}
|
||||
$('#xpChips').addEventListener('click', (e) => {
|
||||
const k = e.target.closest('[data-x]')?.dataset.x;
|
||||
if (!k) return;
|
||||
if (k === 'all') return clearFilters();
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
|
||||
if (k === 'year') E.filters.month = null;
|
||||
if (k === 'query') $('#xpQ').value = '';
|
||||
nav._h = null;
|
||||
changed();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ 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(); });
|
||||
const paintView = () => {
|
||||
root.dataset.view = E.view;
|
||||
host.querySelectorAll('[data-view]').forEach((b) => { if (b.tagName === 'BUTTON') b.setAttribute('aria-pressed', String(b.dataset.view === E.view)); });
|
||||
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
|
||||
};
|
||||
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
|
||||
E.view = b.dataset.view;
|
||||
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
|
||||
paintView(); relayout();
|
||||
}));
|
||||
$('#xpSize').addEventListener('input', (e) => {
|
||||
E.tile = +e.target.value;
|
||||
try { localStorage.setItem('guara-tile', String(E.tile)); } catch { /* */ }
|
||||
relayout();
|
||||
});
|
||||
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
|
||||
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
|
||||
|
||||
// ------------------------------------------------------------ inspector panel
|
||||
let inspT, stopPv = null, metaToken = 0;
|
||||
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
|
||||
|
||||
function timesHtml(it) {
|
||||
const rows = [['Tirada em', it.taken], ['Modificado', it.modified], ['Criado', it.created], ['Acessado', it.accessed]].filter(([, v]) => v != null);
|
||||
return rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(fmtDate(v))}</dd>`).join('');
|
||||
}
|
||||
|
||||
function paintInspector() {
|
||||
stopPv?.(); stopPv = null;
|
||||
const n = selCount();
|
||||
const it = E.items[idxOf(E.curId)];
|
||||
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||
if (n > 1) {
|
||||
const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0);
|
||||
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString(LOC())} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
|
||||
<div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`;
|
||||
return;
|
||||
}
|
||||
if (!it) {
|
||||
insp.innerHTML = `${close}<div class="xp-empty-insp"><div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar.<br><span class="sub">Setas navegam, Espaço abre em tela cheia.</span></div></div></div>`;
|
||||
return;
|
||||
}
|
||||
const [cl, cc] = CONF[it.confidence];
|
||||
const url = kindOf(it) === 'image' ? thumbUrl(job, it.id, 640) : fileUrl(job, it.id);
|
||||
insp.innerHTML = `${close}<div class="viewer" id="xpPv"></div>
|
||||
<div class="xp-ititle"><b>${esc(it.name)}</b><span class="pill ${cc}">${cl}</span></div>
|
||||
<div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}</div>
|
||||
${it.note ? `<div class="pv-note bad" style="text-align:left">${esc(it.note)}</div>` : ''}
|
||||
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
|
||||
<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) });
|
||||
// clicking the thumbnail opens the large modal
|
||||
$('#xpPv').addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button, a, .pv-pdfbar')) return;
|
||||
openLightbox(idxOf(it.id));
|
||||
});
|
||||
const my = ++metaToken;
|
||||
rpc('metadata', { job, id: it.id }).then((m) => {
|
||||
if (my !== metaToken || !$('#xpMeta')) return;
|
||||
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
|
||||
if (!rows.length) return;
|
||||
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(fmtMeta(v))}</dd>`).join('')}</dl>`
|
||||
+ (m.gps ? gpsHtml(m.gps) : '');
|
||||
$('#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(() => {});
|
||||
}
|
||||
/** 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) {
|
||||
const z = 15, n = 2 ** z;
|
||||
const fx = (lon + 180) / 360 * n;
|
||||
const fy = (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n;
|
||||
const tx = Math.floor(fx), ty = Math.floor(fy);
|
||||
let imgs = '';
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) imgs += `<img src="https://tile.openstreetmap.org/${z}/${tx + dx}/${ty + dy}.png" alt="" loading="lazy" draggable="false">`;
|
||||
const px = (fx - tx + 1) / 3 * 100, py = (fy - ty + 1) / 3 * 100;
|
||||
map = `<div class="xp-map"><div class="xp-tiles">${imgs}</div><span class="xp-pin" style="left:${px}%;top:${py}%"></span></div>`;
|
||||
}
|
||||
return `<div class="sub" style="font-size:12.5px;margin-top:8px">Coordenadas: ${lat.toFixed(5)}, ${lon.toFixed(5)}</div>${map}
|
||||
<div class="row" style="margin-top:6px"><button class="btn sm" id="xpOsm">Abrir no OpenStreetMap</button></div>
|
||||
${map ? '<div class="sub" style="font-size:11px;margin-top:4px">© colaboradores do OpenStreetMap</div>' : ''}`;
|
||||
}
|
||||
insp.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('[data-i]')?.dataset.i;
|
||||
if (a === 'full') openLightbox(idxOf(E.curId));
|
||||
else if (a === 'rec1') onRecover({ ids: [E.curId] });
|
||||
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
||||
else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
|
||||
else if (a === 'close') root.classList.remove('show-insp');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ fullscreen
|
||||
let stopLb = null;
|
||||
function openLightbox(i) {
|
||||
const it = E.items[i];
|
||||
if (!it) return;
|
||||
const d = document.querySelector('#lightbox');
|
||||
const [cl] = CONF[it.confidence];
|
||||
d.innerHTML = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${i <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${i >= E.items.length - 1 ? 'disabled' : ''}>›</button>
|
||||
<div class="ttl"><b>${esc(it.name)}</b><small>${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}${it.when != null ? ' · ' + esc(fmtDate(it.when)) : ''} · confiança ${cl} · ${i + 1} de ${E.items.length}</small></div>
|
||||
<button class="icon-btn" id="lbClose" aria-label="Fechar">${I.close}</button></div>
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
stopLb?.();
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, db: dbApi(it), onExternal: () => openExternal(it) });
|
||||
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
|
||||
d.querySelector('#lbPrev').onclick = () => go(-1);
|
||||
d.querySelector('#lbNext').onclick = () => go(1);
|
||||
d.querySelector('#lbClose').onclick = () => d.close();
|
||||
d.querySelector('#lbRec').onclick = () => { d.close(); onRecover({ ids: [it.id] }); };
|
||||
d.onclose = () => { stopLb?.(); stopLb = null; scroll.focus({ preventScroll: true }); };
|
||||
d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); };
|
||||
if (!d.open) d.showModal();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ main paint
|
||||
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
|
||||
|
||||
paintView(); paintCrumb(); paintActions(); paintInspector();
|
||||
reloadAll();
|
||||
|
||||
return {
|
||||
/** Called on every scan status update. */
|
||||
onStatus(st) {
|
||||
E.status = st;
|
||||
const enriching = !!st.enriching;
|
||||
const now = Date.now();
|
||||
const foundChanged = st.found !== E.lastFound;
|
||||
const justDone = st.status !== 'running' && E.running;
|
||||
E.running = st.status === 'running';
|
||||
if ((foundChanged && now - E.lastReload > 1500) || justDone || (E.wasEnriching && !enriching) || (enriching && now - E.lastReload > 2500)) {
|
||||
E.lastFound = st.found;
|
||||
reloadAll();
|
||||
}
|
||||
E.wasEnriching = enriching;
|
||||
},
|
||||
query: () => toQuery(),
|
||||
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
|
||||
/** Language changed in Settings: rebuild JS-rendered elements (dates, months, labels). */
|
||||
relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
|
||||
focus() { scroll.focus({ preventScroll: true }); },
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#ff8a3d"/>
|
||||
<stop offset="1" stop-color="#d9441c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="30" fill="url(#g)"/>
|
||||
<!-- orelhas grandes e focinho longo do lobo-guará -->
|
||||
<path d="M30 22 L52 50 L38 62 Z" fill="#fff"/>
|
||||
<path d="M98 22 L76 50 L90 62 Z" fill="#fff"/>
|
||||
<path d="M38 62 L52 50 Q64 44 76 50 L90 62 L74 96 Q64 108 54 96 Z" fill="#fff"/>
|
||||
<path d="M54 80 Q64 90 74 80 L69 96 Q64 100 59 96 Z" fill="#3a1608"/>
|
||||
<circle cx="53" cy="68" r="4" fill="#3a1608"/>
|
||||
<circle cx="75" cy="68" r="4" fill="#3a1608"/>
|
||||
<path d="M54 52 Q64 47 74 52 L70 60 Q64 56 58 60 Z" fill="#ffd2b0"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 828 B |
@@ -0,0 +1,596 @@
|
||||
// Portuguese -> English UI dictionary. {n} marks a variable placeholder.
|
||||
export const EN = {
|
||||
// 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',
|
||||
'Setores incorrigíveis offline': 'Offline uncorrectable sectors',
|
||||
'Erros incorrigíveis reportados': 'Reported uncorrectable errors',
|
||||
'Erros de CRC na interface (cabo ou porta)': 'Interface CRC errors (cable or port)',
|
||||
'Erros registrados no log do disco': 'Errors in the disk log',
|
||||
'Aviso crítico NVMe': 'NVMe critical warning',
|
||||
'Erros de mídia': 'Media errors',
|
||||
'Reserva de blocos abaixo do limite': 'Spare blocks below threshold',
|
||||
'Erros de leitura/gravação vistos pelo sistema': 'Read/write errors seen by the system',
|
||||
'Estado': 'Status',
|
||||
'Bom': 'Good',
|
||||
'Atenção': 'Caution',
|
||||
'Ruim': 'Bad',
|
||||
'Temperatura': 'Temperature',
|
||||
'Horas ligado': 'Power on hours',
|
||||
'Vezes ligado': 'Power on count',
|
||||
'Total lido': 'Host reads',
|
||||
'Total gravado': 'Host writes',
|
||||
'Atributos SMART ({n})': 'SMART attributes ({n})',
|
||||
'Atributo': 'Attribute',
|
||||
'Atual': 'Current',
|
||||
'Pior': 'Worst',
|
||||
'Limite': 'Threshold',
|
||||
'Bruto': 'Raw',
|
||||
'Modelo': 'Model',
|
||||
'Nº de série': 'Serial number',
|
||||
'Família': 'Family',
|
||||
'Modo de transferência': 'Transfer mode',
|
||||
'Capacidade': 'Capacity',
|
||||
'Rotação': 'Rotation rate',
|
||||
'Formato': 'Form factor',
|
||||
'Recursos': 'Features',
|
||||
'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.',
|
||||
// sidebar and layout
|
||||
'Recuperação de arquivos': 'File recovery',
|
||||
'Dispositivos': 'Devices',
|
||||
'Dispositivos e sessões': 'Devices and sessions',
|
||||
'Atualizar lista': 'Refresh list',
|
||||
'Lista atualizada': 'List refreshed',
|
||||
'Abrir arquivo de imagem…': 'Open disk image file…',
|
||||
'Abrir sessão salva…': 'Open saved session…',
|
||||
'Sessões': 'Sessions',
|
||||
'Sessão': 'Session',
|
||||
'Sessão:': 'Session:',
|
||||
'Recentes': 'Recent',
|
||||
'(salvas sozinhas)': '(saved automatically)',
|
||||
'Nenhuma ainda': 'None yet',
|
||||
'Nada salvo ainda': 'Nothing saved yet',
|
||||
'Nenhum disco encontrado': 'No disks found',
|
||||
'Somente leitura na origem': 'Read-only on the source',
|
||||
'Abrir menu': 'Open menu',
|
||||
'Tema': 'Theme',
|
||||
'Auto': 'Auto',
|
||||
'Claro': 'Light',
|
||||
'Escuro': 'Dark',
|
||||
'Configurações': 'Settings',
|
||||
'· requer admin': '· needs admin',
|
||||
'requer admin': 'needs admin',
|
||||
'{n} · requer admin': '{n} · needs admin',
|
||||
// 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',
|
||||
'Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.': 'File system metadata and signature search in the same pass, without duplicates.',
|
||||
'Confiança por arquivo': 'Confidence per file',
|
||||
'Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.': 'Each finding shows the chance of opening correctly: high, medium or low.',
|
||||
'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.',
|
||||
// device
|
||||
'Mapa do disco': 'Disk map',
|
||||
'Como procurar': 'How to search',
|
||||
'Recomendado': 'Recommended',
|
||||
'Varredura profunda': 'Deep scan',
|
||||
'Varredura rápida': 'Quick scan',
|
||||
'Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.': 'Reads the metadata and examines all free space, damaged partitions and unpartitioned areas by signature. Finds more, takes longer.',
|
||||
'Procurar tudo': 'Search everything',
|
||||
'Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.': 'Only the file system records. In seconds it shows recently deleted files, with name and folder.',
|
||||
'Criar imagem do disco': 'Create disk image',
|
||||
'Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.': 'Copies the whole disk to a file, tolerating bad sectors. Ideal for failing disks: you work on the copy.',
|
||||
'Criar imagem…': 'Create image…',
|
||||
'Ferramentas de perícia': 'Forensic tools',
|
||||
'Pesquisar texto no disco': 'Search text on the disk',
|
||||
'Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.': 'Searches words across the whole disk, including free space and outside partitions (UTF-8 and UTF-16). Useful when there is no file structure.',
|
||||
'termos separados por vírgula': 'terms separated by commas',
|
||||
'Termos a pesquisar': 'Terms to search',
|
||||
'Pesquisar': 'Search',
|
||||
'Hashes da origem': 'Source hashes',
|
||||
'Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.': 'Computes MD5, SHA-1 and SHA-256 of the whole disk to record integrity and chain of custody in the report.',
|
||||
'Calcular hashes': 'Compute hashes',
|
||||
'Opções avançadas': 'Advanced options',
|
||||
'Incluir áreas ainda em uso': 'Include areas still in use',
|
||||
'Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.': 'Also looks for old copies inside existing files. Much slower.',
|
||||
'Sem nenhuma categoria marcada, todos os tipos são procurados.': 'With no category checked, all types are searched.',
|
||||
'Este disco exige permissão de administrador.': 'This disk requires administrator permission.',
|
||||
'Reiniciar como administrador': 'Restart as administrator',
|
||||
'Feche e rode de novo com': 'Close and run again with',
|
||||
'ou como administrador (Windows).': 'or as administrator (Windows).',
|
||||
'Espaço não particionado': 'Unpartitioned space',
|
||||
'Não particionado': 'Unpartitioned',
|
||||
'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.',
|
||||
// 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.',
|
||||
'Nenhum sinal de defeito encontrado pelo SMART.': 'No signs of failure found by SMART.',
|
||||
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
|
||||
'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.': 'Create the disk image first ("Create image" button or `guara image`) and scan the copy. Every extra read can make a failing disk worse.',
|
||||
'Criar a imagem primeiro': 'Create the image first',
|
||||
'Varrer mesmo assim': 'Scan anyway',
|
||||
'Cancelar': 'Cancel',
|
||||
'Este disco parece estar com defeito': 'This disk seems to be failing',
|
||||
// scan
|
||||
'‹ Dispositivos': '‹ Devices',
|
||||
'Procurando…': 'Searching…',
|
||||
'Varredura concluída': 'Scan complete',
|
||||
'Varredura pausada': 'Scan paused',
|
||||
'Varredura interrompida': 'Scan stopped',
|
||||
'Algo deu errado': 'Something went wrong',
|
||||
'Iniciando': 'Starting',
|
||||
'Pausar': 'Pause',
|
||||
'Parar': 'Stop',
|
||||
'Continuar': 'Continue',
|
||||
'Pausando…': 'Pausing…',
|
||||
'Reconectar o disco': 'Reconnect the disk',
|
||||
'Escolher a origem…': 'Choose the source…',
|
||||
'Escolher a origem': 'Choose the source',
|
||||
'Salvar sessão…': 'Save session…',
|
||||
'Exportar CSV': 'Export CSV',
|
||||
'Relatório': 'Report',
|
||||
'arquivos encontrados': 'files found',
|
||||
'arquivos': 'files',
|
||||
'decorrido': 'elapsed',
|
||||
'confiança alta': 'high confidence',
|
||||
'média': 'medium',
|
||||
'baixa': 'low',
|
||||
'alta': 'high',
|
||||
'· faltam ~{n}': '· ~{n} left',
|
||||
'· {n}%': '· {n}%',
|
||||
'{n} arquivos em {n}': '{n} files in {n}',
|
||||
'confiança alta {n} · média {n} · baixa {n}': 'high confidence {n} · medium {n} · low {n}',
|
||||
'Lendo datas e metadados das fotos… {n}/{n}': 'Reading dates and metadata from photos… {n}/{n}',
|
||||
'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',
|
||||
// explorer
|
||||
'Navegação': 'Navigation',
|
||||
'Filtros': 'Filters',
|
||||
'Todos os arquivos': 'All files',
|
||||
'Tudo': 'All',
|
||||
'Tipos': 'Types',
|
||||
'Datas': 'Dates',
|
||||
'Pastas': 'Folders',
|
||||
'Câmeras': 'Cameras',
|
||||
'Confiança': 'Confidence',
|
||||
'Outros': 'Other',
|
||||
'Fotos': 'Photos',
|
||||
'Documentos': 'Documents',
|
||||
'Vídeos': 'Videos',
|
||||
'Áudio': 'Audio',
|
||||
'Compactados': 'Archives',
|
||||
'Bancos de dados': 'Databases',
|
||||
'Raiz': 'Root',
|
||||
'/ (raiz)': '/ (root)',
|
||||
'Por assinatura': 'By signature',
|
||||
'Sem data': 'No date',
|
||||
'sem data': 'no date',
|
||||
'Com localização (GPS)': 'With location (GPS)',
|
||||
'Com GPS': 'With GPS',
|
||||
'Ocultar duplicadas': 'Hide duplicates',
|
||||
'Sem duplicadas': 'Without duplicates',
|
||||
'Ocultar arquivos pequenos (menos de 30 KB)': 'Hide small files (under 30 KB)',
|
||||
'Sem arquivos pequenos': 'Without small files',
|
||||
'Mais de 1 megapixel': 'Over 1 megapixel',
|
||||
'Mais de 1 MP': 'Over 1 MP',
|
||||
'Limpar filtros': 'Clear filters',
|
||||
'Buscar por nome, pasta ou câmera': 'Search by name, folder or camera',
|
||||
'Buscar': 'Search',
|
||||
'Ordenar': 'Sort',
|
||||
'Data (mais recentes)': 'Date (newest)',
|
||||
'Data (mais antigas)': 'Date (oldest)',
|
||||
'Nome': 'Name',
|
||||
'Tamanho': 'Size',
|
||||
'Modo de exibição': 'View mode',
|
||||
'Grade': 'Grid',
|
||||
'Lista': 'List',
|
||||
'Grade de miniaturas': 'Thumbnail grid',
|
||||
'Tamanho das miniaturas': 'Thumbnail size',
|
||||
'Arquivos encontrados': 'Files found',
|
||||
'Detalhes': 'Details',
|
||||
'janeiro': 'January', 'fevereiro': 'February', 'março': 'March', 'abril': 'April', 'maio': 'May', 'junho': 'June',
|
||||
'julho': 'July', 'agosto': 'August', 'setembro': 'September', 'outubro': 'October', 'novembro': 'November', 'dezembro': 'December',
|
||||
'{n} item': '{n} item',
|
||||
'{n} itens': '{n} items',
|
||||
'{n} itens nesta vista': '{n} items in this view',
|
||||
'{n} item nesta vista': '{n} item in this view',
|
||||
'{n} itens nesta vista (mostrando {n})': '{n} items in this view (showing {n})',
|
||||
'{n} selecionado': '{n} selected',
|
||||
'{n} selecionados': '{n} selected',
|
||||
'{n} selecionado (todos desta vista)': '{n} selected (all in this view)',
|
||||
'{n} selecionados (todos desta vista)': '{n} selected (all in this view)',
|
||||
'{n} itens selecionados': '{n} items selected',
|
||||
'{n} no total': '{n} in total',
|
||||
'Selecionar grupo': 'Select group',
|
||||
'Selecionar este grupo': 'Select this group',
|
||||
'Selecionar tudo': 'Select all',
|
||||
'Selecionar todos os {n}': 'Select all {n}',
|
||||
'Limpar seleção': 'Clear selection',
|
||||
'Recuperar tudo desta vista': 'Recover everything in this view',
|
||||
'Recuperar selecionado': 'Recover selected',
|
||||
'Recuperar selecionados': 'Recover selected',
|
||||
'Recuperar este arquivo': 'Recover this file',
|
||||
'Tela cheia': 'Full screen',
|
||||
'Fechar': 'Close',
|
||||
'Anterior': 'Previous',
|
||||
'Próximo': 'Next',
|
||||
'← → para navegar · Esc para fechar': '← → to navigate · Esc to close',
|
||||
'Selecione um arquivo para pré-visualizar.': 'Select a file to preview.',
|
||||
'Selecione um arquivo para pré-visualizar': 'Select a file to preview',
|
||||
'Setas navegam, Espaço abre em tela cheia.': 'Arrows navigate, Space opens full screen.',
|
||||
'Pasta': 'Folder',
|
||||
'Modificado': 'Modified',
|
||||
'Criado': 'Created',
|
||||
'Acessado': 'Accessed',
|
||||
'Tirada em': 'Taken on',
|
||||
'Câmera': 'Camera',
|
||||
'Resolução': 'Resolution',
|
||||
'Metadados': 'Metadata',
|
||||
'Metadados · {n}': 'Metadata · {n}',
|
||||
'Coordenadas:': 'Coordinates:',
|
||||
'Aviso': 'Warning',
|
||||
'Encontrado por assinatura': 'Found by signature',
|
||||
'Assinatura de formato': 'Format signature',
|
||||
'Origem': 'Source',
|
||||
'Origem desconectada: sem miniatura': 'Source disconnected: no thumbnail',
|
||||
'Não foi possível gerar a miniatura (arquivo possivelmente danificado)': 'Could not generate the thumbnail (file possibly damaged)',
|
||||
'Tem localização GPS': 'Has GPS location',
|
||||
'Confiança {n}': 'Confidence {n}',
|
||||
'Nada por aqui': 'Nothing here',
|
||||
// preview
|
||||
'Imagem': 'Image',
|
||||
'Vídeo': 'Video',
|
||||
'PDF': 'PDF',
|
||||
'Texto': 'Text',
|
||||
'Documento (texto extraído)': 'Document (extracted text)',
|
||||
'Conteúdo do arquivo compactado': 'Archive contents',
|
||||
'Dados brutos (hexadecimal)': 'Raw data (hexadecimal)',
|
||||
'Banco de dados SQLite': 'SQLite database',
|
||||
'Banco SQLite': 'SQLite database',
|
||||
'Arquivo vazio.': 'Empty file.',
|
||||
'Não foi possível pré-visualizar.': 'Could not preview.',
|
||||
'O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.': 'The file may be damaged, incomplete or partially overwritten, which also means the chance of recovery is lower.',
|
||||
'A imagem não pôde ser decodificada.': 'The image could not be decoded.',
|
||||
'Este arquivo não parece ser texto.': 'This file does not look like text.',
|
||||
'Mostrando o início ({n} de {n}).': 'Showing the beginning ({n} of {n}).',
|
||||
'Mostrando os primeiros {n} de {n}.': 'Showing the first {n} of {n}.',
|
||||
'{n} × {n} px. Clique para alternar entre ajustar e tamanho real.': '{n} × {n} px. Click to toggle between fit and actual size.',
|
||||
'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.': 'Text extracted from the document. The original formatting, images and charts are not shown.',
|
||||
'{n} item(ns) no arquivo compactado.': '{n} item(s) in the archive.',
|
||||
'Sem texto.': 'No text.',
|
||||
'Página {n} de {n}': 'Page {n} of {n}',
|
||||
'Página anterior': 'Previous page',
|
||||
'Próxima página': 'Next page',
|
||||
'Reproduzir em tela grande': 'Play full size',
|
||||
'Abrir no player do sistema': 'Open in the system player',
|
||||
'Preparando para abrir no player do sistema…': 'Preparing to open in the system player…',
|
||||
'Aberto no player do sistema': 'Opened in the system player',
|
||||
'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',
|
||||
// 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',
|
||||
'Tabelas': 'Tables',
|
||||
'Views': 'Views',
|
||||
'Índices': 'Indexes',
|
||||
'Filtrar esta página': 'Filter this page',
|
||||
'Filtrar': 'Filter',
|
||||
'Registros apagados (prováveis)': 'Deleted records (likely)',
|
||||
'Ver esquema': 'View schema',
|
||||
'Ver dados': 'View data',
|
||||
'apagado': 'deleted',
|
||||
'Nenhuma linha.': 'No rows.',
|
||||
'Nenhuma tabela.': 'No tables.',
|
||||
'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.': 'No deleted records recognized in this table. That does not guarantee there are none: the content may have been overwritten.',
|
||||
'{n} registro(s) provável(is) apagado(s)': '{n} likely deleted record(s)',
|
||||
'{n} a {n} de {n}': '{n} to {n} of {n}',
|
||||
'‹ Anterior': '‹ Previous',
|
||||
'Próximas ›': 'Next ›',
|
||||
// recover
|
||||
'Recuperar para…': 'Recover to…',
|
||||
'Recuperar': 'Recover',
|
||||
'Pasta de destino': 'Destination folder',
|
||||
'Escolher…': 'Choose…',
|
||||
'Organizar os arquivos': 'Organize the files',
|
||||
'Pastas originais (e por tipo quando não há pasta)': 'Original folders (and by type when there is no folder)',
|
||||
'Por tipo (Fotos, Documentos, Vídeos...)': 'By type (Photos, Documents, Videos...)',
|
||||
'Por data (ano e mês)': 'By date (year and month)',
|
||||
'Tudo na mesma pasta': 'Everything in the same folder',
|
||||
'Recuperando arquivos': 'Recovering files',
|
||||
'Pronto': 'Done',
|
||||
'Interrompido': 'Stopped',
|
||||
'Salvar sessão': 'Save session',
|
||||
'Guarda os resultados para recuperar depois, sem varrer de novo.': 'Saves the results so you can recover later without scanning again.',
|
||||
'Sessão salva': 'Session saved',
|
||||
'Salvar': 'Save',
|
||||
'Exportar para CSV': 'Export to CSV',
|
||||
'Exportar': 'Export',
|
||||
'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.': 'Writes the files in the current view (with filters applied). The file opens directly in Excel.',
|
||||
'{n} linhas gravadas': '{n} rows written',
|
||||
'Relatório de recuperação': 'Recovery report',
|
||||
'Gerar relatório': 'Generate report',
|
||||
'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.': 'Generates a print-ready report with the examined source, the findings and the hashes of recovered files. End the name in .pdf for a PDF; with .html you get a web page.',
|
||||
'Salvar em': 'Save to',
|
||||
'Número do caso': 'Case number',
|
||||
'Responsável pelo exame': 'Examiner',
|
||||
'Organização': 'Organization',
|
||||
'Observações': 'Notes',
|
||||
'Relatório gravado': 'Report saved',
|
||||
'lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.': 'lists the MD5, SHA-1 and SHA-256 of each file so you can verify integrity.',
|
||||
'O arquivo': 'The file',
|
||||
'Não foi possível recuperar': 'Could not recover',
|
||||
'Não foi possível iniciar': 'Could not start',
|
||||
'Entendi': 'Got it',
|
||||
'{n} arquivo(s) salvos em {n} ({n}).': '{n} file(s) saved to {n} ({n}).',
|
||||
'{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).',
|
||||
// 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',
|
||||
'Eles serão incluídos nos relatórios gerados nesta sessão.': 'They will be included in reports generated in this session.',
|
||||
'Digite pelo menos um termo.': 'Type at least one term.',
|
||||
'Posição': 'Position',
|
||||
'Partição': 'Partition',
|
||||
'Codificação': 'Encoding',
|
||||
'Contexto': 'Context',
|
||||
'fora': 'outside',
|
||||
'Nada encontrado': 'Nothing found',
|
||||
'Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).': 'These terms do not appear anywhere on the disk (neither in UTF-8 nor UTF-16).',
|
||||
'{n} ocorrência': '{n} occurrence',
|
||||
'{n} ocorrências': '{n} occurrences',
|
||||
'{n} ocorrência{n}': '{n} occurrence{n}',
|
||||
'Termos: {n}': 'Terms: {n}',
|
||||
'{n}% · {n} até agora': '{n}% · {n} so far',
|
||||
// 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á.',
|
||||
'Abrir': 'Open',
|
||||
'Abrir sessão salva': 'Open saved session',
|
||||
'Não foi possível abrir a sessão': 'Could not open the session',
|
||||
'Disco encontrado como {n}': 'Disk found as {n}',
|
||||
'Disco reconectado': 'Disk reconnected',
|
||||
'Origem reconectada': 'Source reconnected',
|
||||
'Não foi possível usar essa origem': 'Could not use this source',
|
||||
'Esta sessão foi feita sobre': 'This session was made on',
|
||||
'Escolha o disco que voltou ou uma imagem dele.': 'Choose the disk that came back or an image of it.',
|
||||
'Nenhum disco encontrado. Informe o caminho abaixo.': 'No disks found. Enter the path below.',
|
||||
'Ou o caminho de um dispositivo ou imagem': 'Or the path to a device or image',
|
||||
'Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)': 'Use it anyway, even if the initial content differs (for example, an edited copy)',
|
||||
'Usar esta origem': 'Use this source',
|
||||
'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)',
|
||||
// settings and shutdown
|
||||
'Idioma': 'Language',
|
||||
'Notificações': 'Notifications',
|
||||
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
|
||||
'Mapas online': 'Online maps',
|
||||
'Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)': 'Show maps for photos with GPS (uses the internet: downloads OpenStreetMap tiles)',
|
||||
'Salvo': 'Saved',
|
||||
'Mostrar mapa': 'Show map',
|
||||
'Mostrar mapa (usa a internet)': 'Show map (uses the internet)',
|
||||
'Abrir no OpenStreetMap': 'Open in OpenStreetMap',
|
||||
'Há uma varredura em andamento': 'A scan is in progress',
|
||||
'Pausar e sair': 'Pause and quit',
|
||||
'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.',
|
||||
// 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.',
|
||||
// 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',
|
||||
'Arquivo de chave (.BEK ou keyfile)': 'Key file (.BEK or keyfile)',
|
||||
'Outros discos do mesmo conjunto (RAID/LVM), um caminho por linha': 'Other disks of the same set (RAID/LVM), one path per line',
|
||||
'Estes discos formam um RAID sem metadados': 'These disks form a RAID without metadata',
|
||||
'O Guará descobre sozinho o nível, a faixa, o layout e a ordem dos discos.': 'Guará works out the level, stripe size, layout and order of the disks by itself.',
|
||||
'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.',
|
||||
// disk image creation
|
||||
'Arquivo de destino': 'Destination file',
|
||||
'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)',
|
||||
'Formato da imagem': 'Image format',
|
||||
'Bruta (.img)': 'Raw (.img)',
|
||||
'Expert Witness (.E01, com hash e metadados)': 'Expert Witness (.E01, with hash and metadata)',
|
||||
'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)',
|
||||
'Imagem salva em': 'Image saved to',
|
||||
'Hashes do conteúdo inteiro de': 'Hashes of the entire content of',
|
||||
// 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}',
|
||||
// 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',
|
||||
// preview
|
||||
'Pré-visualização': 'Preview',
|
||||
'Pré-visualização de {n}': 'Preview of {n}',
|
||||
'vídeo': 'video',
|
||||
'áudio': 'audio',
|
||||
'Não foi possível reproduzir este {n}.': 'This {n} could not be played.',
|
||||
'compressão não suportada': 'compression not supported',
|
||||
'diretório do ZIP inconsistente': 'inconsistent ZIP directory',
|
||||
'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',
|
||||
// explorer: strings split across separate DOM nodes by the UI
|
||||
'selecionado': 'selected',
|
||||
'selecionados': 'selected',
|
||||
'selecionado (todos desta vista)': 'selected (all in this view)',
|
||||
'selecionados (todos desta vista)': 'selected (all in this view)',
|
||||
'Sessão: {n}': 'Session: {n}',
|
||||
'🖼 Fotos': '🖼 Photos',
|
||||
'📄 Documentos': '📄 Documents',
|
||||
'🎞 Vídeos': '🎞 Videos',
|
||||
'🎵 Áudio': '🎵 Audio',
|
||||
'🗜 Compactados': '🗜 Archives',
|
||||
'🗄 Bancos de dados': '🗄 Databases',
|
||||
'📦 Outros': '📦 Other',
|
||||
'Imagem · {n}': 'Image · {n}',
|
||||
'Vídeo · {n}': 'Video · {n}',
|
||||
'Áudio · {n}': 'Audio · {n}',
|
||||
'Texto · {n}': 'Text · {n}',
|
||||
'Documento (texto extraído) · {n}': 'Document (extracted text) · {n}',
|
||||
'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}',
|
||||
// metadata types and fields from the core
|
||||
'Imagem JPEG': 'JPEG image',
|
||||
'Imagem PNG': 'PNG image',
|
||||
'Imagem GIF': 'GIF image',
|
||||
'Imagem BMP': 'BMP image',
|
||||
'Mídia MP4/MOV': 'MP4/MOV media',
|
||||
'Fabricante da câmera': 'Camera make',
|
||||
'Modelo da câmera': 'Camera model',
|
||||
'Data (EXIF)': 'Date (EXIF)',
|
||||
'Foto tirada em': 'Photo taken on',
|
||||
'Nº de série do corpo': 'Body serial number',
|
||||
'Proprietário da câmera': 'Camera owner',
|
||||
'Exposição': 'Exposure',
|
||||
'Distância focal': 'Focal length',
|
||||
'Data do GPS': 'GPS date',
|
||||
'Localização (GPS)': 'Location (GPS)',
|
||||
'Versão do PDF': 'PDF version',
|
||||
'Título': 'Title',
|
||||
'Criado com': 'Created with',
|
||||
'Páginas (aprox.)': 'Pages (approx.)',
|
||||
'Última modificação por': 'Last modified by',
|
||||
'Páginas': 'Pages',
|
||||
'Duração': 'Duration',
|
||||
// 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',
|
||||
'{n} (máx. {n})': '{n} (max. {n})',
|
||||
'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)',
|
||||
// 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.',
|
||||
|
||||
// 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.',
|
||||
'arquivo não encontrado': 'file not found',
|
||||
'nenhum arquivo selecionado': 'no file selected',
|
||||
'informe pelo menos um termo': 'enter at least one term',
|
||||
'Espaço insuficiente no destino: os arquivos somam {n} e há {n} livres. Escolha outro disco ou recupere menos arquivos.': 'Not enough space at the destination: the files total {n} and {n} are free. Choose another disk or recover fewer files.',
|
||||
'Sem permissão para ler a sessão {n}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta.': 'Permission denied reading the session {n}. If it was created as administrator (for example in /root), open Guará as administrator or copy the file to your folder.',
|
||||
'Não foi possível ler a sessão {n}: {n}': 'Could not read the session {n}: {n}',
|
||||
'O arquivo {n} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {n}': 'The file {n} is not a valid Guará session (or was created by an incompatible version): {n}',
|
||||
'disco não encontrado': 'disk not found',
|
||||
'Essa origem tem {n} bytes, mas a sessão foi feita sobre um disco de {n} bytes. Escolha o mesmo disco ou uma imagem dele.': 'This source has {n} bytes, but the session was made on a {n}-byte disk. Choose the same disk or an image of it.',
|
||||
'O conteúdo inicial dessa origem é diferente do disco da sessão (outra tabela de partições ou outro disco). Se tem certeza, use a opção de usar assim mesmo.': 'The initial content of this source differs from the session disk (another partition table or another disk). If you are sure, use the \'use anyway\' option.',
|
||||
'só sessões automáticas podem ser apagadas por aqui': 'only automatic sessions can be deleted here',
|
||||
'parâmetro \'{n}\' ausente': 'missing parameter \'{n}\'',
|
||||
'endereço não permitido': 'address not allowed',
|
||||
'id ausente': 'missing id',
|
||||
'método desconhecido: {n}': 'unknown method: {n}',
|
||||
'A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.': 'The source of this session is unavailable. Reconnect the disk or choose the source to preview and recover.',
|
||||
'Sem permissão para ler {n}. Discos exigem privilégios de administrador: no Linux/macOS rode com sudo; no Windows, execute como administrador.': 'Permission denied reading {n}. Disks need administrator privileges: on Linux/macOS run with sudo; on Windows, run as administrator.',
|
||||
'Não foi possível abrir {n}: {n}': 'Could not open {n}: {n}',
|
||||
'O destino {n} fica em {n} (montado a partir da origem {n}). Escolha outro disco para não sobrescrever dados recuperáveis.': 'The destination {n} is on {n} (mounted from the source {n}). Choose another disk so you do not overwrite recoverable data.',
|
||||
'A origem {n} contém o sistema (/). Recupere para um disco externo ou use uma imagem.': 'The source {n} contains the system (/). Recover to an external disk or use an image.',
|
||||
'leitura além do fim da origem': 'read beyond the end of the source',
|
||||
'senha ou chave incorreta': 'wrong password or key',
|
||||
'não suportado: {n}': 'not supported: {n}',
|
||||
'cabeçalho inválido: {n}': 'invalid header: {n}',
|
||||
'Não é um banco SQLite (cabeçalho ausente). Se o arquivo é um banco, ele pode estar criptografado (SQLCipher) ou ter o início sobrescrito.': 'Not an SQLite database (missing header). If the file is a database, it may be encrypted (SQLCipher) or have its beginning overwritten.',
|
||||
'Tamanho de página inválido no cabeçalho do banco.': 'Invalid page size in the database header.',
|
||||
'página {n} fora do arquivo (banco truncado)': 'page {n} outside the file (truncated database)',
|
||||
'página {n} incompleta': 'page {n} incomplete',
|
||||
'página 0 inválida': 'invalid page 0',
|
||||
'tabela {n} não encontrada': 'table {n} not found',
|
||||
'célula ilegível na página {n}': 'unreadable cell on page {n}',
|
||||
'página {n} de tipo inesperado (0x{n})': 'page {n} of unexpected type (0x{n})',
|
||||
'Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos.': 'File too large to open directly. Recover it and open it with your file manager.',
|
||||
'não foi possível criar {n}: {n} (o arquivo não pode existir)': 'could not create {n}: {n} (the file must not exist)',
|
||||
'não foi possível ler o arquivo de chave {n}: {n}': 'could not read the key file {n}: {n}',
|
||||
'chave do volume em hexadecimal inválida': 'invalid hexadecimal volume key',
|
||||
'não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)': 'could not discover the RAID parameters (no combination produced a coherent file system)',
|
||||
'não é uma imagem EWF (E01/Ex01)': 'not an EWF (E01/Ex01) image',
|
||||
'imagem EWF sem tabela de chunks': 'EWF image without a chunk table',
|
||||
'tabela EWF inconsistente': 'inconsistent EWF table',
|
||||
'cabeçalho de volume EWF ausente': 'missing EWF volume header',
|
||||
'segmento Ex01 ausente: sequência de chunks com lacuna': 'missing Ex01 segment: chunk sequence has a gap',
|
||||
'tamanho de chunk EWF inválido': 'invalid EWF chunk size',
|
||||
'imagem grande demais para o formato E01': 'image too large for the E01 format',
|
||||
'não foi possível criar {n}: {n}': 'could not create {n}: {n}',
|
||||
'AFF4 inválido (ZIP): {n}': 'invalid AFF4 (ZIP): {n}',
|
||||
'AFF4: entrada sem início de dados: {n}': 'AFF4: entry without a data start: {n}',
|
||||
'AFF4 sem information.turtle': 'AFF4 without information.turtle',
|
||||
'AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)': 'AFF4 without an image object (aff4:Image with aff4:dataStream)',
|
||||
'AFF4: fluxo de dados da imagem não encontrado': 'AFF4: image data stream not found',
|
||||
'AFF4: fluxo simbólico inválido': 'AFF4: invalid symbolic stream',
|
||||
'AFF4: fluxo {n} não descrito': 'AFF4: stream {n} not described',
|
||||
'AFF4: entrada {n} ausente': 'AFF4: entry {n} missing',
|
||||
'AFF4/ZIP: {n}': 'AFF4/ZIP: {n}',
|
||||
'cifra {n} (apenas AES)': 'cipher {n} (only AES)',
|
||||
'chave inválida para {n}': 'invalid key for {n}',
|
||||
'IV {n}': 'IV {n}',
|
||||
'{n} arquivos · {n} · {n}': '{n} files · {n} · {n}',
|
||||
'Discos brutos exigem administrador. Rode': 'Raw disks require administrator. Run',
|
||||
'(Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.': '(Linux/macOS) or open Guará as administrator (Windows). You can still open an image file from the menu.',
|
||||
'(Linux/macOS) ou como administrador (Windows).': '(Linux/macOS) or as administrator (Windows).',
|
||||
'cifrada ({n}) · só detecção': 'encrypted ({n}) · detection only',
|
||||
'Onde salvar o arquivo de imagem? Use': 'Where to save the image file? Use',
|
||||
'outro disco': 'another disk',
|
||||
'com espaço suficiente e um nome que ainda não exista.': 'with enough space and a name that does not exist yet.',
|
||||
'Criar imagem': 'Create image',
|
||||
'{n} trecho(s) ilegível(is) foram preenchidos com zeros. Veja o mapa de erros ao lado da imagem.': '{n} unreadable chunk(s) were filled with zeros. See the error map next to the image.',
|
||||
'{n}. Escolha o disco que voltou ou uma imagem dele.': '{n}. Choose the disk that came back or an image of it.',
|
||||
'Escolha uma pasta em': 'Choose a folder on',
|
||||
'(todos desta vista)': '(all in this view)',
|
||||
'Recuperar {n}': 'Recover {n}',
|
||||
'Dimensões': 'Dimensions',
|
||||
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
|
||||
'e mais {n}…': 'and {n} more…',
|
||||
'abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'opens in your player: the embedded Linux browser does not include the codecs and would freeze the window.',
|
||||
'Nova versão do Guará disponível': 'New Guará version available',
|
||||
'Atualizar agora': 'Update now',
|
||||
'Novidades': 'What\'s new',
|
||||
'Depois': 'Later',
|
||||
'Atualizando o Guará…': 'Updating Guará…',
|
||||
'Baixando a atualização…': 'Downloading the update…',
|
||||
'Baixando… {n}%': 'Downloading… {n}%',
|
||||
'Instalando… o Guará vai reabrir sozinho.': 'Installing… Guará will reopen by itself.',
|
||||
'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:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:',
|
||||
'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}',
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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';
|
||||
|
||||
let lang = 'pt';
|
||||
export const isEn = () => lang === 'en';
|
||||
export const currentLang = () => lang;
|
||||
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
|
||||
|
||||
export function resolveLang(pref) {
|
||||
if (pref === 'pt' || pref === 'en') return pref;
|
||||
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
|
||||
}
|
||||
|
||||
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
|
||||
// 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}')));
|
||||
const patterns = Object.entries(EN)
|
||||
.filter(([k]) => k.includes('{n}'))
|
||||
.map(([k, v]) => {
|
||||
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
|
||||
})
|
||||
// more specific patterns (more fixed text) first
|
||||
.sort((a, b) => b.re.source.length - a.re.source.length);
|
||||
|
||||
const cache = new Map();
|
||||
export function tr(text) {
|
||||
if (!isEn()) return text;
|
||||
const lead = text.match(/^\s*/)[0];
|
||||
const trail = text.match(/\s*$/)[0];
|
||||
const core = text.trim();
|
||||
if (!core) return text;
|
||||
if (cache.has(core)) return lead + cache.get(core) + trail;
|
||||
let out = exact.get(core);
|
||||
if (out === undefined) {
|
||||
for (const p of patterns) {
|
||||
const m = core.match(p.re);
|
||||
if (m) {
|
||||
let i = 0;
|
||||
// captured values may also contain translatable parts (e.g. type names)
|
||||
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out === undefined) out = core;
|
||||
if (cache.size > 5000) cache.clear();
|
||||
cache.set(core, out);
|
||||
return lead + out + trail;
|
||||
}
|
||||
|
||||
const orig = new WeakMap(); // node -> original source text
|
||||
let observer = null;
|
||||
|
||||
function translateNode(n) {
|
||||
if (n.nodeType === 3) {
|
||||
if (n.parentElement?.closest(SKIP)) return;
|
||||
const o = orig.get(n) ?? n.nodeValue;
|
||||
const t = tr(o);
|
||||
if (t !== n.nodeValue) {
|
||||
if (!orig.has(n)) orig.set(n, o);
|
||||
n.nodeValue = t;
|
||||
}
|
||||
} else if (n.nodeType === 1) {
|
||||
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
|
||||
for (const a of ATTRS) {
|
||||
if (n.hasAttribute?.(a)) {
|
||||
const key = `i18n-${a}`;
|
||||
const o = n[key] ?? n.getAttribute(a);
|
||||
const t = tr(o);
|
||||
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
|
||||
}
|
||||
}
|
||||
for (const c of n.childNodes) translateNode(c);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAll(root) {
|
||||
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let n;
|
||||
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
|
||||
root.querySelectorAll('*').forEach((el) => {
|
||||
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
translateNode(document.body);
|
||||
document.title = tr(document.title);
|
||||
if (observer) return;
|
||||
observer = new MutationObserver((muts) => {
|
||||
observer.disconnect();
|
||||
for (const m of muts) {
|
||||
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
|
||||
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
|
||||
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
|
||||
}
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
restoreAll(document.body);
|
||||
}
|
||||
|
||||
export function setLang(pref) {
|
||||
const next = resolveLang(pref);
|
||||
const changed = next !== lang;
|
||||
lang = next;
|
||||
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
|
||||
if (lang === 'en') start();
|
||||
else if (changed || observer) stop();
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Guará</title>
|
||||
<link rel="icon" href="guara.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="logo.png" type="image/png">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,25 +14,28 @@
|
||||
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
|
||||
</button>
|
||||
<img src="guara.svg" alt="" width="24" height="24"><strong>Guará</strong>
|
||||
<img class="logo-img" src="logo.png" width="24" height="24" alt=""><strong>Guará</strong>
|
||||
</header>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
|
||||
<div class="brand"><img src="guara.svg" alt="" width="30" height="30"><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
|
||||
<div class="brand"><img class="logo-img" src="logo.png" width="30" height="30" alt=""><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
|
||||
<nav>
|
||||
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
||||
<ul id="deviceList" class="list"></ul>
|
||||
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
||||
<div class="side-title">Sessões</div>
|
||||
<ul id="sessionList" class="list"></ul>
|
||||
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
|
||||
<ul id="recentList" class="list"></ul>
|
||||
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||
</nav>
|
||||
<footer class="side-foot"><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
<footer class="side-foot"><button class="icon-btn small" id="settingsBtn" title="Configurações" aria-label="Configurações"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
</aside>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<main class="main" id="main" tabindex="-1"></main>
|
||||
</div>
|
||||
|
||||
<dialog id="dlg"></dialog>
|
||||
<dialog id="lightbox" class="lightbox" aria-label="Pré-visualização"></dialog>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="app.js" type="module"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { LOC } from './i18n.js';
|
||||
// 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']);
|
||||
const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
|
||||
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
|
||||
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
|
||||
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
|
||||
const DBS = new Set(['db', 'sqlite', 'sqlite3', 'db3', 'sdb', 'sqlitedb', 'storedata']);
|
||||
|
||||
export function kindOf(item) {
|
||||
const e = (item.ext || '').toLowerCase();
|
||||
if (IMG.has(e)) return 'image';
|
||||
if (VID.has(e)) return 'video';
|
||||
if (AUD.has(e)) return 'audio';
|
||||
if (e === 'pdf') return 'pdf';
|
||||
if (DBS.has(e)) return 'database';
|
||||
if (OFFICE.has(e)) return 'office';
|
||||
if (ZIPS.has(e)) return 'zip';
|
||||
if (TXT.has(e)) return 'text';
|
||||
return 'hex';
|
||||
}
|
||||
|
||||
export const KIND_LABEL = {
|
||||
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
|
||||
zip: 'Conteúdo do arquivo compactado', database: 'Banco de dados SQLite', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
|
||||
};
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const human = (n) => {
|
||||
const u = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0, v = n;
|
||||
while (v >= 1024 && i < 3) { v /= 1024; i++; }
|
||||
return i ? `${v.toFixed(1)} ${u[i]}` : `${n} B`;
|
||||
};
|
||||
|
||||
async function fetchRange(url, a, b) {
|
||||
const r = await fetch(url, { headers: { Range: `bytes=${a}-${b}` } });
|
||||
if (!r.ok) throw new Error(`leitura falhou (${r.status})`);
|
||||
return new Uint8Array(await r.arrayBuffer());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ minimal ZIP reader
|
||||
async function zipDir(url, size) {
|
||||
const tailLen = Math.min(size, 66000);
|
||||
const tail = await fetchRange(url, size - tailLen, size - 1);
|
||||
const dv = new DataView(tail.buffer);
|
||||
let e = -1;
|
||||
for (let i = tail.length - 22; i >= 0; i--) if (dv.getUint32(i, true) === 0x06054b50) { e = i; break; }
|
||||
if (e < 0) throw new Error('diretório do ZIP não encontrado (arquivo possivelmente incompleto)');
|
||||
const n = dv.getUint16(e + 10, true);
|
||||
const cdSize = dv.getUint32(e + 12, true);
|
||||
const cdOff = dv.getUint32(e + 16, true);
|
||||
if (cdOff + cdSize > size) throw new Error('diretório do ZIP inconsistente');
|
||||
const cd = await fetchRange(url, cdOff, cdOff + cdSize - 1);
|
||||
const cv = new DataView(cd.buffer);
|
||||
const out = [];
|
||||
let p = 0;
|
||||
for (let i = 0; i < n && p + 46 <= cd.length; i++) {
|
||||
if (cv.getUint32(p, true) !== 0x02014b50) break;
|
||||
const nl = cv.getUint16(p + 28, true), xl = cv.getUint16(p + 30, true), cl = cv.getUint16(p + 32, true);
|
||||
out.push({
|
||||
method: cv.getUint16(p + 10, true), csize: cv.getUint32(p + 20, true), usize: cv.getUint32(p + 24, true),
|
||||
off: cv.getUint32(p + 42, true), name: new TextDecoder().decode(cd.subarray(p + 46, p + 46 + nl)),
|
||||
});
|
||||
p += 46 + nl + xl + cl;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function zipRead(url, e, cap = 8 << 20) {
|
||||
if (e.usize > cap) throw new Error('parte grande demais');
|
||||
const lh = await fetchRange(url, e.off, e.off + 29);
|
||||
const dv = new DataView(lh.buffer);
|
||||
const start = e.off + 30 + dv.getUint16(26, true) + dv.getUint16(28, true);
|
||||
const data = e.csize ? await fetchRange(url, start, start + e.csize - 1) : new Uint8Array(0);
|
||||
if (e.method === 0) return data;
|
||||
if (e.method !== 8) throw new Error('compressão não suportada');
|
||||
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
|
||||
|
||||
// ------------------------------------------------------------------ renderers
|
||||
function note(host, html, cls = '') {
|
||||
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
|
||||
}
|
||||
|
||||
function failure(host, item, err, withHex) {
|
||||
host.innerHTML = '';
|
||||
note(host, `<b>Não foi possível pré-visualizar.</b><br>${esc(err?.message || err || '')} O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.`, 'bad');
|
||||
return withHex ? hex(host, item, host.dataset.url, false) : null;
|
||||
}
|
||||
|
||||
async function hex(host, item, url, large) {
|
||||
const n = Math.min(item.size, large ? 4096 : 1024);
|
||||
if (!n) { note(host, 'Arquivo vazio.'); return; }
|
||||
const b = await fetchRange(url, 0, n - 1);
|
||||
const lines = [];
|
||||
for (let i = 0; i < b.length; i += 16) {
|
||||
const row = [...b.subarray(i, i + 16)];
|
||||
const h = row.map((x) => x.toString(16).padStart(2, '0')).join(' ').padEnd(47);
|
||||
const a = row.map((x) => (x >= 32 && x < 127 ? String.fromCharCode(x) : '·')).join('');
|
||||
lines.push(`<span class="o">${i.toString(16).padStart(6, '0')}</span> ${h} <span class="a">${esc(a)}</span>`);
|
||||
}
|
||||
host.insertAdjacentHTML('beforeend', `<pre class="pv-hex">${lines.join('\n')}</pre>`);
|
||||
if (item.size > n) note(host, `Mostrando os primeiros ${human(n)} de ${human(item.size)}.`);
|
||||
}
|
||||
|
||||
async function text(host, item, url, large) {
|
||||
const cap = large ? 262144 : 24576;
|
||||
const b = await fetchRange(url, 0, Math.min(item.size, cap) - 1);
|
||||
const sample = b.subarray(0, 2048);
|
||||
const bin = sample.filter((x) => x === 0 || (x < 9) ).length;
|
||||
if (bin > sample.length * 0.02) { note(host, 'Este arquivo não parece ser texto.'); return hex(host, item, url, large); }
|
||||
let s = new TextDecoder('utf-8', { fatal: false }).decode(b);
|
||||
if ((s.match(/�/g) || []).length > s.length * 0.02) s = new TextDecoder('windows-1252').decode(b);
|
||||
host.insertAdjacentHTML('beforeend', `<pre class="pv-text">${esc(s)}</pre>`);
|
||||
if (item.size > b.length) note(host, `Mostrando o início (${human(b.length)} de ${human(item.size)}).`);
|
||||
}
|
||||
|
||||
async function image(host, item, url, state, thumb) {
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.alt = `Pré-visualização de ${item.name}`;
|
||||
img.className = 'pv-img';
|
||||
img.decoding = 'async';
|
||||
img.onload = () => {
|
||||
if (state.dead) return resolve();
|
||||
host.innerHTML = '';
|
||||
host.appendChild(img);
|
||||
if (!thumb) {
|
||||
host.classList.add('zoomable');
|
||||
img.onclick = () => host.classList.toggle('actual');
|
||||
}
|
||||
if (!thumb) note(host, `${img.naturalWidth} × ${img.naturalHeight} px. Clique para alternar entre ajustar e tamanho real.`);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => { if (!state.dead) failure(host, item, 'A imagem não pôde ser decodificada.', true); resolve(); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function media(host, item, url, tag, state) {
|
||||
const el = document.createElement(tag);
|
||||
el.controls = true;
|
||||
el.preload = 'metadata';
|
||||
el.className = `pv-${tag}`;
|
||||
if (tag === 'video') el.playsInline = true;
|
||||
el.onerror = () => { if (!state.dead) failure(host, item, `Não foi possível reproduzir este ${tag === 'video' ? 'vídeo' : 'áudio'}.`, true); };
|
||||
el.src = url;
|
||||
host.innerHTML = '';
|
||||
host.appendChild(el);
|
||||
state.cleanup.push(() => { el.pause?.(); el.removeAttribute('src'); el.load?.(); });
|
||||
}
|
||||
|
||||
let pdfjsPromise;
|
||||
function loadPdfjs() {
|
||||
pdfjsPromise ??= import('./vendor/pdf.min.mjs').then((m) => {
|
||||
m.GlobalWorkerOptions.workerSrc = new URL('./vendor/pdf.worker.min.mjs', import.meta.url).href;
|
||||
return m;
|
||||
});
|
||||
return pdfjsPromise;
|
||||
}
|
||||
|
||||
async function pdf(host, item, url, state, large) {
|
||||
const pdfjs = await loadPdfjs();
|
||||
const task = pdfjs.getDocument({ url, disableAutoFetch: true, isEvalSupported: false });
|
||||
state.cleanup.push(() => task.destroy());
|
||||
const doc = await task.promise;
|
||||
if (state.dead) return;
|
||||
host.innerHTML = '<div class="pv-pdfbar"><button class="btn sm" data-d="-1" aria-label="Página anterior">‹</button><span class="pg"></span><button class="btn sm" data-d="1" aria-label="Próxima página">›</button></div><div class="pv-pdfwrap"><canvas class="pv-canvas"></canvas></div>';
|
||||
const canvas = host.querySelector('canvas');
|
||||
const label = host.querySelector('.pg');
|
||||
let n = 1, busy = false;
|
||||
async function draw() {
|
||||
if (busy || state.dead) return;
|
||||
busy = true;
|
||||
try {
|
||||
const page = await doc.getPage(n);
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const width = Math.max(240, (host.clientWidth || 320) - 16);
|
||||
const scale = (width / base.width) * (large ? 1 : 1);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const vp = page.getViewport({ scale: scale * dpr });
|
||||
canvas.width = vp.width; canvas.height = vp.height;
|
||||
canvas.style.width = `${vp.width / dpr}px`;
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||||
label.textContent = `Página ${n} de ${doc.numPages}`;
|
||||
} catch (e) {
|
||||
if (!state.dead) { host.innerHTML = ''; failure(host, item, e, true); }
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
host.querySelectorAll('[data-d]').forEach((b) => (b.onclick = () => { n = Math.min(doc.numPages, Math.max(1, n + +b.dataset.d)); draw(); }));
|
||||
await draw();
|
||||
}
|
||||
|
||||
const colIndex = (ref) => [...ref.replace(/\d+/g, '')].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
||||
const colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(65 + ((i - 1) % 26)) + s; i = Math.floor((i - 1) / 26); } return s; };
|
||||
|
||||
async function office(host, item, url, state) {
|
||||
const ents = await zipDir(url, item.size);
|
||||
const find = (n) => ents.find((e) => e.name === n);
|
||||
const ext = item.ext.toLowerCase();
|
||||
let html = '';
|
||||
if (ext === 'docx') {
|
||||
const d = xml(await zipRead(url, find('word/document.xml')));
|
||||
const paras = [...d.getElementsByTagName('w:p')].slice(0, 400).map((p) => {
|
||||
let t = '';
|
||||
p.querySelectorAll('t, tab, br').forEach((n) => { t += n.localName === 't' ? n.textContent : n.localName === 'tab' ? '\t' : '\n'; });
|
||||
const h = p.querySelector('pStyle')?.getAttribute('w:val') || '';
|
||||
const tag = /^(Heading|Ttulo|Title)/i.test(h) ? 'h4' : 'p';
|
||||
return t.trim() ? `<${tag}>${esc(t)}</${tag}>` : '';
|
||||
});
|
||||
html = `<div class="pv-page">${paras.join('') || '<p class="sub">Sem texto.</p>'}</div>`;
|
||||
} else if (ext === 'odt') {
|
||||
const d = xml(await zipRead(url, find('content.xml')));
|
||||
const els = [...d.getElementsByTagName('text:p'), ...[]].slice(0, 400);
|
||||
html = `<div class="pv-page">${els.map((p) => (p.textContent.trim() ? `<p>${esc(p.textContent)}</p>` : '')).join('') || '<p class="sub">Sem texto.</p>'}</div>`;
|
||||
} else if (ext === 'xlsx') {
|
||||
const ss = find('xl/sharedStrings.xml');
|
||||
const strings = ss ? [...xml(await zipRead(url, ss)).getElementsByTagName('si')].map((si) => si.textContent) : [];
|
||||
const sheet = ents.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0];
|
||||
const d = xml(await zipRead(url, sheet));
|
||||
const rows = [...d.getElementsByTagName('row')].slice(0, 80);
|
||||
let maxc = 0;
|
||||
const grid = rows.map((r) => {
|
||||
const cells = {};
|
||||
[...r.getElementsByTagName('c')].forEach((c) => {
|
||||
const i = colIndex(c.getAttribute('r') || 'A1');
|
||||
const v = c.getElementsByTagName('v')[0]?.textContent ?? c.getElementsByTagName('t')[0]?.textContent ?? '';
|
||||
cells[i] = c.getAttribute('t') === 's' ? strings[+v] ?? '' : v;
|
||||
maxc = Math.max(maxc, i + 1);
|
||||
});
|
||||
return [r.getAttribute('r'), cells];
|
||||
});
|
||||
maxc = Math.min(maxc, 16);
|
||||
html = `<div class="pv-sheet"><table><thead><tr><th></th>${Array.from({ length: maxc }, (_, i) => `<th>${colName(i)}</th>`).join('')}</tr></thead><tbody>${grid
|
||||
.map(([n, cells]) => `<tr><th>${esc(n)}</th>${Array.from({ length: maxc }, (_, i) => `<td>${esc(cells[i] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||||
} else if (ext === 'ods') {
|
||||
const d = xml(await zipRead(url, find('content.xml')));
|
||||
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: 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);
|
||||
slides = [];
|
||||
for (const f of files) {
|
||||
const d = xml(await zipRead(url, f));
|
||||
slides.push([...d.getElementsByTagName('a:p')].map((p) => p.textContent).filter((t) => t.trim()));
|
||||
}
|
||||
} else {
|
||||
const d = xml(await zipRead(url, find('content.xml')));
|
||||
slides = [...d.getElementsByTagName('draw:page')].slice(0, 60).map((pg) => [...pg.getElementsByTagName('text:p')].map((p) => p.textContent).filter((t) => t.trim()));
|
||||
}
|
||||
html = slides.map((s, i) => `<div class="pv-slide"><small>Slide ${i + 1}</small>${s.map((t, j) => (j === 0 ? `<h4>${esc(t)}</h4>` : `<p>${esc(t)}</p>`)).join('') || '<p class="sub">Sem texto.</p>'}</div>`).join('');
|
||||
}
|
||||
if (state.dead) return;
|
||||
host.innerHTML = html;
|
||||
note(host, 'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.');
|
||||
}
|
||||
|
||||
async function zip(host, item, url, state) {
|
||||
const ents = await zipDir(url, item.size);
|
||||
if (state.dead) return;
|
||||
host.innerHTML = `<ul class="pv-zip">${ents.slice(0, 400).map((e) => `<li><span>${esc(e.name)}</span><small>${e.name.endsWith('/') ? 'pasta' : human(e.usize)}</small></li>`).join('')}</ul>`;
|
||||
note(host, `${ents.length} item(ns) no arquivo compactado.`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ databases (SQLite)
|
||||
|
||||
/** 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 = '';
|
||||
if (v > 9.4e8 && v < 4.2e9) { ms = v * 1000; kind = 'Unix (s)'; }
|
||||
else if (v > 9.4e11 && v < 4.2e12) { ms = v; kind = 'Unix (ms)'; }
|
||||
else if (v > 9.4e14 && v < 4.2e15) { ms = v / 1000; kind = 'Unix (µs)'; }
|
||||
else if (v > 1.2e16 && v < 1.4e16) { ms = v / 1000 - 11644473600000; kind = 'Chrome/WebKit'; }
|
||||
else if (v > 3e8 && v < 9.4e8) { ms = (v + 978307200) * 1000; kind = 'macOS/CoreData'; }
|
||||
if (ms == null) return null;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : { kind, text: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' };
|
||||
}
|
||||
|
||||
function cellHtml(c) {
|
||||
switch (c.t) {
|
||||
case 'null': return '<span class="db-null">NULL</span>';
|
||||
case 'int': { const h = tsHint(c.v); return h ? `<span title="${esc(h.kind)}: ${esc(h.text)}">${c.v}<i class="db-ts">${esc(h.text.slice(0, 10))}</i></span>` : String(c.v); }
|
||||
case 'real': return String(c.v);
|
||||
case 'text': return `<span title="${esc(c.v.slice(0, 600))}">${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}</span>`;
|
||||
default: return `<span class="db-blob" title="${esc(c.v.head)}">BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function database(host, item, state, large, db, onOpen) {
|
||||
if (!db) throw new Error('visualizador indisponível');
|
||||
const info = await db.info();
|
||||
if (state.dead) return;
|
||||
const tables = info.tables;
|
||||
const warn = (info.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
|
||||
if (!large) {
|
||||
const total = tables.reduce((a, t) => a + (t.rows || 0), 0);
|
||||
host.innerHTML = `<div class="db-sum"><b>Banco SQLite</b><span class="sub">${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)}</span>
|
||||
<ul>${tables.slice(0, 10).map((t) => `<li><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></li>`).join('')}</ul>
|
||||
${tables.length > 10 ? `<span class="sub">e mais ${tables.length - 10}…</span>` : ''}${warn}
|
||||
<button class="btn primary sm" data-pv="open">Abrir o visualizador</button></div>`;
|
||||
host.onclick = (e) => { if (e.target.closest('[data-pv]')) onOpen?.(); };
|
||||
return;
|
||||
}
|
||||
const S = { table: tables.find((t) => t.rows > 0)?.name || tables[0]?.name, offset: 0, limit: 100, deleted: false, q: '', schema: false };
|
||||
host.classList.add('db-large');
|
||||
host.innerHTML = `<div class="db-wrap"><nav class="db-tables" aria-label="Tabelas"></nav><section class="db-main"><div class="db-bar"></div><div class="db-grid"></div><div class="db-foot"></div></section></div>`;
|
||||
const $ = (sel) => host.querySelector(sel);
|
||||
const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot');
|
||||
nav.innerHTML = tables.map((t) => `<button class="db-t" data-t="${esc(t.name)}"><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></button>`).join('')
|
||||
+ (info.indexes.length || info.views.length ? `<div class="db-meta">${info.views.length ? `<b>Views</b>${info.views.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}${info.indexes.length ? `<b>Índices</b>${info.indexes.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}</div>` : '')
|
||||
+ warn;
|
||||
|
||||
async function load() {
|
||||
if (state.dead) return;
|
||||
nav.querySelectorAll('.db-t').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === S.table)));
|
||||
const t = tables.find((x) => x.name === S.table);
|
||||
if (!t) { grid.innerHTML = '<div class="pv-note">Nenhuma tabela.</div>'; return; }
|
||||
bar.innerHTML = `<b>${esc(t.name)}</b><span class="sub">${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas</span>
|
||||
<input class="input" id="dbq" type="search" placeholder="Filtrar esta página" value="${esc(S.q)}" aria-label="Filtrar">
|
||||
<label class="check" style="padding:0"><input type="checkbox" id="dbdel" ${S.deleted ? 'checked' : ''}><span>Registros apagados (prováveis)</span></label>
|
||||
<button class="btn sm" id="dbsch">${S.schema ? 'Ver dados' : 'Ver esquema'}</button>`;
|
||||
if (S.schema) { grid.innerHTML = `<pre class="pv-text">${esc(t.sql)}</pre>`; foot.innerHTML = ''; wire(); return; }
|
||||
grid.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
let page;
|
||||
try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `<div class="pv-note bad">${esc(e.message || e)}</div>`; wire(); return; }
|
||||
if (state.dead) return;
|
||||
const q = S.q.toLowerCase();
|
||||
const rows = q ? page.rows.filter((r) => r.cells.some((c) => String(c.t === 'blob' ? '' : c.v).toLowerCase().includes(q))) : page.rows;
|
||||
const notes = (page.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
|
||||
grid.innerHTML = notes + (rows.length ? `<table class="db-table"><thead><tr><th>#</th>${page.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r, i) => `<tr class="${r.deleted ? 'del' : ''}"><th>${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}</th>${r.cells.map((c) => `<td>${cellHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`
|
||||
: `<div class="pv-note">${S.deleted ? 'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.' : 'Nenhuma linha.'}</div>`);
|
||||
const total = S.deleted ? page.total : (t.rows || page.total);
|
||||
foot.innerHTML = `<span class="sub">${S.deleted ? `${total.toLocaleString(LOC())} registro(s) provável(is) apagado(s)` : `${total ? S.offset + 1 : 0} a ${Math.min(S.offset + S.limit, total)} de ${total.toLocaleString(LOC())}`}</span>
|
||||
<span class="row"><button class="btn sm" id="dbprev" ${S.offset <= 0 ? 'disabled' : ''}>‹ Anterior</button><button class="btn sm" id="dbnext" ${S.offset + S.limit >= total ? 'disabled' : ''}>Próximas ›</button></span>`;
|
||||
wire();
|
||||
}
|
||||
function wire() {
|
||||
$('#dbq')?.addEventListener('input', (e) => { S.q = e.target.value; clearTimeout(S.t); S.t = setTimeout(load, 200); });
|
||||
$('#dbdel')?.addEventListener('change', (e) => { S.deleted = e.target.checked; S.offset = 0; load(); });
|
||||
$('#dbsch')?.addEventListener('click', () => { S.schema = !S.schema; load(); });
|
||||
$('#dbprev')?.addEventListener('click', () => { S.offset = Math.max(0, S.offset - S.limit); load(); });
|
||||
$('#dbnext')?.addEventListener('click', () => { S.offset += S.limit; load(); });
|
||||
}
|
||||
nav.addEventListener('click', (e) => { const b = e.target.closest('.db-t'); if (b) { S.table = b.dataset.t; S.offset = 0; S.q = ''; S.deleted = false; S.schema = false; load(); } });
|
||||
load();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ API
|
||||
/** 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;
|
||||
host.innerHTML = `<div class="pv-card"><div class="pv-bigicon" aria-hidden="true">${ico}</div>
|
||||
<b>${esc(item.name)}</b><span class="sub">${esc(KIND_LABEL[kind])} · ${human(item.size)}</span>
|
||||
${inlineMedia
|
||||
? '<button class="btn primary" data-pv="open">Reproduzir em tela grande</button>'
|
||||
: '<button class="btn primary" data-pv="ext">Abrir no player do sistema</button><span class="pv-why">Neste app o ' + (kind === 'video' ? 'vídeo' : 'áudio') + ' abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.</span>'}
|
||||
</div>`;
|
||||
host.onclick = (e) => {
|
||||
const a = e.target.closest('[data-pv]')?.dataset.pv;
|
||||
if (a === 'open') onOpen?.();
|
||||
else if (a === 'ext') onExternal?.();
|
||||
};
|
||||
void canInline;
|
||||
}
|
||||
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null, db = null }) {
|
||||
const state = { dead: false, cleanup: [] };
|
||||
host.dataset.url = url;
|
||||
host.classList.remove('zoomable', 'actual');
|
||||
host.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
const kind = kindOf(item);
|
||||
// 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; };
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
|
||||
if (kind === 'image') await image(host, item, url, state, thumb);
|
||||
else if (kind === 'video') media(host, item, url, 'video', state);
|
||||
else if (kind === 'audio') media(host, item, url, 'audio', state);
|
||||
else if (kind === 'pdf') await pdf(host, item, url, state, large);
|
||||
else if (kind === 'database') await database(host, item, state, large, db, onOpen);
|
||||
else if (kind === 'office') await office(host, item, url, state);
|
||||
else if (kind === 'zip') await zip(host, item, url, state);
|
||||
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
|
||||
else { host.innerHTML = ''; await hex(host, item, url, large); }
|
||||
} catch (e) {
|
||||
if (!state.dead) await failure(host, item, e, kind !== 'hex' && kind !== 'text');
|
||||
}
|
||||
})();
|
||||
return () => { state.dead = true; state.cleanup.forEach((f) => { try { f(); } catch { /* ignora */ } }); };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,3 @@
|
||||
# Third-party libraries
|
||||
|
||||
- **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
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2024 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/var t={9306:(t,e,i)=>{var s=i(4901),n=i(6823),r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not a function")}},3506:(t,e,i)=>{var s=i(3925),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r("Can't set "+n(t)+" as a prototype")}},7080:(t,e,i)=>{var s=i(4402).has;t.exports=function(t){s(t);return t}},679:(t,e,i)=>{var s=i(1625),n=TypeError;t.exports=function(t,e){if(s(e,t))return t;throw new n("Incorrect invocation")}},8551:(t,e,i)=>{var s=i(34),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not an object")}},7811:t=>{t.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(t,e,i)=>{var s=i(4576),n=i(6706),r=i(2195),a=s.ArrayBuffer,o=s.TypeError;t.exports=a&&n(a.prototype,"byteLength","get")||function(t){if("ArrayBuffer"!==r(t))throw new o("ArrayBuffer expected");return t.byteLength}},3238:(t,e,i)=>{var s=i(4576),n=i(7476),r=i(7394),a=s.ArrayBuffer,o=a&&a.prototype,l=o&&n(o.slice);t.exports=function(t){if(0!==r(t))return!1;if(!l)return!1;try{l(t,0,0);return!1}catch(t){return!0}}},5169:(t,e,i)=>{var s=i(3238),n=TypeError;t.exports=function(t){if(s(t))throw new n("ArrayBuffer is detached");return t}},5636:(t,e,i)=>{var s=i(4576),n=i(9504),r=i(6706),a=i(7696),o=i(5169),l=i(7394),h=i(4483),c=i(1548),d=s.structuredClone,u=s.ArrayBuffer,p=s.DataView,g=Math.min,f=u.prototype,m=p.prototype,b=n(f.slice),v=r(f,"resizable","get"),y=r(f,"maxByteLength","get"),w=n(m.getInt8),A=n(m.setInt8);t.exports=(c||h)&&function(t,e,i){var s,n=l(t),r=void 0===e?n:a(e),f=!v||!v(t);o(t);if(c){t=d(t,{transfer:[t]});if(n===r&&(i||f))return t}if(n>=r&&(!i||f))s=b(t,0,r);else{var m=i&&!f&&y?{maxByteLength:y(t)}:void 0;s=new u(r,m);for(var x=new p(t),_=new p(s),E=g(r,n),S=0;S<E;S++)A(_,S,w(x,S))}c||h(t);return s}},4644:(t,e,i)=>{var s,n,r,a=i(7811),o=i(3724),l=i(4576),h=i(4901),c=i(34),d=i(9297),u=i(6955),p=i(6823),g=i(6699),f=i(6840),m=i(2106),b=i(1625),v=i(2787),y=i(2967),w=i(8227),A=i(3392),x=i(1181),_=x.enforce,E=x.get,S=l.Int8Array,C=S&&S.prototype,T=l.Uint8ClampedArray,M=T&&T.prototype,P=S&&v(S),D=C&&v(C),k=Object.prototype,R=l.TypeError,I=w("toStringTag"),L=A("TYPED_ARRAY_TAG"),O="TypedArrayConstructor",N=a&&!!y&&"Opera"!==u(l.opera),B=!1,H={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},U={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(t){var e=v(t);if(c(e)){var i=E(e);return i&&d(i,O)?i[O]:getTypedArrayConstructor(e)}},isTypedArray=function(t){if(!c(t))return!1;var e=u(t);return d(H,e)||d(U,e)};for(s in H)(r=(n=l[s])&&n.prototype)?_(r)[O]=n:N=!1;for(s in U)(r=(n=l[s])&&n.prototype)&&(_(r)[O]=n);if(!N||!h(P)||P===Function.prototype){P=function TypedArray(){throw new R("Incorrect invocation")};if(N)for(s in H)l[s]&&y(l[s],P)}if(!N||!D||D===k){D=P.prototype;if(N)for(s in H)l[s]&&y(l[s].prototype,D)}N&&v(M)!==D&&y(M,D);if(o&&!d(D,I)){B=!0;m(D,I,{configurable:!0,get:function(){return c(this)?this[L]:void 0}});for(s in H)l[s]&&g(l[s],L,s)}t.exports={NATIVE_ARRAY_BUFFER_VIEWS:N,TYPED_ARRAY_TAG:B&&L,aTypedArray:function(t){if(isTypedArray(t))return t;throw new R("Target is not a typed array")},aTypedArrayConstructor:function(t){if(h(t)&&(!y||b(P,t)))return t;throw new R(p(t)+" is not a typed array constructor")},exportTypedArrayMethod:function(t,e,i,s){if(o){if(i)for(var n in H){var r=l[n];if(r&&d(r.prototype,t))try{delete r.prototype[t]}catch(i){try{r.prototype[t]=e}catch(t){}}}D[t]&&!i||f(D,t,i?e:N&&C[t]||e,s)}},exportTypedArrayStaticMethod:function(t,e,i){var s,n;if(o){if(y){if(i)for(s in H)if((n=l[s])&&d(n,t))try{delete n[t]}catch(t){}if(P[t]&&!i)return;try{return f(P,t,i?e:N&&P[t]||e)}catch(t){}}for(s in H)!(n=l[s])||n[t]&&!i||f(n,t,e)}},getTypedArrayConstructor,isView:function isView(t){if(!c(t))return!1;var e=u(t);return"DataView"===e||d(H,e)||d(U,e)},isTypedArray,TypedArray:P,TypedArrayPrototype:D}},5370:(t,e,i)=>{var s=i(6198);t.exports=function(t,e,i){for(var n=0,r=arguments.length>2?i:s(e),a=new t(r);r>n;)a[n]=e[n++];return a}},9617:(t,e,i)=>{var s=i(5397),n=i(5610),r=i(6198),createMethod=function(t){return function(e,i,a){var o=s(e),l=r(o);if(0===l)return!t&&-1;var h,c=n(a,l);if(t&&i!=i){for(;l>c;)if((h=o[c++])!=h)return!0}else for(;l>c;c++)if((t||c in o)&&o[c]===i)return t||c||0;return!t&&-1}};t.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(t,e,i)=>{var s=i(3724),n=i(4376),r=TypeError,a=Object.getOwnPropertyDescriptor,o=s&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(t){return t instanceof TypeError}}();t.exports=o?function(t,e){if(n(t)&&!a(t,"length").writable)throw new r("Cannot set read only .length");return t.length=e}:function(t,e){return t.length=e}},7680:(t,e,i)=>{var s=i(9504);t.exports=s([].slice)},7628:(t,e,i)=>{var s=i(6198);t.exports=function(t,e){for(var i=s(t),n=new e(i),r=0;r<i;r++)n[r]=t[i-r-1];return n}},9928:(t,e,i)=>{Line truncated
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2024 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/var e={9306:(e,t,i)=>{var a=i(4901),r=i(6823),s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not a function")}},3506:(e,t,i)=>{var a=i(3925),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s("Can't set "+r(e)+" as a prototype")}},7080:(e,t,i)=>{var a=i(4402).has;e.exports=function(e){a(e);return e}},679:(e,t,i)=>{var a=i(1625),r=TypeError;e.exports=function(e,t){if(a(t,e))return e;throw new r("Incorrect invocation")}},8551:(e,t,i)=>{var a=i(34),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not an object")}},7811:e=>{e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(e,t,i)=>{var a=i(4576),r=i(6706),s=i(2195),n=a.ArrayBuffer,o=a.TypeError;e.exports=n&&r(n.prototype,"byteLength","get")||function(e){if("ArrayBuffer"!==s(e))throw new o("ArrayBuffer expected");return e.byteLength}},3238:(e,t,i)=>{var a=i(4576),r=i(7476),s=i(7394),n=a.ArrayBuffer,o=n&&n.prototype,g=o&&r(o.slice);e.exports=function(e){if(0!==s(e))return!1;if(!g)return!1;try{g(e,0,0);return!1}catch(e){return!0}}},5169:(e,t,i)=>{var a=i(3238),r=TypeError;e.exports=function(e){if(a(e))throw new r("ArrayBuffer is detached");return e}},5636:(e,t,i)=>{var a=i(4576),r=i(9504),s=i(6706),n=i(7696),o=i(5169),g=i(7394),c=i(4483),l=i(1548),h=a.structuredClone,C=a.ArrayBuffer,Q=a.DataView,E=Math.min,u=C.prototype,d=Q.prototype,f=r(u.slice),p=s(u,"resizable","get"),m=s(u,"maxByteLength","get"),y=r(d.getInt8),w=r(d.setInt8);e.exports=(l||c)&&function(e,t,i){var a,r=g(e),s=void 0===t?r:n(t),u=!p||!p(e);o(e);if(l){e=h(e,{transfer:[e]});if(r===s&&(i||u))return e}if(r>=s&&(!i||u))a=f(e,0,s);else{var d=i&&!u&&m?{maxByteLength:m(e)}:void 0;a=new C(s,d);for(var b=new Q(e),D=new Q(a),S=E(s,r),k=0;k<S;k++)w(D,k,y(b,k))}l||c(e);return a}},4644:(e,t,i)=>{var a,r,s,n=i(7811),o=i(3724),g=i(4576),c=i(4901),l=i(34),h=i(9297),C=i(6955),Q=i(6823),E=i(6699),u=i(6840),d=i(2106),f=i(1625),p=i(2787),m=i(2967),y=i(8227),w=i(3392),b=i(1181),D=b.enforce,S=b.get,k=g.Int8Array,R=k&&k.prototype,N=g.Uint8ClampedArray,G=N&&N.prototype,M=k&&p(k),U=R&&p(R),x=Object.prototype,L=g.TypeError,H=y("toStringTag"),J=w("TYPED_ARRAY_TAG"),v="TypedArrayConstructor",Y=n&&!!m&&"Opera"!==C(g.opera),K=!1,T={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},q={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(e){var t=p(e);if(l(t)){var i=S(t);return i&&h(i,v)?i[v]:getTypedArrayConstructor(t)}},isTypedArray=function(e){if(!l(e))return!1;var t=C(e);return h(T,t)||h(q,t)};for(a in T)(s=(r=g[a])&&r.prototype)?D(s)[v]=r:Y=!1;for(a in q)(s=(r=g[a])&&r.prototype)&&(D(s)[v]=r);if(!Y||!c(M)||M===Function.prototype){M=function TypedArray(){throw new L("Incorrect invocation")};if(Y)for(a in T)g[a]&&m(g[a],M)}if(!Y||!U||U===x){U=M.prototype;if(Y)for(a in T)g[a]&&m(g[a].prototype,U)}Y&&p(G)!==U&&m(G,U);if(o&&!h(U,H)){K=!0;d(U,H,{configurable:!0,get:function(){return l(this)?this[J]:void 0}});for(a in T)g[a]&&E(g[a],J,a)}e.exports={NATIVE_ARRAY_BUFFER_VIEWS:Y,TYPED_ARRAY_TAG:K&&J,aTypedArray:function(e){if(isTypedArray(e))return e;throw new L("Target is not a typed array")},aTypedArrayConstructor:function(e){if(c(e)&&(!m||f(M,e)))return e;throw new L(Q(e)+" is not a typed array constructor")},exportTypedArrayMethod:function(e,t,i,a){if(o){if(i)for(var r in T){var s=g[r];if(s&&h(s.prototype,e))try{delete s.prototype[e]}catch(i){try{s.prototype[e]=t}catch(e){}}}U[e]&&!i||u(U,e,i?t:Y&&R[e]||t,a)}},exportTypedArrayStaticMethod:function(e,t,i){var a,r;if(o){if(m){if(i)for(a in T)if((r=g[a])&&h(r,e))try{delete r[e]}catch(e){}if(M[e]&&!i)return;try{return u(M,e,i?t:Y&&M[e]||t)}catch(e){}}for(a in T)!(r=g[a])||r[e]&&!i||u(r,e,t)}},getTypedArrayConstructor,isView:function isView(e){if(!l(e))return!1;var t=C(e);return"DataView"===t||h(T,t)||h(q,t)},isTypedArray,TypedArray:M,TypedArrayPrototype:U}},5370:(e,t,i)=>{var a=i(6198);e.exports=function(e,t,i){for(var r=0,s=arguments.length>2?i:a(t),n=new e(s);s>r;)n[r]=t[r++];return n}},9617:(e,t,i)=>{var a=i(5397),r=i(5610),s=i(6198),createMethod=function(e){return function(t,i,n){var o=a(t),g=s(o);if(0===g)return!e&&-1;var c,l=r(n,g);if(e&&i!=i){for(;g>l;)if((c=o[l++])!=c)return!0}else for(;g>l;l++)if((e||l in o)&&o[l]===i)return e||l||0;return!e&&-1}};e.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(e,t,i)=>{var a=i(3724),r=i(4376),s=TypeError,n=Object.getOwnPropertyDescriptor,o=a&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(e){return e instanceof TypeError}}();e.exports=o?function(e,t){if(r(e)&&!n(e,"length").writable)throw new s("Cannot set read only .length");return e.length=t}:function(e,t){return e.length=t}},7680:(e,t,i)=>{var a=i(9504);e.exports=a([].slice)},7628:(e,t,i)=>{var a=i(6198);e.exports=function(e,t){for(var i=a(e),r=new t(i),s=0;s<i;s++)r[s]=e[i-s-1];return r}},9928:(e,t,i)=>{Line truncated
|
||||
@@ -1,10 +1,11 @@
|
||||
[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
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -16,5 +17,6 @@ guara-core = { path = "../guara-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tiny_http = "0.12"
|
||||
getrandom = "0.2"
|
||||
getrandom = "0.4"
|
||||
ctrlc = "3"
|
||||
rpassword = "7"
|
||||
@@ -1,14 +1,21 @@
|
||||
//! 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;
|
||||
use std::io::Read;
|
||||
use tiny_http::{Header, Method, Response, Server};
|
||||
use std::sync::Arc;
|
||||
use tiny_http::{Header, Method, Request, Response, Server};
|
||||
|
||||
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
||||
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
|
||||
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
|
||||
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
|
||||
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
|
||||
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
|
||||
const LOGO: &[u8] = include_bytes!("../../../logo.png");
|
||||
const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs");
|
||||
const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs");
|
||||
|
||||
pub fn run(port: u16, open: bool) -> Result<(), String> {
|
||||
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
|
||||
@@ -18,79 +25,126 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
|
||||
.map(|a| a.port())
|
||||
.unwrap_or(port);
|
||||
let mut tok = [0u8; 16];
|
||||
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
|
||||
let token: String = tok.iter().map(|b| format!("{b:02x}")).collect();
|
||||
getrandom::fill(&mut tok).map_err(|e| e.to_string())?;
|
||||
let token: Arc<str> = tok
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
.into();
|
||||
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
|
||||
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
|
||||
if open {
|
||||
open_browser(&url);
|
||||
}
|
||||
let eng = Engine::new();
|
||||
let hosts = [
|
||||
let eng = Arc::new(Engine::new());
|
||||
let hosts: Arc<[String; 2]> = Arc::new([
|
||||
format!("127.0.0.1:{real_port}"),
|
||||
format!("localhost:{real_port}"),
|
||||
];
|
||||
for mut req in server.incoming_requests() {
|
||||
let host_ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
|
||||
if !host_ok {
|
||||
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||
continue;
|
||||
}
|
||||
let path = req.url().split('?').next().unwrap_or("/").to_string();
|
||||
let ct = |v: &str| Header::from_bytes("Content-Type", v).unwrap();
|
||||
match (req.method().clone(), path.as_str()) {
|
||||
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/app.css") => {
|
||||
let _ = req
|
||||
.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
|
||||
}
|
||||
(Method::Get, "/app.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/guara.svg") => {
|
||||
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
|
||||
}
|
||||
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||
let ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
|
||||
if !ok {
|
||||
let _ =
|
||||
req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
continue;
|
||||
}
|
||||
let method = p.trim_start_matches("/rpc/").to_string();
|
||||
let mut body = String::new();
|
||||
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
|
||||
let params = serde_json::from_str(&body).unwrap_or(json!({}));
|
||||
let (code, out) = match eng.rpc(&method, params) {
|
||||
Ok(v) => (200, v),
|
||||
Err(e) => (400, json!({ "error": e.to_string() })),
|
||||
};
|
||||
let _ = req.respond(
|
||||
Response::from_string(out.to_string())
|
||||
.with_status_code(code)
|
||||
.with_header(ct("application/json")),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
|
||||
}
|
||||
}
|
||||
]);
|
||||
for req in server.incoming_requests() {
|
||||
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
|
||||
// One thread per request: streaming video/audio must not block the UI.
|
||||
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let host_ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
|
||||
if !host_ok {
|
||||
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||
return;
|
||||
}
|
||||
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
|
||||
return;
|
||||
};
|
||||
let full = req.url().to_string();
|
||||
let (path, _query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let ct = |v: &str| header("Content-Type", v);
|
||||
match (req.method().clone(), path) {
|
||||
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||
let _ = req
|
||||
.respond(Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")));
|
||||
}
|
||||
(Method::Get, "/app.css") => {
|
||||
let _ =
|
||||
req.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
|
||||
}
|
||||
(Method::Get, "/app.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/preview.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/i18n.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(I18N_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/i18n-en.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/logo.png") => {
|
||||
let _ = req.respond(Response::from_data(LOGO).with_header(ct("image/png")));
|
||||
}
|
||||
(Method::Get, "/explorer.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(EXPLORER_JS)
|
||||
.with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/vendor/pdf.min.mjs") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/vendor/pdf.worker.min.mjs") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||
let ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
|
||||
if !ok {
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return;
|
||||
}
|
||||
let method = p.trim_start_matches("/rpc/").to_string();
|
||||
let mut body = String::new();
|
||||
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
|
||||
let params = serde_json::from_str(&body).unwrap_or(json!({}));
|
||||
let (code, out) = match eng.rpc(&method, params) {
|
||||
Ok(v) => (200, v),
|
||||
Err(e) => (400, json!({ "error": e.to_string() })),
|
||||
};
|
||||
let _ = req.respond(
|
||||
Response::from_string(out.to_string())
|
||||
.with_status_code(code)
|
||||
.with_header(ct("application/json")),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_browser(url: &str) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
[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
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
sha1 = "0.11"
|
||||
md-5 = "0.11"
|
||||
jpeg-decoder = { version = "0.3", default-features = false }
|
||||
jpeg-encoder = "0.7"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
thiserror = "2"
|
||||
tiny_http = "0.12"
|
||||
bzip2 = "0.6"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
snap = "1"
|
||||
ruzstd = "0.9"
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["alloc", "safe-decode", "safe-encode"] }
|
||||
aes = "0.9"
|
||||
hmac = "0.13"
|
||||
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
|
||||
ripemd = "0.2"
|
||||
argon2 = "0.6"
|
||||
ed25519-compact = { version = "2", default-features = false, features = ["random", "std"] }
|
||||
lzfse_rust = "0.2.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", "Win32_Security"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", "Win32_Security"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mp4 = "0.14"
|
||||
@@ -0,0 +1,850 @@
|
||||
//! AFF4 (Advanced Forensic Format 4, v1.0 standard): ZIP container with Turtle RDF metadata.
|
||||
//!
|
||||
//! 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;
|
||||
use crate::image::ImageReport;
|
||||
use crate::source::{read_exact_at, Source};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn is_aff4(head: &[u8]) -> bool {
|
||||
// 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")
|
||||
|| find(&head[30..head.len().min(80)], b"version.txt"))
|
||||
}
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> bool {
|
||||
h.windows(n.len()).any(|w| w == n)
|
||||
}
|
||||
|
||||
const NS: &str = "http://aff4.org/Schema#";
|
||||
|
||||
// ------------------------------------------------------------------ Turtle
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct Resource {
|
||||
types: Vec<String>,
|
||||
props: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl Resource {
|
||||
fn get(&self, k: &str) -> Option<&str> {
|
||||
self.props
|
||||
.get(k)
|
||||
.and_then(|v| v.first())
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
fn num(&self, k: &str) -> Option<u64> {
|
||||
self.get(k)?.parse().ok()
|
||||
}
|
||||
fn is(&self, t: &str) -> bool {
|
||||
self.types.iter().any(|x| x == t)
|
||||
}
|
||||
}
|
||||
|
||||
struct Turtle<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
prefixes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Turtle<'_> {
|
||||
fn ws(&mut self) {
|
||||
loop {
|
||||
while self.i < self.b.len() && self.b[self.i].is_ascii_whitespace() {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.i < self.b.len() && self.b[self.i] == b'#' {
|
||||
while self.i < self.b.len() && self.b[self.i] != b'\n' {
|
||||
self.i += 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn expand(&self, tok: &str) -> String {
|
||||
if let Some((p, l)) = tok.split_once(':') {
|
||||
if let Some(base) = self.prefixes.get(p) {
|
||||
return format!("{base}{l}");
|
||||
}
|
||||
}
|
||||
tok.to_string()
|
||||
}
|
||||
/// 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)?;
|
||||
match c {
|
||||
b'<' => {
|
||||
let s = self.i + 1;
|
||||
while self.i < self.b.len() && self.b[self.i] != b'>' {
|
||||
self.i += 1;
|
||||
}
|
||||
let v = String::from_utf8_lossy(&self.b[s..self.i]).to_string();
|
||||
self.i += 1;
|
||||
Some(v)
|
||||
}
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
let mut out = Vec::new();
|
||||
while self.i < self.b.len() && self.b[self.i] != b'"' {
|
||||
if self.b[self.i] == b'\\' && self.i + 1 < self.b.len() {
|
||||
self.i += 1;
|
||||
}
|
||||
out.push(self.b[self.i]);
|
||||
self.i += 1;
|
||||
}
|
||||
self.i += 1;
|
||||
if self.b[self.i..].starts_with(b"^^") {
|
||||
self.i += 2;
|
||||
let _ = self.term();
|
||||
} else if self.b.get(self.i) == Some(&b'@') {
|
||||
while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out).to_string())
|
||||
}
|
||||
_ => {
|
||||
let s = self.i;
|
||||
while self.i < self.b.len()
|
||||
&& !self.b[self.i].is_ascii_whitespace()
|
||||
&& !matches!(self.b[self.i], b';' | b',')
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
// trailing statement period attached to the term
|
||||
let mut e = self.i;
|
||||
if e > s && self.b[e - 1] == b'.' {
|
||||
e -= 1;
|
||||
self.i = e;
|
||||
}
|
||||
let tok = String::from_utf8_lossy(&self.b[s..e]).to_string();
|
||||
Some(if tok == "a" { tok } else { self.expand(&tok) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_turtle(text: &str) -> HashMap<String, Resource> {
|
||||
let mut t = Turtle {
|
||||
b: text.as_bytes(),
|
||||
i: 0,
|
||||
prefixes: HashMap::new(),
|
||||
};
|
||||
let mut out: HashMap<String, Resource> = HashMap::new();
|
||||
loop {
|
||||
t.ws();
|
||||
if t.i >= t.b.len() {
|
||||
break;
|
||||
}
|
||||
if t.b[t.i..].starts_with(b"@prefix") {
|
||||
t.i += 7;
|
||||
t.ws();
|
||||
let s = t.i;
|
||||
while t.i < t.b.len() && t.b[t.i] != b':' {
|
||||
t.i += 1;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&t.b[s..t.i]).trim().to_string();
|
||||
t.i += 1;
|
||||
let iri = t.term().unwrap_or_default();
|
||||
t.ws();
|
||||
if t.b.get(t.i) == Some(&b'.') {
|
||||
t.i += 1;
|
||||
}
|
||||
t.prefixes.insert(name, iri);
|
||||
continue;
|
||||
}
|
||||
// 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;
|
||||
Some(t.prefixes.get("").cloned().unwrap_or_default())
|
||||
} else {
|
||||
t.term()
|
||||
})
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let res = out.entry(subj).or_default();
|
||||
loop {
|
||||
let Some(pred) = t.term() else { return out };
|
||||
let mut objs = vec![];
|
||||
loop {
|
||||
let Some(o) = t.term() else { return out };
|
||||
objs.push(o);
|
||||
t.ws();
|
||||
if t.b.get(t.i) == Some(&b',') {
|
||||
t.i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if pred == "a" {
|
||||
res.types.extend(objs);
|
||||
} else {
|
||||
res.props.entry(pred).or_default().extend(objs);
|
||||
}
|
||||
t.ws();
|
||||
match t.b.get(t.i) {
|
||||
Some(b';') => {
|
||||
t.i += 1;
|
||||
t.ws();
|
||||
// trailing ";" before "."
|
||||
if t.b.get(t.i) == Some(&b'.') {
|
||||
t.i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(b'.') => {
|
||||
t.i += 1;
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ reader
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Method {
|
||||
Stored,
|
||||
Deflate,
|
||||
Zlib,
|
||||
Snappy,
|
||||
Lz4,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ImageStream {
|
||||
urn: String,
|
||||
chunk: u64,
|
||||
per_bevy: u64,
|
||||
method: Method,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct MapEntry {
|
||||
start: u64,
|
||||
len: u64,
|
||||
target_off: u64,
|
||||
target: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Target {
|
||||
Stream(usize),
|
||||
Zero,
|
||||
Fill(u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Loc {
|
||||
data_start: u64,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
pub struct Aff4Source {
|
||||
file: File,
|
||||
entries: HashMap<String, Loc>,
|
||||
streams: Vec<ImageStream>,
|
||||
targets: Vec<Target>,
|
||||
map: Vec<MapEntry>,
|
||||
size: u64,
|
||||
/// 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>>>>,
|
||||
}
|
||||
|
||||
fn enc(urn: &str) -> String {
|
||||
urn.replace(':', "%3A").replace('/', "%2F")
|
||||
}
|
||||
|
||||
fn method_of(uri: &str) -> Method {
|
||||
let u = uri.to_ascii_lowercase();
|
||||
if u.contains("snappy") {
|
||||
Method::Snappy
|
||||
} else if u.contains("lz4") {
|
||||
Method::Lz4
|
||||
} else if u.contains("rfc1951") {
|
||||
Method::Deflate
|
||||
} else if u.contains("rfc1950") {
|
||||
Method::Zlib
|
||||
} else {
|
||||
Method::Stored
|
||||
}
|
||||
}
|
||||
|
||||
impl Aff4Source {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let file = File::open(path)?;
|
||||
let mut zip = zip::ZipArchive::new(file.try_clone()?)
|
||||
.map_err(|e| Error::msg(format!("AFF4 inválido (ZIP): {e}")))?;
|
||||
let mut entries = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
let f = zip.by_index_raw(i).map_err(|e| Error::msg(e.to_string()))?;
|
||||
let name = f.name().map_err(zerr)?.into_owned();
|
||||
let data_start = f
|
||||
.data_start()
|
||||
.ok_or_else(|| Error::msg(format!("AFF4: entrada sem início de dados: {name}")))?;
|
||||
entries.insert(
|
||||
name,
|
||||
Loc {
|
||||
data_start,
|
||||
size: f.compressed_size(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let turtle = {
|
||||
let mut f = zip
|
||||
.by_name("information.turtle")
|
||||
.map_err(|_| Error::msg("AFF4 sem information.turtle"))?;
|
||||
let mut s = String::new();
|
||||
f.read_to_string(&mut s)?;
|
||||
s
|
||||
};
|
||||
let res = parse_turtle(&turtle);
|
||||
let img_t = format!("{NS}Image");
|
||||
let disk_t = format!("{NS}DiskImage");
|
||||
let ds = format!("{NS}dataStream");
|
||||
let image = res
|
||||
.values()
|
||||
.find(|r| (r.is(&img_t) || r.is(&disk_t)) && r.get(&ds).is_some())
|
||||
.ok_or_else(|| {
|
||||
Error::msg("AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)")
|
||||
})?;
|
||||
let mut info = HashMap::new();
|
||||
if let Some(v) = image.get(&format!("{NS}diskModel")) {
|
||||
info.insert("Modelo do disco".to_string(), v.to_string());
|
||||
}
|
||||
for (k, label) in [
|
||||
("caseNumber", "Número do caso"),
|
||||
("examiner", "Examinador"),
|
||||
("notes", "Notas"),
|
||||
("evidenceNumber", "Número da evidência"),
|
||||
("caseName", "Nome do caso"),
|
||||
] {
|
||||
if let Some(v) = res.values().find_map(|r| r.get(&format!("{NS}{k}"))) {
|
||||
info.insert(label.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
let data_urn = image.get(&ds).unwrap().to_string();
|
||||
// Hashes recorded on the data stream (or map).
|
||||
for r in res
|
||||
.values()
|
||||
.filter(|r| r.get(&format!("{NS}hash")).is_some())
|
||||
{
|
||||
for h in r.props.get(&format!("{NS}hash")).into_iter().flatten() {
|
||||
match h.len() {
|
||||
32 if h.chars().all(|c| c.is_ascii_hexdigit()) => {
|
||||
info.entry("md5".to_string()).or_insert_with(|| h.clone());
|
||||
}
|
||||
40 if h.chars().all(|c| c.is_ascii_hexdigit()) => {
|
||||
info.entry("sha1".to_string()).or_insert_with(|| h.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut me = Self {
|
||||
file,
|
||||
entries,
|
||||
streams: vec![],
|
||||
targets: vec![],
|
||||
map: vec![],
|
||||
size: 0,
|
||||
info,
|
||||
cache: Mutex::new(None),
|
||||
index_cache: Mutex::new(HashMap::new()),
|
||||
};
|
||||
let top = res
|
||||
.get(&data_urn)
|
||||
.ok_or_else(|| Error::msg("AFF4: fluxo de dados da imagem não encontrado"))?;
|
||||
let map_t = format!("{NS}Map");
|
||||
if top.is(&map_t) {
|
||||
me.size = top.num(&format!("{NS}size")).unwrap_or(0);
|
||||
let mp = enc(&data_urn);
|
||||
let idx_txt = me.read_entry(&format!("{mp}/idx"))?;
|
||||
for line in String::from_utf8_lossy(&idx_txt).lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(sym) = line.strip_prefix(&format!("{NS}SymbolicStream")) {
|
||||
let b = u8::from_str_radix(sym, 16)
|
||||
.map_err(|_| Error::msg("AFF4: fluxo simbólico inválido"))?;
|
||||
me.targets.push(Target::Fill(b));
|
||||
} else if line == format!("{NS}Zero") {
|
||||
me.targets.push(Target::Zero);
|
||||
} else if line.starts_with(NS) {
|
||||
me.targets.push(Target::Zero);
|
||||
} else {
|
||||
let s = me.load_stream(&res, line)?;
|
||||
me.streams.push(s);
|
||||
me.targets.push(Target::Stream(me.streams.len() - 1));
|
||||
}
|
||||
}
|
||||
let m = me.read_entry(&format!("{mp}/map"))?;
|
||||
for c in m.chunks_exact(28) {
|
||||
me.map.push(MapEntry {
|
||||
start: u64::from_le_bytes(c[0..8].try_into().unwrap()),
|
||||
len: u64::from_le_bytes(c[8..16].try_into().unwrap()),
|
||||
target_off: u64::from_le_bytes(c[16..24].try_into().unwrap()),
|
||||
target: u32::from_le_bytes(c[24..28].try_into().unwrap()),
|
||||
});
|
||||
}
|
||||
me.map.sort_by_key(|e| e.start);
|
||||
} else {
|
||||
let s = me.load_stream(&res, &data_urn)?;
|
||||
me.size = s.size;
|
||||
me.streams.push(s);
|
||||
me.targets.push(Target::Stream(0));
|
||||
me.map = vec![MapEntry {
|
||||
start: 0,
|
||||
len: me.size,
|
||||
target_off: 0,
|
||||
target: 0,
|
||||
}];
|
||||
}
|
||||
if me.size == 0 {
|
||||
me.size = image.num(&format!("{NS}size")).unwrap_or(0);
|
||||
}
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
fn load_stream(&self, res: &HashMap<String, Resource>, urn: &str) -> Result<ImageStream> {
|
||||
let r = res
|
||||
.get(urn)
|
||||
.ok_or_else(|| Error::msg(format!("AFF4: fluxo {urn} não descrito")))?;
|
||||
Ok(ImageStream {
|
||||
urn: urn.to_string(),
|
||||
chunk: r.num(&format!("{NS}chunkSize")).unwrap_or(32768),
|
||||
per_bevy: r.num(&format!("{NS}chunksInSegment")).unwrap_or(2048),
|
||||
method: r
|
||||
.get(&format!("{NS}compressionMethod"))
|
||||
.map(method_of)
|
||||
.unwrap_or(Method::Stored),
|
||||
size: r.num(&format!("{NS}size")).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
};
|
||||
let Some(st) = self.streams.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut h = MultiHasher::new();
|
||||
let mut buf = vec![0u8; st.chunk as usize];
|
||||
let mut pos = 0u64;
|
||||
while pos < st.size {
|
||||
let n = (st.size - pos).min(st.chunk) as usize;
|
||||
self.read_stream(0, pos, &mut buf[..n])?;
|
||||
h.update(&buf[..n]);
|
||||
pos += n as u64;
|
||||
}
|
||||
Ok(Some(h.finalize().md5 == want))
|
||||
}
|
||||
|
||||
fn read_entry(&self, name: &str) -> Result<Vec<u8>> {
|
||||
let loc = self
|
||||
.entries
|
||||
.get(name)
|
||||
.ok_or_else(|| Error::msg(format!("AFF4: entrada {name} ausente")))?;
|
||||
let mut v = vec![0u8; loc.size as usize];
|
||||
read_exact_at_file(&self.file, loc.data_start, &mut v)?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn chunk(&self, si: usize, chunk_no: u64) -> io::Result<Vec<u8>> {
|
||||
{
|
||||
let c = self.cache.lock().unwrap();
|
||||
if let Some((s, n, d)) = c.as_ref() {
|
||||
if *s == si && *n == chunk_no {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = &self.streams[si];
|
||||
let (bevy, within) = (chunk_no / st.per_bevy, chunk_no % st.per_bevy);
|
||||
let base = format!("{}/{:08}", enc(&st.urn), bevy);
|
||||
let index = {
|
||||
let mut ic = self.index_cache.lock().unwrap();
|
||||
if let Some(i) = ic.get(&(si, bevy)) {
|
||||
i.clone()
|
||||
} else {
|
||||
let loc = self.entries.get(&format!("{base}.index")).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "índice do bevy ausente")
|
||||
})?;
|
||||
let mut v = vec![0u8; loc.size as usize];
|
||||
read_exact_at_file(&self.file, loc.data_start, &mut v)?;
|
||||
let a = std::sync::Arc::new(v);
|
||||
ic.insert((si, bevy), a.clone());
|
||||
a
|
||||
}
|
||||
};
|
||||
let e = index
|
||||
.get(within as usize * 12..within as usize * 12 + 12)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "bloco fora do índice"))?;
|
||||
let (off, len) = (
|
||||
u64::from_le_bytes(e[0..8].try_into().unwrap()),
|
||||
u32::from_le_bytes(e[8..12].try_into().unwrap()) as usize,
|
||||
);
|
||||
let loc = self
|
||||
.entries
|
||||
.get(&base)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bevy ausente"))?;
|
||||
let mut raw = vec![0u8; len];
|
||||
read_exact_at_file(&self.file, loc.data_start + off, &mut raw)?;
|
||||
let want = st.chunk.min(st.size.saturating_sub(chunk_no * st.chunk)) as usize;
|
||||
let data = if len == st.chunk as usize || st.method == Method::Stored {
|
||||
raw
|
||||
} else {
|
||||
let bad = |m: String| io::Error::new(io::ErrorKind::InvalidData, m);
|
||||
match st.method {
|
||||
Method::Snappy => snap::raw::Decoder::new()
|
||||
.decompress_vec(&raw)
|
||||
.map_err(|e| bad(format!("snappy: {e}")))?,
|
||||
Method::Lz4 => lz4_flex::block::decompress(&raw, st.chunk as usize)
|
||||
.map_err(|e| bad(format!("lz4: {e}")))?,
|
||||
Method::Deflate => {
|
||||
let mut v = Vec::with_capacity(st.chunk as usize);
|
||||
flate2::read::DeflateDecoder::new(&raw[..])
|
||||
.read_to_end(&mut v)
|
||||
.map_err(|e| bad(format!("deflate: {e}")))?;
|
||||
v
|
||||
}
|
||||
Method::Zlib => {
|
||||
let mut v = Vec::with_capacity(st.chunk as usize);
|
||||
flate2::read::ZlibDecoder::new(&raw[..])
|
||||
.read_to_end(&mut v)
|
||||
.map_err(|e| bad(format!("zlib: {e}")))?;
|
||||
v
|
||||
}
|
||||
Method::Stored => unreachable!(),
|
||||
}
|
||||
};
|
||||
let mut data = data;
|
||||
data.resize(want.max(data.len().min(st.chunk as usize)), 0);
|
||||
*self.cache.lock().unwrap() = Some((si, chunk_no, data.clone()));
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn read_stream(&self, si: usize, off: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let cs = self.streams[si].chunk;
|
||||
let mut done = 0;
|
||||
while done < buf.len() {
|
||||
let pos = off + done as u64;
|
||||
let (cn, within) = (pos / cs, (pos % cs) as usize);
|
||||
let d = self.chunk(si, cn)?;
|
||||
if within >= d.len() {
|
||||
buf[done..].fill(0);
|
||||
return Ok(());
|
||||
}
|
||||
let n = (d.len() - within).min(buf.len() - done);
|
||||
buf[done..done + n].copy_from_slice(&d[within..within + n]);
|
||||
done += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exact_at_file(f: &File, off: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::FileExt::read_exact_at(f, buf, off)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut done = 0;
|
||||
while done < buf.len() {
|
||||
let n =
|
||||
std::os::windows::fs::FileExt::seek_read(f, &mut buf[done..], off + done as u64)?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"AFF4 truncado",
|
||||
));
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for Aff4Source {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if offset >= self.size || buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let want = buf.len().min((self.size - offset) as usize);
|
||||
let mut done = 0usize;
|
||||
while done < want {
|
||||
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 {
|
||||
// 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);
|
||||
done += n;
|
||||
continue;
|
||||
};
|
||||
let n = ((e.start + e.len - pos) as usize).min(want - done);
|
||||
let toff = e.target_off + (pos - e.start);
|
||||
match self.targets.get(e.target as usize) {
|
||||
Some(Target::Stream(si)) => {
|
||||
self.read_stream(*si, toff, &mut buf[done..done + n])?
|
||||
}
|
||||
Some(Target::Fill(b)) => buf[done..done + n].fill(*b),
|
||||
_ => buf[done..done + n].fill(0),
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ writer
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Aff4Options {
|
||||
pub case_name: String,
|
||||
pub examiner: String,
|
||||
pub notes: String,
|
||||
pub evidence_number: String,
|
||||
pub chunk_size: u32,
|
||||
}
|
||||
|
||||
impl Default for Aff4Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
case_name: String::new(),
|
||||
examiner: String::new(),
|
||||
notes: String::new(),
|
||||
evidence_number: String::new(),
|
||||
chunk_size: 32768,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_uuid() -> String {
|
||||
let mut b = [0u8; 16];
|
||||
let _ = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut b));
|
||||
if b == [0u8; 16] {
|
||||
let t = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(1);
|
||||
b = (t ^ (std::process::id() as u128) << 64).to_le_bytes();
|
||||
}
|
||||
b[6] = (b[6] & 0x0F) | 0x40;
|
||||
b[8] = (b[8] & 0x3F) | 0x80;
|
||||
let h: Vec<String> = b.iter().map(|x| format!("{x:02x}")).collect();
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
h[0..4].concat(),
|
||||
h[4..6].concat(),
|
||||
h[6..8].concat(),
|
||||
h[8..10].concat(),
|
||||
h[10..16].concat()
|
||||
)
|
||||
}
|
||||
|
||||
fn iso_now() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z.rem_euclid(146097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// Writes a linear AFF4 image (single snappy bevy stream) from `src`.
|
||||
pub fn write_aff4(
|
||||
src: &dyn Source,
|
||||
dest: &Path,
|
||||
opts: &Aff4Options,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<ImageReport> {
|
||||
let out = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(dest)
|
||||
.map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"não foi possível criar {}: {e} (o arquivo não pode existir)",
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
crate::owner::give_to_user(dest);
|
||||
let total = src.size();
|
||||
let chunk = opts.chunk_size as u64;
|
||||
let per_bevy = 2048u64;
|
||||
let volume = format!("aff4://{}", new_uuid());
|
||||
let stream = format!("aff4://{}", new_uuid());
|
||||
let image = format!("aff4://{}", new_uuid());
|
||||
let mut zip = zip::ZipWriter::new(out);
|
||||
let stored = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored)
|
||||
.large_file(true);
|
||||
let result = (|| -> Result<ImageReport> {
|
||||
zip.start_file("container.description", stored)
|
||||
.map_err(zerr)?;
|
||||
zip.write_all(volume.as_bytes())?;
|
||||
zip.start_file("version.txt", stored).map_err(zerr)?;
|
||||
zip.write_all(format!("major=1\nminor=2\ntool=Guara {}\n", crate::VERSION).as_bytes())?;
|
||||
|
||||
let mut rep = ImageReport::default();
|
||||
let mut hasher = MultiHasher::new();
|
||||
let mut buf = vec![0u8; chunk as usize];
|
||||
let mut pos = 0u64;
|
||||
let mut chunk_no = 0u64;
|
||||
let mut enc_snap = snap::raw::Encoder::new();
|
||||
while pos < total {
|
||||
let bevy = chunk_no / per_bevy;
|
||||
let name = format!("{}/{:08}", enc(&stream), bevy);
|
||||
zip.start_file(name.as_str(), stored).map_err(zerr)?;
|
||||
let mut index: Vec<u8> = vec![];
|
||||
let mut boff = 0u64;
|
||||
for _ in 0..per_bevy {
|
||||
if pos >= total {
|
||||
break;
|
||||
}
|
||||
if cancelled() {
|
||||
rep.cancelled = true;
|
||||
break;
|
||||
}
|
||||
let want = (total - pos).min(chunk) as usize;
|
||||
let b = &mut buf[..want];
|
||||
if read_exact_at(src, pos, b).is_err() {
|
||||
let mut o = 0;
|
||||
while o < want {
|
||||
let n = 512.min(want - o);
|
||||
if read_exact_at(src, pos + o as u64, &mut b[o..o + n]).is_err() {
|
||||
b[o..o + n].fill(0);
|
||||
match rep.bad_ranges.last_mut() {
|
||||
Some(l) if l.0 + l.1 == pos + o as u64 => l.1 += n as u64,
|
||||
_ => rep.bad_ranges.push((pos + o as u64, n as u64)),
|
||||
}
|
||||
}
|
||||
o += n;
|
||||
}
|
||||
}
|
||||
hasher.update(b);
|
||||
// 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()))?;
|
||||
// Only a full chunk can be stored uncompressed (length == chunk_size).
|
||||
let data: &[u8] = if want as u64 == chunk && comp.len() >= want {
|
||||
b
|
||||
} else {
|
||||
&comp
|
||||
};
|
||||
zip.write_all(data)?;
|
||||
index.extend_from_slice(&boff.to_le_bytes());
|
||||
index.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
boff += data.len() as u64;
|
||||
pos += want as u64;
|
||||
chunk_no += 1;
|
||||
progress(pos, total);
|
||||
}
|
||||
zip.start_file(format!("{name}.index").as_str(), stored)
|
||||
.map_err(zerr)?;
|
||||
zip.write_all(&index)?;
|
||||
if rep.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let h = hasher.finalize();
|
||||
rep.bytes = pos;
|
||||
rep.md5 = h.md5.clone();
|
||||
rep.sha1 = h.sha1.clone();
|
||||
rep.sha256 = h.sha256.clone();
|
||||
|
||||
let now = iso_now();
|
||||
let sectors = total.div_ceil(512);
|
||||
let ttl = format!(
|
||||
"@prefix aff4: <http://aff4.org/Schema#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n\n\
|
||||
<{stream}>\n a aff4:ImageStream ;\n aff4:chunkSize \"{chunk}\"^^xsd:int ;\n aff4:chunksInSegment \"{per_bevy}\"^^xsd:int ;\n aff4:compressionMethod <http://code.google.com/p/snappy/> ;\n aff4:hash \"{sha1}\"^^aff4:SHA1 , \"{md5}\"^^aff4:MD5 ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:version \"1\"^^xsd:int .\n\n\
|
||||
<{image}>\n a aff4:ContiguousImage , aff4:DiskImage , aff4:Image ;\n aff4:blockSize \"512\"^^xsd:int ;\n aff4:dataStream <{stream}> ;\n aff4:sectorCount \"{sectors}\"^^xsd:long ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> .\n\n\
|
||||
<aff4://{notes_id}>\n a aff4:CaseNotes ;\n aff4:caseNumber \"{case}\" ;\n aff4:evidenceNumber \"{ev}\" ;\n aff4:examiner \"{ex}\" ;\n aff4:notes \"{notes}\" ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:timestamp \"{now}\"^^xsd:dateTime .\n\n\
|
||||
<{volume}>\n a aff4:ZipVolume ;\n aff4:contains <{stream}> , <{image}> ;\n aff4:creationTime \"{now}\"^^xsd:dateTime ;\n aff4:interface aff4:Volume ;\n aff4:stored \"{fname}\" .\n",
|
||||
sha1 = h.sha1,
|
||||
md5 = h.md5,
|
||||
notes_id = new_uuid(),
|
||||
case = esc(&opts.case_name),
|
||||
ev = esc(&opts.evidence_number),
|
||||
ex = esc(&opts.examiner),
|
||||
notes = esc(&opts.notes),
|
||||
fname = esc(&dest.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()),
|
||||
);
|
||||
zip.start_file("information.turtle", stored).map_err(zerr)?;
|
||||
zip.write_all(ttl.as_bytes())?;
|
||||
Ok(rep)
|
||||
})();
|
||||
match result {
|
||||
Ok(rep) => {
|
||||
zip.finish().map_err(zerr)?;
|
||||
crate::owner::write_owned(
|
||||
&dest.with_extension("map.json"),
|
||||
serde_json::to_vec_pretty(&rep)?,
|
||||
)?;
|
||||
Ok(rep)
|
||||
}
|
||||
Err(e) => {
|
||||
drop(zip);
|
||||
let _ = std::fs::remove_file(dest);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn zerr(e: zip::result::ZipError) -> Error {
|
||||
Error::msg(format!("AFF4/ZIP: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
//! 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};
|
||||
use crate::partition;
|
||||
use crate::source::{open_path, Source, SubSource};
|
||||
use crate::types::{FsKind, PartInfo};
|
||||
use crate::volume::{Overlay, Region};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum SecretInput {
|
||||
Password {
|
||||
value: String,
|
||||
},
|
||||
Recovery {
|
||||
value: String,
|
||||
},
|
||||
/// Path to a key file (BitLocker .BEK, LUKS keyfile).
|
||||
Keyfile {
|
||||
path: String,
|
||||
},
|
||||
/// Hex-encoded volume master key.
|
||||
Key {
|
||||
hex: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub kind: String,
|
||||
pub key_hex: String,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Chunk (stripe) size in KiB.
|
||||
pub chunk_kb: Option<u64>,
|
||||
pub layout: Option<crate::raid::Layout>,
|
||||
/// 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 {
|
||||
/// Treat target and `members` as a metadata-less RAID array (parameters auto-detected).
|
||||
#[serde(default)]
|
||||
pub raid: Option<RaidSpec>,
|
||||
/// Additional disks or images belonging to the same set (RAID/LVM) besides the primary target.
|
||||
#[serde(default)]
|
||||
pub members: Vec<String>,
|
||||
/// Passwords and keys: stripped by session `snapshot` before saving to disk.
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<SecretInput>,
|
||||
/// Persist derived volume keys in the session so it can be reopened without re-prompting.
|
||||
#[serde(default)]
|
||||
pub remember_keys: bool,
|
||||
#[serde(default)]
|
||||
pub keys: Vec<SavedKey>,
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
pub fn is_plain(&self) -> bool {
|
||||
self.members.is_empty()
|
||||
&& self.secrets.is_empty()
|
||||
&& self.keys.is_empty()
|
||||
&& self.raid.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct UnlockInfo {
|
||||
pub start: u64,
|
||||
pub len: u64,
|
||||
pub kind: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct LockedInfo {
|
||||
pub start: u64,
|
||||
pub kind: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub struct Assembled {
|
||||
pub source: Arc<dyn Source>,
|
||||
pub unlocked: Vec<UnlockInfo>,
|
||||
pub locked: Vec<LockedInfo>,
|
||||
/// Derived keys to persist in the session when permitted by the user.
|
||||
pub keys: Vec<SavedKey>,
|
||||
/// Logical volumes / extra partitions not present in the physical partition table.
|
||||
pub extra_parts: Vec<PartInfo>,
|
||||
/// Physical partitions that act solely as containers (reassembled RAID/LVM members).
|
||||
pub covered: Vec<u64>,
|
||||
/// Primary disk is merely a RAID member: skip scanning the raw disk directly.
|
||||
pub skip_primary: bool,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
fn to_secrets(a: &Assembly) -> Result<Vec<Secret>> {
|
||||
let mut v = vec![];
|
||||
for s in &a.secrets {
|
||||
v.push(match s {
|
||||
SecretInput::Password { value } => Secret::Password(value.clone()),
|
||||
SecretInput::Recovery { value } => Secret::RecoveryKey(value.clone()),
|
||||
SecretInput::Keyfile { path } => Secret::KeyFile(std::fs::read(path).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"não foi possível ler o arquivo de chave {path}: {e}"
|
||||
))
|
||||
})?),
|
||||
SecretInput::Key { hex } => Secret::VolumeKey(
|
||||
crypt::from_hex(hex)
|
||||
.ok_or_else(|| Error::msg("chave do volume em hexadecimal inválida"))?,
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn kind_name(fs: FsKind) -> &'static str {
|
||||
match fs {
|
||||
FsKind::Bitlocker => "bitlocker",
|
||||
FsKind::Luks => "luks",
|
||||
FsKind::Corestorage => "filevault",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn try_unlock(window: Arc<dyn Source>, fs: FsKind, secrets: &[Secret]) -> crypt::UnlockResult {
|
||||
match fs {
|
||||
FsKind::Bitlocker => crypt::bitlocker::unlock(window, secrets),
|
||||
FsKind::Luks => crypt::luks::unlock(window, secrets),
|
||||
FsKind::Corestorage => crypt::filevault::unlock(window, secrets),
|
||||
_ => Err(UnlockError::Unsupported("tipo".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
const MIB: u64 = 1 << 20;
|
||||
|
||||
/// 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
|
||||
.iter_mut()
|
||||
.find(|r| r.start <= start && start < r.start + r.len)
|
||||
{
|
||||
let inner = Region {
|
||||
start: start - r.start,
|
||||
len,
|
||||
src,
|
||||
label: label.clone(),
|
||||
};
|
||||
let wrapped = Overlay::new(r.src.clone(), vec![inner]);
|
||||
if start == r.start {
|
||||
r.len = r.len.max(len);
|
||||
}
|
||||
r.src = Arc::new(wrapped);
|
||||
return;
|
||||
}
|
||||
regions.push(Region {
|
||||
start,
|
||||
len,
|
||||
src,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
/// Candidate RAID/LVM member: a disk window (whole disk or partition).
|
||||
struct Window {
|
||||
src: Arc<dyn Source>,
|
||||
/// Start offset in the virtual disk address space, if part of it.
|
||||
primary_start: Option<u64>,
|
||||
}
|
||||
|
||||
fn windows_of(src: &Arc<dyn Source>, primary: bool) -> Vec<Window> {
|
||||
let mut v = vec![Window {
|
||||
src: src.clone(),
|
||||
primary_start: primary.then_some(0),
|
||||
}];
|
||||
let layout = partition::analyze(src.as_ref(), false);
|
||||
for p in &layout.partitions {
|
||||
let len = if p.len == 0 {
|
||||
src.size().saturating_sub(p.start)
|
||||
} else {
|
||||
p.len
|
||||
};
|
||||
v.push(Window {
|
||||
src: Arc::new(SubSource::new(src.clone(), p.start, len)),
|
||||
primary_start: primary.then_some(p.start),
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assembled> {
|
||||
let secrets = to_secrets(asm)?;
|
||||
let mut regions: Vec<Region> = vec![];
|
||||
let mut out = Assembled {
|
||||
source: base.clone(),
|
||||
unlocked: vec![],
|
||||
locked: vec![],
|
||||
keys: vec![],
|
||||
extra_parts: vec![],
|
||||
covered: vec![],
|
||||
skip_primary: false,
|
||||
notes: vec![],
|
||||
};
|
||||
let mut members: Vec<Arc<dyn Source>> = vec![];
|
||||
for m in &asm.members {
|
||||
members.push(open_path(Path::new(m)).map_err(|e| Error::msg(format!("{m}: {e}")))?);
|
||||
}
|
||||
let mut next_virtual = base.size().div_ceil(MIB) * MIB + MIB;
|
||||
if let Some(spec) = &asm.raid {
|
||||
let mut disks: Vec<Arc<dyn Source>> = vec![base.clone()];
|
||||
disks.extend(members.iter().cloned());
|
||||
let params = pick_raid(&disks, spec, &mut out.notes)?;
|
||||
let src: Arc<dyn Source> =
|
||||
Arc::new(crate::raid::RaidSource::new(params).map_err(Error::msg)?);
|
||||
out.notes.push(format!(
|
||||
"RAID montado a partir de {} disco(s) sem metadados.",
|
||||
disks.len()
|
||||
));
|
||||
out.skip_primary = true;
|
||||
members.clear();
|
||||
add_virtual(
|
||||
&mut regions,
|
||||
&mut next_virtual,
|
||||
&mut out,
|
||||
src,
|
||||
"RAID".into(),
|
||||
);
|
||||
}
|
||||
let mut done_containers: std::collections::BTreeSet<String> = Default::default();
|
||||
let mut tried_pool = false;
|
||||
// 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);
|
||||
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
|
||||
for p in &out.extra_parts {
|
||||
if !all_parts.iter().any(|q| q.start == p.start) {
|
||||
all_parts.push(p.clone());
|
||||
}
|
||||
}
|
||||
let mut progress = false;
|
||||
|
||||
// 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 {
|
||||
src: current.clone(),
|
||||
primary_start: Some(0),
|
||||
});
|
||||
for p in &all_parts {
|
||||
if matches!(p.fs, FsKind::Raid | FsKind::Lvm) {
|
||||
let len = if p.len == 0 {
|
||||
current.size().saturating_sub(p.start)
|
||||
} else {
|
||||
p.len
|
||||
};
|
||||
pool.push(Window {
|
||||
src: Arc::new(SubSource::new(current.clone(), p.start, len)),
|
||||
primary_start: Some(p.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
for m in &members {
|
||||
pool.extend(windows_of(m, false));
|
||||
}
|
||||
tried_pool = true;
|
||||
// RAID
|
||||
let mut md: Vec<(Arc<dyn Source>, crate::raid::MdSuper)> = vec![];
|
||||
let mut md_primary: Vec<u64> = vec![];
|
||||
for w in &pool {
|
||||
if let Some(sup) = crate::raid::read_md_super(w.src.as_ref()) {
|
||||
if let Some(st) = w.primary_start {
|
||||
md_primary.push(st);
|
||||
}
|
||||
md.push((w.src.clone(), sup));
|
||||
}
|
||||
}
|
||||
let new_md: Vec<_> = md
|
||||
.into_iter()
|
||||
.filter(|(_, s)| {
|
||||
!done_containers.contains(&format!("md{}", crypt::to_hex(&s.uuid)))
|
||||
})
|
||||
.collect();
|
||||
if !new_md.is_empty() {
|
||||
for (_, s) in &new_md {
|
||||
done_containers.insert(format!("md{}", crypt::to_hex(&s.uuid)));
|
||||
}
|
||||
for res in crate::raid::assemble_md(new_md) {
|
||||
match res {
|
||||
Ok(a) => {
|
||||
let label = format!(
|
||||
"{} {}",
|
||||
a.info.level.name(),
|
||||
if a.name.is_empty() {
|
||||
a.uuid.clone()
|
||||
} else {
|
||||
a.name.clone()
|
||||
}
|
||||
);
|
||||
if a.missing > 0 {
|
||||
out.notes.push(format!(
|
||||
"{label}: montado sem {} membro(s) (modo degradado).",
|
||||
a.missing
|
||||
));
|
||||
} else {
|
||||
out.notes.push(format!("{label} montado."));
|
||||
}
|
||||
add_virtual(
|
||||
&mut regions,
|
||||
&mut next_virtual,
|
||||
&mut out,
|
||||
a.source.clone(),
|
||||
label,
|
||||
);
|
||||
progress = true;
|
||||
}
|
||||
Err(e) => out.notes.push(format!("RAID não montado: {e}")),
|
||||
}
|
||||
}
|
||||
out.covered.extend(md_primary);
|
||||
}
|
||||
// LVM
|
||||
let mut pvs: Vec<(Arc<dyn Source>, crate::lvm::PvInfo)> = vec![];
|
||||
let mut pv_primary: Vec<u64> = vec![];
|
||||
let md_srcs: Vec<Arc<dyn Source>> = regions.iter().map(|r| r.src.clone()).collect();
|
||||
let mut lvm_pool: Vec<(Arc<dyn Source>, Option<u64>)> = pool
|
||||
.iter()
|
||||
.map(|w| (w.src.clone(), w.primary_start))
|
||||
.collect();
|
||||
for (r, s) in regions.iter().zip(md_srcs) {
|
||||
lvm_pool.push((s, Some(r.start)));
|
||||
}
|
||||
for (src, st) in lvm_pool {
|
||||
if let Some(pv) = crate::lvm::read_pv(src.as_ref()) {
|
||||
if !done_containers.insert(format!("pv{}", pv.uuid)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(st) = st {
|
||||
pv_primary.push(st);
|
||||
}
|
||||
pvs.push((src, pv));
|
||||
}
|
||||
}
|
||||
if !pvs.is_empty() {
|
||||
for vg in crate::lvm::assemble_vgs(pvs) {
|
||||
for w in &vg.warnings {
|
||||
out.notes.push(format!("LVM {}: {w}", vg.name));
|
||||
}
|
||||
for (name, lv) in vg.lvs {
|
||||
add_virtual(
|
||||
&mut regions,
|
||||
&mut next_virtual,
|
||||
&mut out,
|
||||
lv,
|
||||
format!("LVM {}/{}", vg.name, name),
|
||||
);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
out.covered.extend(pv_primary);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
for p in &out.extra_parts {
|
||||
if !all_parts.iter().any(|q| q.start == p.start) {
|
||||
all_parts.push(p.clone());
|
||||
}
|
||||
}
|
||||
out.locked.clear();
|
||||
for p in &all_parts {
|
||||
if p.encrypted.is_none() || out.unlocked.iter().any(|u| u.start == p.start) {
|
||||
continue;
|
||||
}
|
||||
let len = if p.len == 0 {
|
||||
current.size().saturating_sub(p.start)
|
||||
} else {
|
||||
p.len
|
||||
};
|
||||
let window: Arc<dyn Source> = Arc::new(SubSource::new(current.clone(), p.start, len));
|
||||
let mut all = secrets.clone();
|
||||
for k in asm.keys.iter().filter(|k| k.start == p.start) {
|
||||
if let Some(b) = crypt::from_hex(&k.key_hex) {
|
||||
all.push(Secret::VolumeKey(b));
|
||||
}
|
||||
}
|
||||
match try_unlock(window, p.fs, &all) {
|
||||
Ok(u) => {
|
||||
let ulen = u.source.size();
|
||||
out.unlocked.push(UnlockInfo {
|
||||
start: p.start,
|
||||
len: ulen,
|
||||
kind: kind_name(p.fs).into(),
|
||||
description: u.description.clone(),
|
||||
});
|
||||
if let Some(k) = &u.volume_key {
|
||||
out.keys.push(SavedKey { start: p.start, kind: kind_name(p.fs).into(), key_hex: crypt::to_hex(k) });
|
||||
}
|
||||
patch(&mut regions, p.start, u.source, u.description);
|
||||
progress = true;
|
||||
}
|
||||
Err(e) => out.locked.push(LockedInfo {
|
||||
start: p.start,
|
||||
kind: kind_name(p.fs).into(),
|
||||
reason: match e {
|
||||
UnlockError::WrongSecret if secrets.is_empty() && asm.keys.is_empty() => "volume cifrado: informe a senha, a chave de recuperação ou o arquivo de chave".into(),
|
||||
other => other.to_string(),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
if !progress {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !regions.is_empty() {
|
||||
out.source = Arc::new(Overlay::new(base, regions));
|
||||
// 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;
|
||||
p.encrypted = fs.encrypted().map(String::from);
|
||||
if let Some(l) = len {
|
||||
p.len = p.len.min(l).max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.covered.sort_unstable();
|
||||
out.covered.dedup();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
out: &mut Assembled,
|
||||
src: Arc<dyn Source>,
|
||||
label: String,
|
||||
) {
|
||||
let start = *next;
|
||||
let len = src.size();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
*next = (start + len).div_ceil(MIB) * MIB + MIB;
|
||||
let inner = partition::analyze(src.as_ref(), false);
|
||||
if inner.partitions.is_empty() {
|
||||
out.extra_parts.push(PartInfo {
|
||||
index: 0,
|
||||
start,
|
||||
len,
|
||||
fs: FsKind::Unknown,
|
||||
label: Some(label.clone()),
|
||||
lost: false,
|
||||
encrypted: None,
|
||||
table_type: Some(label.clone()),
|
||||
});
|
||||
} else {
|
||||
for p in inner.partitions {
|
||||
let mut q = p.clone();
|
||||
q.start += start;
|
||||
q.label = q.label.or_else(|| Some(label.clone()));
|
||||
out.extra_parts.push(q);
|
||||
}
|
||||
}
|
||||
regions.push(Region {
|
||||
start,
|
||||
len,
|
||||
src,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolves RAID parameters: user overrides take precedence; remaining fields come from detection.
|
||||
fn pick_raid(
|
||||
disks: &[Arc<dyn Source>],
|
||||
spec: &RaidSpec,
|
||||
notes: &mut Vec<String>,
|
||||
) -> Result<crate::raid::RaidParams> {
|
||||
use crate::raid::{Member, RaidParams};
|
||||
if let (Some(level), Some(chunk), Some(layout), Some(order)) =
|
||||
(spec.level, spec.chunk_kb, spec.layout, spec.order.clone())
|
||||
{
|
||||
if order.len() == disks.len() && order.iter().all(|&i| i < disks.len()) {
|
||||
let members = order
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
Some(Member {
|
||||
src: disks[i].clone(),
|
||||
data_offset: 0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
return Ok(RaidParams::simple(level, chunk * 1024, layout, members));
|
||||
}
|
||||
}
|
||||
let cands = crate::raid_detect::detect(disks, &|| false);
|
||||
let c = cands
|
||||
.into_iter()
|
||||
.find(|c| {
|
||||
spec.level.map_or(true, |l| c.level == l)
|
||||
&& spec.chunk_kb.map_or(true, |k| c.chunk == k * 1024)
|
||||
&& spec.layout.map_or(true, |l| c.layout == l)
|
||||
&& spec.order.as_ref().map_or(true, |o| *o == c.order)
|
||||
})
|
||||
.ok_or_else(|| Error::msg("não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)"))?;
|
||||
notes.push(format!(
|
||||
"Parâmetros do RAID descobertos: {}, faixa de {} KiB, layout {:?}, ordem dos discos {:?} (pontuação {:.1}).",
|
||||
c.level.name(),
|
||||
c.chunk / 1024,
|
||||
c.layout,
|
||||
c.order,
|
||||
c.score
|
||||
));
|
||||
Ok(c.params(disks))
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Audit trail: each examiner action (opening a source, scanning, recovering, imaging,
|
||||
//! exporting) is recorded as a JSON line with who, when, and what.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! 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};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
seq: u64,
|
||||
time: &'a str,
|
||||
user: &'a str,
|
||||
host: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
examiner: Option<&'a str>,
|
||||
version: &'a str,
|
||||
action: &'a str,
|
||||
detail: &'a Value,
|
||||
prev: &'a str,
|
||||
}
|
||||
|
||||
/// A single audit log entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub seq: u64,
|
||||
/// UTC timestamp in ISO 8601 format.
|
||||
pub time: String,
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub examiner: Option<String>,
|
||||
pub version: String,
|
||||
pub action: String,
|
||||
pub detail: Value,
|
||||
pub prev: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Result of verifying the hash chain.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Verification {
|
||||
pub entries: usize,
|
||||
pub ok: bool,
|
||||
/// 1-based entry index where the chain breaks and the reason.
|
||||
pub broken_at: Option<(u64, String)>,
|
||||
/// Hash of the last entry; store off-machine to detect tail truncation.
|
||||
pub head: String,
|
||||
}
|
||||
|
||||
pub struct Log {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
fn sha_hex(prev: &str, body: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(prev.as_bytes());
|
||||
h.update(b"\n");
|
||||
h.update(body.as_bytes());
|
||||
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z.rem_euclid(146097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
utc_iso(secs)
|
||||
}
|
||||
|
||||
fn env(k: &str) -> Option<String> {
|
||||
std::env::var(k).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Underlying OS user performing the action (the user who invoked sudo, if applicable).
|
||||
fn current_user() -> String {
|
||||
env("SUDO_USER")
|
||||
.or_else(|| env("USER"))
|
||||
.or_else(|| env("USERNAME"))
|
||||
.or_else(|| env("LOGNAME"))
|
||||
.unwrap_or_else(|| "?".into())
|
||||
}
|
||||
|
||||
fn host_name() -> String {
|
||||
env("HOSTNAME")
|
||||
.or_else(|| env("COMPUTERNAME"))
|
||||
.or_else(|| {
|
||||
std::fs::read_to_string("/etc/hostname")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "?".into())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let len = f.metadata()?.len();
|
||||
let mut want = 8192u64;
|
||||
loop {
|
||||
let start = len.saturating_sub(want);
|
||||
f.seek(SeekFrom::Start(start))?;
|
||||
let mut buf = vec![];
|
||||
f.read_to_end(&mut buf)?;
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let trimmed = text.trim_end();
|
||||
match trimmed.rfind('\n') {
|
||||
Some(i) => return Ok(Some(trimmed[i + 1..].to_string())),
|
||||
None if start == 0 => {
|
||||
return Ok((!trimmed.is_empty()).then(|| trimmed.to_string()));
|
||||
}
|
||||
None => want *= 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Log {
|
||||
pub fn at(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// `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),
|
||||
None => crate::settings::config_dir().join("auditoria.jsonl"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_default() -> Self {
|
||||
Self::at(Self::default_path())
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
Some(l) => {
|
||||
let e: Entry = serde_json::from_str(&l)
|
||||
.map_err(|e| Error::msg(format!("última linha da trilha inválida: {e}")))?;
|
||||
(e.seq + 1, e.hash)
|
||||
}
|
||||
};
|
||||
let (time, user, host) = (now_iso(), current_user(), host_name());
|
||||
let examiner = env("GUARA_EXAMINER");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let body = serde_json::to_string(&Body {
|
||||
seq,
|
||||
time: &time,
|
||||
user: &user,
|
||||
host: &host,
|
||||
examiner: examiner.as_deref(),
|
||||
version,
|
||||
action,
|
||||
detail: &detail,
|
||||
prev: &prev,
|
||||
})?;
|
||||
let hash = sha_hex(&prev, &body);
|
||||
let line = format!("{},\"hash\":\"{hash}\"}}\n", &body[..body.len() - 1]);
|
||||
if let Some(dir) = self.path.parent() {
|
||||
crate::owner::create_dir_all_owned(dir)?;
|
||||
}
|
||||
let existed = self.path.exists();
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.flush()?;
|
||||
if !existed {
|
||||
crate::owner::give_to_user(&self.path);
|
||||
}
|
||||
Ok(Entry {
|
||||
seq,
|
||||
time,
|
||||
user,
|
||||
host,
|
||||
examiner,
|
||||
version: version.into(),
|
||||
action: action.into(),
|
||||
detail,
|
||||
prev,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
serde_json::from_str(l)
|
||||
.map_err(|e| Error::msg(format!("linha {} da trilha inválida: {e}", i + 1)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut prev = GENESIS.to_string();
|
||||
let mut n = 0usize;
|
||||
let broken = |n: usize, why: &str, entries: usize, head: &str| Verification {
|
||||
entries,
|
||||
ok: false,
|
||||
broken_at: Some((n as u64, why.to_string())),
|
||||
head: head.to_string(),
|
||||
};
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
n += 1;
|
||||
let Ok(e) = serde_json::from_str::<Entry>(line) else {
|
||||
return Ok(broken(n, "linha ilegível", n - 1, &prev));
|
||||
};
|
||||
if e.seq != n as u64 {
|
||||
return Ok(broken(
|
||||
n,
|
||||
"numeração fora de ordem (entrada removida ou inserida)",
|
||||
n - 1,
|
||||
&prev,
|
||||
));
|
||||
}
|
||||
if e.prev != prev {
|
||||
return Ok(broken(n, "não continua a entrada anterior", n - 1, &prev));
|
||||
}
|
||||
let Some(i) = line.rfind(",\"hash\":\"") else {
|
||||
return Ok(broken(n, "linha sem hash", n - 1, &prev));
|
||||
};
|
||||
let body = format!("{}}}", &line[..i]);
|
||||
if sha_hex(&prev, &body) != e.hash {
|
||||
return Ok(broken(
|
||||
n,
|
||||
"conteúdo alterado (o hash não confere)",
|
||||
n - 1,
|
||||
&prev,
|
||||
));
|
||||
}
|
||||
prev = e.hash;
|
||||
}
|
||||
Ok(Verification {
|
||||
entries: n,
|
||||
ok: true,
|
||||
broken_at: None,
|
||||
head: prev,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build details JSON for a source path.
|
||||
pub fn path_detail(path: &str) -> Value {
|
||||
json!({ "path": path })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_de_datas_conhecidas() {
|
||||
assert_eq!(utc_iso(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc_iso(951_782_400), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(utc_iso(1_792_000_000 + 3661), "2026-10-14T18:47:41Z");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -25,23 +25,33 @@ pub fn carve_range(
|
||||
cfg: &CarveCfg,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
ev: &mut dyn CarveEvents,
|
||||
) -> bool {
|
||||
) -> 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;
|
||||
let mut fails = 0u32;
|
||||
while pos < end {
|
||||
if cancelled() {
|
||||
*next = pos;
|
||||
return false;
|
||||
return CarveEnd::Cancelled;
|
||||
}
|
||||
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
|
||||
let got = match read_full(src, pos, &mut buf[..want]) {
|
||||
Ok(n) => n,
|
||||
Ok(n) => {
|
||||
fails = 0;
|
||||
n
|
||||
}
|
||||
Err(_) => {
|
||||
// Setor ruim: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
@@ -52,6 +62,7 @@ pub fn carve_range(
|
||||
break;
|
||||
}
|
||||
let scan_len = got.min(CHUNK).min((end - pos) as usize);
|
||||
ev.chunk(pos, &buf[..scan_len]);
|
||||
let mut i = 0usize;
|
||||
while i < scan_len {
|
||||
let abs = pos + i as u64;
|
||||
@@ -68,10 +79,12 @@ pub fn carve_range(
|
||||
|| cfg.categories.contains(&category_for_ext(hit.ext));
|
||||
if hit.len >= MIN_LEN && allowed {
|
||||
let action = ev.hit(abs, &hit);
|
||||
if matches!(action, HitAction::Skip)
|
||||
&& hit.confidence != crate::types::Confidence::Low
|
||||
{
|
||||
skip_until = abs + hit.len;
|
||||
match action {
|
||||
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
|
||||
skip_until = abs + hit.len;
|
||||
}
|
||||
HitAction::SkipLen(n) => skip_until = abs + n,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,20 +97,33 @@ pub fn carve_range(
|
||||
*next = pos;
|
||||
}
|
||||
*next = end;
|
||||
true
|
||||
CarveEnd::Done
|
||||
}
|
||||
|
||||
/// Receptor de eventos do carving.
|
||||
/// Outcome of carving a byte range.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarveEnd {
|
||||
Done,
|
||||
Cancelled,
|
||||
/// Multiple consecutive reads failed: the device was likely disconnected.
|
||||
IoLost,
|
||||
}
|
||||
|
||||
/// Receiver for file-carving events.
|
||||
pub trait CarveEvents {
|
||||
fn advance(&mut self, bytes: u64);
|
||||
/// 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,
|
||||
/// Segue setor a setor (acerto descartado).
|
||||
/// Skip only this many bytes (when the remainder of the file is unreliable).
|
||||
SkipLen(u64),
|
||||
/// Continue sector by sector (hit discarded).
|
||||
Continue,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
//! 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};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn le16(b: &[u8], o: usize) -> usize {
|
||||
u16::from_le_bytes([b[o], b[o + 1]]) as usize
|
||||
}
|
||||
fn le32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
fn le64(b: &[u8], o: usize) -> u64 {
|
||||
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
const GUID_NORMAL: [u8; 16] = [
|
||||
0x3b, 0xd6, 0x67, 0x49, 0x29, 0x2e, 0xd8, 0x4a, 0x83, 0x99, 0xf6, 0xa3, 0x39, 0xe3, 0xd0, 0x01,
|
||||
];
|
||||
const GUID_EOW: [u8; 16] = [
|
||||
0x3b, 0x4d, 0xa8, 0x92, 0x80, 0xdd, 0x0e, 0x4d, 0x9e, 0x4e, 0xb1, 0xe3, 0x28, 0x4e, 0xae, 0xd8,
|
||||
];
|
||||
|
||||
/// 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: 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);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn detect(vbr: &[u8]) -> bool {
|
||||
offsets_pos(vbr).is_some()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Elephant
|
||||
|
||||
fn rotl(x: u32, n: u32) -> u32 {
|
||||
x.rotate_left(n)
|
||||
}
|
||||
|
||||
const RA: [u32; 4] = [9, 0, 13, 0];
|
||||
const RB: [u32; 4] = [0, 10, 0, 25];
|
||||
|
||||
fn words(buf: &[u8]) -> Vec<u32> {
|
||||
buf.chunks_exact(4)
|
||||
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Decryption (Linux dm-crypt order): additions in ascending order.
|
||||
fn diffuser_a_decrypt(d: &mut [u32]) {
|
||||
let n = d.len();
|
||||
for _ in 0..5 {
|
||||
for i in 0..n {
|
||||
let a = d[(i + n - 2) % n];
|
||||
let b = rotl(d[(i + n - 5) % n], RA[i % 4]);
|
||||
d[i] = d[i].wrapping_add(a ^ b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diffuser_b_decrypt(d: &mut [u32]) {
|
||||
let n = d.len();
|
||||
for _ in 0..3 {
|
||||
for i in 0..n {
|
||||
let a = d[(i + 2) % n];
|
||||
let b = rotl(d[(i + 5) % n], RB[i % 4]);
|
||||
d[i] = d[i].wrapping_add(a ^ b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
key.encrypt(&mut iv);
|
||||
cbc_decrypt(key, &iv, buf);
|
||||
let mut d = words(buf);
|
||||
diffuser_b_decrypt(&mut d);
|
||||
diffuser_a_decrypt(&mut d);
|
||||
for (i, w) in d.iter().enumerate() {
|
||||
buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
// 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;
|
||||
k1[15] = 0x80;
|
||||
tweak.encrypt(&mut k0);
|
||||
tweak.encrypt(&mut k1);
|
||||
let mut sk = [0u8; 32];
|
||||
sk[..16].copy_from_slice(&k0);
|
||||
sk[16..].copy_from_slice(&k1);
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b ^= sk[i % 32];
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CCM
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let ctr = |i: u32| -> [u8; 16] {
|
||||
let mut a = [0u8; 16];
|
||||
a[0] = 2;
|
||||
a[1..13].copy_from_slice(nonce);
|
||||
a[13..16].copy_from_slice(&i.to_be_bytes()[1..]);
|
||||
key.encrypt(&mut a);
|
||||
a
|
||||
};
|
||||
let s0 = ctr(0);
|
||||
let mut tag = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
tag[i] = blob[i] ^ s0[i];
|
||||
}
|
||||
let ct = &blob[16..];
|
||||
let mut pt = Vec::with_capacity(ct.len());
|
||||
for (bi, ch) in ct.chunks(16).enumerate() {
|
||||
let s = ctr(bi as u32 + 1);
|
||||
pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b));
|
||||
}
|
||||
// CBC-MAC over B0 + plaintext.
|
||||
let mut b0 = [0u8; 16];
|
||||
b0[0] = 0x3A;
|
||||
b0[1..13].copy_from_slice(nonce);
|
||||
b0[13..16].copy_from_slice(&(pt.len() as u32).to_be_bytes()[1..]);
|
||||
key.encrypt(&mut b0);
|
||||
let mut x = b0;
|
||||
for ch in pt.chunks(16) {
|
||||
for i in 0..ch.len() {
|
||||
x[i] ^= ch[i];
|
||||
}
|
||||
key.encrypt(&mut x);
|
||||
}
|
||||
(x == tag).then_some(pt)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- keys
|
||||
|
||||
fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] {
|
||||
let mut last = [0u8; 32];
|
||||
let mut buf = [0u8; 88];
|
||||
buf[32..64].copy_from_slice(initial);
|
||||
buf[64..80].copy_from_slice(salt);
|
||||
for i in 0u64..0x10_0000 {
|
||||
buf[..32].copy_from_slice(&last);
|
||||
buf[80..88].copy_from_slice(&i.to_le_bytes());
|
||||
last.copy_from_slice(&Sha256::digest(buf));
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] {
|
||||
let utf16: Vec<u8> = pw.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
let h1 = Sha256::digest(&utf16);
|
||||
let mut init = [0u8; 32];
|
||||
init.copy_from_slice(&Sha256::digest(h1));
|
||||
stretch(&init, salt)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let mut raw = [0u8; 16];
|
||||
for i in 0..8 {
|
||||
let n: u32 = digits[i * 6..i * 6 + 6].parse().ok()?;
|
||||
if n % 11 != 0 || n / 11 > 0xFFFF {
|
||||
return None;
|
||||
}
|
||||
raw[i * 2..i * 2 + 2].copy_from_slice(&((n / 11) as u16).to_le_bytes());
|
||||
}
|
||||
let mut init = [0u8; 32];
|
||||
init.copy_from_slice(&Sha256::digest(raw));
|
||||
Some(stretch(&init, salt))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- metadata
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
etype: usize,
|
||||
vtype: usize,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_entries(b: &[u8]) -> Vec<Entry> {
|
||||
let mut v = vec![];
|
||||
let mut o = 0;
|
||||
while o + 8 <= b.len() {
|
||||
let size = le16(b, o);
|
||||
if size < 8 || o + size > b.len() {
|
||||
break;
|
||||
}
|
||||
v.push(Entry {
|
||||
etype: le16(b, o + 2),
|
||||
vtype: le16(b, o + 4),
|
||||
data: b[o + 8..o + size].to_vec(),
|
||||
});
|
||||
o += size;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
struct Vmk {
|
||||
protection: usize,
|
||||
nested: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl Vmk {
|
||||
fn ccm(&self) -> Option<&Entry> {
|
||||
self.nested.iter().find(|e| e.vtype == 5)
|
||||
}
|
||||
fn stretch_salt(&self) -> Option<&[u8]> {
|
||||
self.nested
|
||||
.iter()
|
||||
.find(|e| e.vtype == 3)
|
||||
.filter(|e| e.data.len() >= 20)
|
||||
.map(|e| &e.data[4..20])
|
||||
}
|
||||
}
|
||||
|
||||
struct Fve {
|
||||
method: usize,
|
||||
volume_header_offset: u64,
|
||||
volume_header_size: u64,
|
||||
encrypted_size: u64,
|
||||
meta_size: u64,
|
||||
vmks: Vec<Vmk>,
|
||||
fvek: Option<Entry>,
|
||||
}
|
||||
|
||||
fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
|
||||
let h = read_vec(src, offset, 64 + 48).ok()?;
|
||||
if h.len() < 112 || &h[..8] != b"-FVE-FS-" {
|
||||
return None;
|
||||
}
|
||||
let version = le16(&h, 10);
|
||||
if version != 2 {
|
||||
return None; // Vista (version 1) not supported
|
||||
}
|
||||
let encrypted_size = le64(&h, 16);
|
||||
let hdr_sectors = le32(&h, 28) as u64;
|
||||
let volume_header_offset = le64(&h, 56);
|
||||
let meta_size = le32(&h, 64) as usize;
|
||||
if !(48..=1 << 20).contains(&meta_size) {
|
||||
return None;
|
||||
}
|
||||
let method = le16(&h, 64 + 36);
|
||||
let all = read_vec(src, offset + 64, meta_size).ok()?;
|
||||
if all.len() < meta_size {
|
||||
return None;
|
||||
}
|
||||
let entries = parse_entries(&all[48..]);
|
||||
let mut vmks = vec![];
|
||||
let mut fvek = None;
|
||||
let (mut vh_off, mut vh_size) = (volume_header_offset, hdr_sectors * 512);
|
||||
for e in entries {
|
||||
match (e.etype, e.vtype) {
|
||||
(15, 15) if e.data.len() >= 16 => {
|
||||
vh_off = le64(&e.data, 0);
|
||||
vh_size = le64(&e.data, 8);
|
||||
}
|
||||
(2, 8) if e.data.len() > 28 => vmks.push(Vmk {
|
||||
protection: le16(&e.data, 26),
|
||||
nested: parse_entries(&e.data[28..]),
|
||||
}),
|
||||
(3, 5) => fvek = Some(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Fve {
|
||||
method,
|
||||
volume_header_offset: vh_off,
|
||||
volume_header_size: vh_size,
|
||||
encrypted_size,
|
||||
meta_size: 0x10000,
|
||||
vmks,
|
||||
fvek,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extracts the key from a .BEK startup key file.
|
||||
fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
|
||||
if file.len() < 48 {
|
||||
return None;
|
||||
}
|
||||
let es = parse_entries(&file[48..]);
|
||||
for e in &es {
|
||||
if e.vtype == 9 && e.data.len() > 24 {
|
||||
for n in parse_entries(&e.data[24..]) {
|
||||
if n.vtype == 1 && n.data.len() >= 36 {
|
||||
return Some(n.data[4..36].to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> {
|
||||
let mut out: Vec<Vec<u8>> = vec![];
|
||||
// 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 {
|
||||
out.push(n.data[4..36].to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in secrets {
|
||||
match s {
|
||||
Secret::Password(p) if v.protection == 0x2000 => {
|
||||
if let Some(salt) = v.stretch_salt() {
|
||||
out.push(password_key(p, salt).to_vec());
|
||||
}
|
||||
}
|
||||
Secret::RecoveryKey(r) if v.protection == 0x0800 => {
|
||||
if let Some(k) = v.stretch_salt().and_then(|salt| recovery_key(r, salt)) {
|
||||
out.push(k.to_vec());
|
||||
}
|
||||
}
|
||||
Secret::KeyFile(f) if v.protection == 0x0200 => {
|
||||
if let Some(k) = bek_key(f) {
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decrypt_key_entry(key: &[u8], e: &Entry) -> Option<Vec<u8>> {
|
||||
let aes = AesAny::new(key)?;
|
||||
if e.data.len() < 12 + 16 + 8 {
|
||||
return None;
|
||||
}
|
||||
let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?;
|
||||
// payload = entry header (8) + method (4) + key
|
||||
(pt.len() > 12).then(|| pt[12..].to_vec())
|
||||
}
|
||||
|
||||
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
let vbr = read_vec(src.as_ref(), 0, 512).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
|
||||
if !detect(&vbr) || vbr.len() < 512 {
|
||||
return Err(UnlockError::Corrupt("não é BitLocker".into()));
|
||||
}
|
||||
let bps = le16(&vbr, 11).max(512) as u64;
|
||||
let pos = offsets_pos(&vbr).unwrap_or(176);
|
||||
// Three metadata copies exist; use the first valid one.
|
||||
let mut fve = None;
|
||||
let mut meta_offsets = vec![];
|
||||
for i in 0..3 {
|
||||
let off = le64(&vbr, pos + i * 8);
|
||||
if off != 0 {
|
||||
meta_offsets.push(off);
|
||||
}
|
||||
if off != 0 && off < src.size() {
|
||||
if let Some(f) = read_fve(src.as_ref(), off) {
|
||||
fve = Some(f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fve = fve.ok_or_else(|| {
|
||||
UnlockError::Unsupported("metadados FVE ilegíveis ou do Windows Vista".into())
|
||||
})?;
|
||||
let fvek_entry = fve
|
||||
.fvek
|
||||
.as_ref()
|
||||
.ok_or_else(|| UnlockError::Corrupt("sem FVEK".into()))?;
|
||||
|
||||
let mut volume_key: Option<Vec<u8>> = None;
|
||||
for s in secrets {
|
||||
if let Secret::VolumeKey(k) = s {
|
||||
volume_key = Some(k.clone());
|
||||
}
|
||||
}
|
||||
let mut vmk_found: Option<Vec<u8>> = None;
|
||||
if volume_key.is_none() {
|
||||
'outer: for v in &fve.vmks {
|
||||
let Some(ccm) = v.ccm() else { continue };
|
||||
for k in candidate_keys(v, secrets) {
|
||||
if let Some(vmk) = decrypt_key_entry(&k, ccm) {
|
||||
if vmk.len() >= 32 {
|
||||
vmk_found = Some(vmk[..32].to_vec());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let fvek_bytes = if let Some(k) = volume_key {
|
||||
k
|
||||
} else {
|
||||
let vmk = vmk_found.ok_or(UnlockError::WrongSecret)?;
|
||||
decrypt_key_entry(&vmk, fvek_entry)
|
||||
.ok_or_else(|| UnlockError::Corrupt("FVEK não decifra com a VMK".into()))?
|
||||
};
|
||||
let k = &fvek_bytes;
|
||||
let bad = || UnlockError::Corrupt("tamanho de chave incompatível com o método".into());
|
||||
let (cipher, name) = match fve.method {
|
||||
0x8000 | 0x8001 => {
|
||||
let n = if fve.method == 0x8000 { 16 } else { 32 };
|
||||
if k.len() < 32 + n {
|
||||
return Err(bad());
|
||||
}
|
||||
(
|
||||
SectorCipher::Elephant {
|
||||
key: AesAny::new(&k[..n]).ok_or_else(bad)?,
|
||||
tweak: AesAny::new(&k[32..32 + n]).ok_or_else(bad)?,
|
||||
},
|
||||
if n == 16 {
|
||||
"AES-128-CBC + difusor"
|
||||
} else {
|
||||
"AES-256-CBC + difusor"
|
||||
},
|
||||
)
|
||||
}
|
||||
0x8002 | 0x8003 => {
|
||||
let n = if fve.method == 0x8002 { 16 } else { 32 };
|
||||
if k.len() < n {
|
||||
return Err(bad());
|
||||
}
|
||||
let key = AesAny::new(&k[..n]).ok_or_else(bad)?;
|
||||
(
|
||||
SectorCipher::Cbc {
|
||||
key: key.clone(),
|
||||
iv: Iv::Eboiv(key),
|
||||
},
|
||||
if n == 16 {
|
||||
"AES-128-CBC"
|
||||
} else {
|
||||
"AES-256-CBC"
|
||||
},
|
||||
)
|
||||
}
|
||||
0x8004 | 0x8005 => {
|
||||
let n = if fve.method == 0x8004 { 16 } else { 32 };
|
||||
if k.len() < 2 * n {
|
||||
return Err(bad());
|
||||
}
|
||||
let iv = if bps > 512 {
|
||||
Iv::Unit(bps)
|
||||
} else {
|
||||
Iv::Plain64
|
||||
};
|
||||
(
|
||||
SectorCipher::Xts {
|
||||
k1: AesAny::new(&k[..n]).ok_or_else(bad)?,
|
||||
k2: AesAny::new(&k[n..2 * n]).ok_or_else(bad)?,
|
||||
iv,
|
||||
},
|
||||
if n == 16 {
|
||||
"AES-128-XTS"
|
||||
} else {
|
||||
"AES-256-XTS"
|
||||
},
|
||||
)
|
||||
}
|
||||
m => {
|
||||
return Err(UnlockError::Unsupported(format!(
|
||||
"método de cifragem BitLocker {m:#x}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let len = src.size();
|
||||
let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps);
|
||||
let mut patches = vec![];
|
||||
// FVE metadata regions: Windows masks them out as zeros.
|
||||
for off in meta_offsets {
|
||||
patches.push(Patch {
|
||||
at: off,
|
||||
len: fve.meta_size,
|
||||
from: 0,
|
||||
decrypt_from: None,
|
||||
zero: true,
|
||||
});
|
||||
}
|
||||
if fve.volume_header_size > 0 && fve.volume_header_offset > 0 {
|
||||
// The original volume header was relocated and encrypted elsewhere.
|
||||
patches.push(Patch {
|
||||
at: fve.volume_header_offset,
|
||||
len: fve.volume_header_size,
|
||||
from: 0,
|
||||
decrypt_from: None,
|
||||
zero: true,
|
||||
});
|
||||
patches.push(Patch {
|
||||
at: 0,
|
||||
len: fve.volume_header_size,
|
||||
from: fve.volume_header_offset,
|
||||
decrypt_from: Some(fve.volume_header_offset),
|
||||
zero: false,
|
||||
});
|
||||
}
|
||||
cs = cs.with_patches(patches);
|
||||
if fve.encrypted_size > 0 && fve.encrypted_size < len {
|
||||
cs = cs.with_plain_from(fve.encrypted_size);
|
||||
}
|
||||
Ok(Unlocked {
|
||||
source: Arc::new(cs),
|
||||
description: format!("BitLocker ({name})"),
|
||||
volume_key: Some(fvek_bytes),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unlock_window(
|
||||
base: Arc<dyn Source>,
|
||||
start: u64,
|
||||
len: u64,
|
||||
secrets: &[Secret],
|
||||
) -> UnlockResult {
|
||||
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! 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};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const MD_BLOCK: usize = 8192;
|
||||
|
||||
fn le32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
fn le64(b: &[u8], o: usize) -> u64 {
|
||||
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
pub fn detect(head: &[u8]) -> bool {
|
||||
head.len() >= 512 && head[88] == 0x43 && head[89] == 0x53 && head[10] == 0x10
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let mut a: [u8; 8] = wrapped[..8].try_into().unwrap();
|
||||
let mut r2: [u8; 8] = wrapped[8..16].try_into().unwrap();
|
||||
let mut r3: [u8; 8] = wrapped[16..24].try_into().unwrap();
|
||||
for t in (1..=12u64).rev() {
|
||||
let prev_r2 = r2;
|
||||
let tb = t.to_be_bytes();
|
||||
let mut blk = [0u8; 16];
|
||||
for i in 0..8 {
|
||||
blk[i] = a[i] ^ tb[i];
|
||||
}
|
||||
blk[8..].copy_from_slice(&r3);
|
||||
aes.decrypt(&mut blk);
|
||||
a.copy_from_slice(&blk[..8]);
|
||||
r2.copy_from_slice(&blk[8..]);
|
||||
r3 = prev_r2;
|
||||
}
|
||||
if a != [0xA6; 8] {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out[..8].copy_from_slice(&r2);
|
||||
out[8..].copy_from_slice(&r3);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn b64(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let (mut acc, mut bits) = (0u32, 0);
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
while let Some(p) = xml[from..].find(&k) {
|
||||
let rest = &xml[from + p + k.len()..];
|
||||
from += p + k.len();
|
||||
let Some(open) = rest.find('<').filter(|&o| o == 0) else {
|
||||
continue;
|
||||
};
|
||||
let Some(gt) = rest[open..].find('>') else {
|
||||
continue;
|
||||
};
|
||||
if rest[open..open + gt].ends_with('/') {
|
||||
continue; // <reference .../>
|
||||
}
|
||||
let Some(te) = rest[open + 1..].find([' ', '>']) else {
|
||||
continue;
|
||||
};
|
||||
let tag = &rest[open + 1..open + 1 + te];
|
||||
let vstart = open + gt + 1;
|
||||
if let Some(e) = rest[vstart..].find(&format!("</{tag}>")) {
|
||||
let v = rest[vstart..vstart + e].trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn xml_of(blk: &[u8], off_pos: usize) -> Option<String> {
|
||||
let off = le32(blk, off_pos) as usize;
|
||||
let size = le32(blk, off_pos + 4) as usize;
|
||||
if off >= MD_BLOCK || size > MD_BLOCK - off {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&blk[off..off + size]).to_string())
|
||||
}
|
||||
|
||||
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
let corrupt = |s: &str| UnlockError::Corrupt(s.to_string());
|
||||
let h = read_vec(src.as_ref(), 0, 512).map_err(|e| corrupt(&e.to_string()))?;
|
||||
if !detect(&h) {
|
||||
return Err(corrupt("não é um volume Core Storage"));
|
||||
}
|
||||
let block_size = le32(&h, 96) as u64;
|
||||
let disklbl = le64(&h, 104);
|
||||
if block_size == 0 || block_size > 1 << 20 || le32(&h, 168) != 16 {
|
||||
return Err(UnlockError::Unsupported(
|
||||
"Core Storage com chave de metadados que não é AES-128".into(),
|
||||
));
|
||||
}
|
||||
let mut md_key = [0u8; 32];
|
||||
md_key[..16].copy_from_slice(&h[176..192]);
|
||||
md_key[16..].copy_from_slice(&h[304..320]);
|
||||
let (k1, k2) = (
|
||||
AesAny::new(&md_key[..16]).unwrap(),
|
||||
AesAny::new(&md_key[16..]).unwrap(),
|
||||
);
|
||||
let md_cipher = SectorCipher::Xts {
|
||||
k1,
|
||||
k2,
|
||||
iv: Iv::Plain64,
|
||||
};
|
||||
|
||||
let label = read_vec(src.as_ref(), disklbl * block_size, MD_BLOCK)
|
||||
.map_err(|e| corrupt(&e.to_string()))?;
|
||||
if label.len() < MD_BLOCK {
|
||||
return Err(corrupt("rótulo do disco truncado"));
|
||||
}
|
||||
let vgd_off = le32(&label, 220) as u64;
|
||||
let vgd = read_vec(src.as_ref(), disklbl * block_size + vgd_off, 40)
|
||||
.map_err(|e| corrupt(&e.to_string()))?;
|
||||
if vgd.len() < 40 {
|
||||
return Err(corrupt("descritor de grupos truncado"));
|
||||
}
|
||||
let md_blocks = le64(&vgd, 8);
|
||||
let md_start = le64(&vgd, 32) * block_size;
|
||||
|
||||
let (mut iters, mut salt, mut wkek, mut wvk) = (0u32, vec![], vec![], vec![]);
|
||||
let (mut lv_size, mut lv_off) = (0u64, 0u64);
|
||||
let mut family = [0u8; 16];
|
||||
let mut got = 0u8;
|
||||
for i in 0..md_blocks.min(64) {
|
||||
let mut b = read_vec(src.as_ref(), md_start + i * MD_BLOCK as u64, MD_BLOCK)
|
||||
.map_err(|e| corrupt(&e.to_string()))?;
|
||||
if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) {
|
||||
break;
|
||||
}
|
||||
// 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 => {
|
||||
if let Some(xml) = xml_of(&b, 112) {
|
||||
let pwk = plist_value(&xml, "PassphraseWrappedKEKStruct").and_then(|s| b64(&s));
|
||||
let kw = plist_value(&xml, "KEKWrappedVolumeKeyStruct").and_then(|s| b64(&s));
|
||||
if let (Some(p), Some(k)) = (pwk, kw) {
|
||||
if p.len() >= 172 && k.len() >= 32 {
|
||||
salt = p[8..24].to_vec();
|
||||
wkek = p[32..56].to_vec();
|
||||
iters = le32(&p, 168);
|
||||
wvk = k[8..32].to_vec();
|
||||
got |= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0x001a => {
|
||||
if let Some(xml) = xml_of(&b, 128) {
|
||||
if let Some(v) = plist_value(&xml, "com.apple.corestorage.lv.size") {
|
||||
lv_size = u64::from_str_radix(v.trim_start_matches("0x"), 16).unwrap_or(0);
|
||||
got |= 8;
|
||||
if let Some(u) = plist_value(&xml, "com.apple.corestorage.lv.familyUUID") {
|
||||
let hex: String = u.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
if let Some(b) = super::from_hex(&hex).filter(|b| b.len() == 16) {
|
||||
family.copy_from_slice(&b);
|
||||
got |= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0x0305 => {
|
||||
lv_off = le32(&b, 104) as u64 * block_size;
|
||||
got |= 4;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if got != 7 | 8 {
|
||||
return Err(corrupt("metadados Core Storage incompletos"));
|
||||
}
|
||||
|
||||
let finish = |vk16: [u8; 16]| -> UnlockResult {
|
||||
let mut full = [0u8; 32];
|
||||
full[..16].copy_from_slice(&vk16);
|
||||
full[16..].copy_from_slice(
|
||||
&Sha256::new()
|
||||
.chain_update(vk16)
|
||||
.chain_update(family)
|
||||
.finalize()[..16],
|
||||
);
|
||||
let cipher = SectorCipher::Xts {
|
||||
k1: AesAny::new(&full[..16]).unwrap(),
|
||||
k2: AesAny::new(&full[16..]).unwrap(),
|
||||
iv: Iv::Plain64,
|
||||
};
|
||||
let cs = CryptSource::new(src.clone(), cipher, lv_off, lv_size, 512);
|
||||
Ok(Unlocked {
|
||||
source: Arc::new(cs),
|
||||
description: "FileVault 2 (Core Storage, AES-128-XTS)".into(),
|
||||
volume_key: Some(vk16.to_vec()),
|
||||
})
|
||||
};
|
||||
for s in secrets {
|
||||
if let Secret::VolumeKey(k) = s {
|
||||
if k.len() >= 16 {
|
||||
let mut a = [0u8; 16];
|
||||
a.copy_from_slice(&k[..16]);
|
||||
return finish(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in secrets {
|
||||
if let Secret::Password(p) = s {
|
||||
let mut pk = [0u8; 16];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(p.as_bytes(), &salt, iters.max(1), &mut pk);
|
||||
if let Some(kek) = unwrap_key(&pk, &wkek) {
|
||||
if let Some(vk) = unwrap_key(&kek, &wvk) {
|
||||
return finish(vk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(UnlockError::WrongSecret)
|
||||
}
|
||||
|
||||
pub fn unlock_window(
|
||||
base: Arc<dyn Source>,
|
||||
start: u64,
|
||||
len: u64,
|
||||
secrets: &[Secret],
|
||||
) -> UnlockResult {
|
||||
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
//! LUKS1 and LUKS2 (AES ciphers; PBKDF2 and Argon2; SHA-1/256/512 and RIPEMD-160 hashes).
|
||||
|
||||
use super::*;
|
||||
use crate::source::{read_vec, SubSource};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
pub const MAGIC: &[u8; 6] = b"LUKS\xBA\xBE";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hash {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
Ripemd160,
|
||||
}
|
||||
|
||||
impl Hash {
|
||||
pub fn parse(s: &str) -> Option<Hash> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"sha1" => Hash::Sha1,
|
||||
"sha256" => Hash::Sha256,
|
||||
"sha512" => Hash::Sha512,
|
||||
"ripemd160" | "rmd160" => Hash::Ripemd160,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn size(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha1 | Hash::Ripemd160 => 20,
|
||||
Hash::Sha256 => 32,
|
||||
Hash::Sha512 => 64,
|
||||
}
|
||||
}
|
||||
pub fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
|
||||
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
for p in parts {
|
||||
d.update(p);
|
||||
}
|
||||
d.finalize().to_vec()
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => run::<Sha1>(parts),
|
||||
Hash::Sha256 => run::<Sha256>(parts),
|
||||
Hash::Sha512 => run::<Sha512>(parts),
|
||||
Hash::Ripemd160 => run::<ripemd::Ripemd160>(parts),
|
||||
}
|
||||
}
|
||||
pub fn pbkdf2(self, pass: &[u8], salt: &[u8], iterations: u32, len: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; len];
|
||||
let it = iterations.max(1);
|
||||
match self {
|
||||
Hash::Sha1 => pbkdf2::pbkdf2_hmac::<Sha1>(pass, salt, it, &mut out),
|
||||
Hash::Sha256 => pbkdf2::pbkdf2_hmac::<Sha256>(pass, salt, it, &mut out),
|
||||
Hash::Sha512 => pbkdf2::pbkdf2_hmac::<Sha512>(pass, salt, it, &mut out),
|
||||
Hash::Ripemd160 => pbkdf2::pbkdf2_hmac::<ripemd::Ripemd160>(pass, salt, it, &mut out),
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Anti-forensic splitter diffusion step.
|
||||
fn diffuse(block: &mut [u8], h: Hash) {
|
||||
let ds = h.size();
|
||||
let mut i = 0u32;
|
||||
let mut off = 0;
|
||||
while off < block.len() {
|
||||
let n = ds.min(block.len() - off);
|
||||
let d = h.digest(&[&i.to_be_bytes(), &block[off..off + n]]);
|
||||
block[off..off + n].copy_from_slice(&d[..n]);
|
||||
off += n;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
for (b, s) in buf.iter_mut().zip(&src[i * block..(i + 1) * block]) {
|
||||
*b ^= *s;
|
||||
}
|
||||
diffuse(&mut buf, h);
|
||||
}
|
||||
let last = &src[(stripes - 1) * block..stripes * block];
|
||||
buf.iter().zip(last).map(|(a, b)| a ^ b).collect()
|
||||
}
|
||||
|
||||
/// 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('-')
|
||||
.ok_or_else(|| UnlockError::Unsupported(spec.into()))?;
|
||||
if !alg.eq_ignore_ascii_case("aes") {
|
||||
return Err(UnlockError::Unsupported(format!(
|
||||
"cifra {alg} (apenas AES)"
|
||||
)));
|
||||
}
|
||||
let (mode, ivs) = rest.split_once('-').unwrap_or((rest, "plain"));
|
||||
let bad = || UnlockError::Unsupported(format!("chave inválida para {spec}"));
|
||||
let mk_iv = |k: &[u8]| -> std::result::Result<Iv, UnlockError> {
|
||||
let ivl = ivs.to_ascii_lowercase();
|
||||
Ok(match ivl.as_str() {
|
||||
"plain64" => Iv::Plain64,
|
||||
"plain" | "plain32" => Iv::Plain32,
|
||||
"plain64be" => Iv::Plain64Be,
|
||||
s if s.starts_with("essiv:") => {
|
||||
let h =
|
||||
Hash::parse(&s[6..]).ok_or_else(|| UnlockError::Unsupported(spec.into()))?;
|
||||
Iv::Essiv(AesAny::new(&h.digest(&[k])).ok_or_else(bad)?)
|
||||
}
|
||||
_ => return Err(UnlockError::Unsupported(format!("IV {ivs}"))),
|
||||
})
|
||||
};
|
||||
match mode.to_ascii_lowercase().as_str() {
|
||||
"xts" => {
|
||||
if key.len() % 2 != 0 {
|
||||
return Err(bad());
|
||||
}
|
||||
let h = key.len() / 2;
|
||||
Ok(SectorCipher::Xts {
|
||||
k1: AesAny::new(&key[..h]).ok_or_else(bad)?,
|
||||
k2: AesAny::new(&key[h..]).ok_or_else(bad)?,
|
||||
iv: mk_iv(&key[..h])?,
|
||||
})
|
||||
}
|
||||
"cbc" => Ok(SectorCipher::Cbc {
|
||||
key: AesAny::new(key).ok_or_else(bad)?,
|
||||
iv: mk_iv(key)?,
|
||||
}),
|
||||
m => Err(UnlockError::Unsupported(format!("modo {m}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn passwords(secrets: &[Secret]) -> Vec<Vec<u8>> {
|
||||
secrets
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Secret::Password(p) => Some(p.as_bytes().to_vec()),
|
||||
Secret::KeyFile(k) => Some(k.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn volume_keys(secrets: &[Secret]) -> Vec<Vec<u8>> {
|
||||
secrets
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Secret::VolumeKey(k) => Some(k.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn detect(head: &[u8]) -> bool {
|
||||
head.len() >= 8 && &head[..6] == MAGIC
|
||||
}
|
||||
|
||||
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
let head = read_vec(src.as_ref(), 0, 8).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
|
||||
if !detect(&head) {
|
||||
return Err(UnlockError::Corrupt("não é LUKS".into()));
|
||||
}
|
||||
match u16::from_be_bytes([head[6], head[7]]) {
|
||||
1 => unlock_v1(src, secrets),
|
||||
2 => unlock_v2(src, secrets),
|
||||
v => Err(UnlockError::Unsupported(format!("LUKS versão {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn cstr(b: &[u8]) -> String {
|
||||
let n = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||
String::from_utf8_lossy(&b[..n]).to_string()
|
||||
}
|
||||
|
||||
fn unlock_v1(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
let h = read_vec(src.as_ref(), 0, 592).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
|
||||
if h.len() < 592 {
|
||||
return Err(UnlockError::Corrupt("cabeçalho LUKS1 truncado".into()));
|
||||
}
|
||||
let cipher_name = cstr(&h[8..40]);
|
||||
let cipher_mode = cstr(&h[40..72]);
|
||||
let hash = Hash::parse(&cstr(&h[72..104]))
|
||||
.ok_or_else(|| UnlockError::Unsupported(format!("hash {}", cstr(&h[72..104]))))?;
|
||||
let payload = be32(&h, 104) as u64 * 512;
|
||||
let key_bytes = be32(&h, 108) as usize;
|
||||
let mk_digest = &h[112..132];
|
||||
let mk_salt = &h[132..164];
|
||||
let mk_iter = be32(&h, 164);
|
||||
let spec = format!("{cipher_name}-{cipher_mode}");
|
||||
if key_bytes == 0 || key_bytes > 128 {
|
||||
return Err(UnlockError::Corrupt("tamanho de chave inválido".into()));
|
||||
}
|
||||
let check = |mk: &[u8]| hash.pbkdf2(mk, mk_salt, mk_iter, 20) == mk_digest;
|
||||
let finish = |mk: Vec<u8>| -> UnlockResult {
|
||||
let cipher = parse_cipher(&spec, &mk)?;
|
||||
let len = src.size().saturating_sub(payload);
|
||||
let cs = CryptSource::new(src.clone(), cipher, payload, len, 512);
|
||||
Ok(Unlocked {
|
||||
source: Arc::new(cs),
|
||||
description: format!("LUKS1 ({spec})"),
|
||||
volume_key: Some(mk),
|
||||
})
|
||||
};
|
||||
for vk in volume_keys(secrets) {
|
||||
if vk.len() == key_bytes && check(&vk) {
|
||||
return finish(vk);
|
||||
}
|
||||
}
|
||||
let pws = passwords(secrets);
|
||||
for slot in 0..8 {
|
||||
let o = 208 + slot * 48;
|
||||
if be32(&h, o) != 0x00AC_71F3 {
|
||||
continue;
|
||||
}
|
||||
let iter = be32(&h, o + 4);
|
||||
let salt = &h[o + 8..o + 40];
|
||||
let km_off = be32(&h, o + 40) as u64 * 512;
|
||||
let stripes = be32(&h, o + 44) as usize;
|
||||
if stripes == 0 || stripes > 100_000 {
|
||||
continue;
|
||||
}
|
||||
let km_len = (key_bytes * stripes).div_ceil(512) * 512;
|
||||
let Ok(mut km) = read_vec(src.as_ref(), km_off, km_len) else {
|
||||
continue;
|
||||
};
|
||||
if km.len() < km_len {
|
||||
continue;
|
||||
}
|
||||
for pw in &pws {
|
||||
let dk = hash.pbkdf2(pw, salt, iter, key_bytes);
|
||||
let cipher = parse_cipher(&spec, &dk)?;
|
||||
let mut m = km.clone();
|
||||
for (i, s) in m.chunks_exact_mut(512).enumerate() {
|
||||
cipher.decrypt_sector(s, i as u64, i as u64 * 512);
|
||||
}
|
||||
let mk = af_merge(&m, key_bytes, stripes, hash);
|
||||
if check(&mk) {
|
||||
return finish(mk);
|
||||
}
|
||||
}
|
||||
km.clear();
|
||||
}
|
||||
Err(UnlockError::WrongSecret)
|
||||
}
|
||||
|
||||
fn b64(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let mut acc = 0u32;
|
||||
let mut bits = 0;
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' | b'\n' | b'\r' => continue,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> {
|
||||
v.get(k)?.as_str()
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
fn unlock_v2(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
|
||||
let h = read_vec(src.as_ref(), 0, 4096).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
|
||||
if h.len() < 4096 {
|
||||
return Err(UnlockError::Corrupt("cabeçalho LUKS2 truncado".into()));
|
||||
}
|
||||
let hdr_size = u64::from_be_bytes(h[8..16].try_into().unwrap());
|
||||
if !(16384..=(4 << 20)).contains(&hdr_size) {
|
||||
return Err(UnlockError::Corrupt(
|
||||
"tamanho de cabeçalho LUKS2 inválido".into(),
|
||||
));
|
||||
}
|
||||
let area = read_vec(src.as_ref(), 4096, (hdr_size - 4096) as usize)
|
||||
.map_err(|e| UnlockError::Corrupt(e.to_string()))?;
|
||||
let end = area.iter().position(|&c| c == 0).unwrap_or(area.len());
|
||||
let json: serde_json::Value = serde_json::from_slice(&area[..end])
|
||||
.map_err(|e| UnlockError::Corrupt(format!("JSON: {e}")))?;
|
||||
let segments = json
|
||||
.get("segments")
|
||||
.and_then(|s| s.as_object())
|
||||
.ok_or_else(|| UnlockError::Corrupt("sem segments".into()))?;
|
||||
let (_, seg) = segments
|
||||
.iter()
|
||||
.find(|(_, s)| jstr(s, "type") == Some("crypt"))
|
||||
.ok_or_else(|| UnlockError::Unsupported("sem segmento cifrado".into()))?;
|
||||
let seg_off =
|
||||
jnum(seg, "offset").ok_or_else(|| UnlockError::Corrupt("offset do segmento".into()))?;
|
||||
let seg_len = match seg.get("size").and_then(|s| s.as_str()) {
|
||||
Some("dynamic") | None => src.size().saturating_sub(seg_off),
|
||||
Some(n) => n.parse().unwrap_or(0),
|
||||
};
|
||||
let seg_enc = jstr(seg, "encryption")
|
||||
.unwrap_or("aes-xts-plain64")
|
||||
.to_string();
|
||||
let sector = jnum(seg, "sector_size").unwrap_or(512);
|
||||
let iv_tweak = jnum(seg, "iv_tweak").unwrap_or(0);
|
||||
let digests = json
|
||||
.get("digests")
|
||||
.and_then(|d| d.as_object())
|
||||
.ok_or_else(|| UnlockError::Corrupt("sem digests".into()))?;
|
||||
|
||||
let check = |vk: &[u8]| -> bool {
|
||||
for (_, d) in digests {
|
||||
if jstr(d, "type") != Some("pbkdf2") {
|
||||
continue;
|
||||
}
|
||||
let (Some(hash), Some(salt), Some(want), Some(it)) = (
|
||||
jstr(d, "hash").and_then(Hash::parse),
|
||||
jstr(d, "salt").and_then(b64),
|
||||
jstr(d, "digest").and_then(b64),
|
||||
jnum(d, "iterations"),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if hash.pbkdf2(vk, &salt, it as u32, want.len()) == want {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
let finish = |vk: Vec<u8>| -> UnlockResult {
|
||||
let cipher = parse_cipher(&seg_enc, &vk)?;
|
||||
let cs =
|
||||
CryptSource::new(src.clone(), cipher, seg_off, seg_len, sector).with_iv_tweak(iv_tweak);
|
||||
Ok(Unlocked {
|
||||
source: Arc::new(cs),
|
||||
description: format!("LUKS2 ({seg_enc})"),
|
||||
volume_key: Some(vk),
|
||||
})
|
||||
};
|
||||
for vk in volume_keys(secrets) {
|
||||
if check(&vk) {
|
||||
return finish(vk);
|
||||
}
|
||||
}
|
||||
let pws = passwords(secrets);
|
||||
let slots = json
|
||||
.get("keyslots")
|
||||
.and_then(|d| d.as_object())
|
||||
.ok_or_else(|| UnlockError::Corrupt("sem keyslots".into()))?;
|
||||
let mut unsupported: Option<String> = None;
|
||||
for (_, slot) in slots {
|
||||
if jstr(slot, "type") != Some("luks2") {
|
||||
continue;
|
||||
}
|
||||
let key_size = jnum(slot, "key_size").unwrap_or(0) as usize;
|
||||
let af = slot.get("af");
|
||||
let stripes = af.and_then(|a| jnum(a, "stripes")).unwrap_or(4000) as usize;
|
||||
let af_hash = af
|
||||
.and_then(|a| jstr(a, "hash"))
|
||||
.and_then(Hash::parse)
|
||||
.unwrap_or(Hash::Sha256);
|
||||
let ar = slot.get("area");
|
||||
let (Some(a_off), Some(a_size)) = (
|
||||
ar.and_then(|a| jnum(a, "offset")),
|
||||
ar.and_then(|a| jnum(a, "size")),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let a_enc = ar
|
||||
.and_then(|a| jstr(a, "encryption"))
|
||||
.unwrap_or("aes-xts-plain64");
|
||||
let a_ksize = ar.and_then(|a| jnum(a, "key_size")).unwrap_or(64) as usize;
|
||||
let kdf = slot.get("kdf");
|
||||
let Some(kdf) = kdf else { continue };
|
||||
let salt = jstr(kdf, "salt").and_then(b64).unwrap_or_default();
|
||||
let km_len = (key_size * stripes).div_ceil(512) * 512;
|
||||
if key_size == 0 || stripes == 0 || km_len as u64 > a_size {
|
||||
continue;
|
||||
}
|
||||
let Ok(km) = read_vec(src.as_ref(), a_off, km_len) else {
|
||||
continue;
|
||||
};
|
||||
if km.len() < km_len {
|
||||
continue;
|
||||
}
|
||||
for pw in &pws {
|
||||
let dk: Vec<u8> = match jstr(kdf, "type") {
|
||||
Some("pbkdf2") => {
|
||||
let hh = jstr(kdf, "hash")
|
||||
.and_then(Hash::parse)
|
||||
.unwrap_or(Hash::Sha256);
|
||||
hh.pbkdf2(
|
||||
pw,
|
||||
&salt,
|
||||
jnum(kdf, "iterations").unwrap_or(1000) as u32,
|
||||
a_ksize,
|
||||
)
|
||||
}
|
||||
Some(t @ ("argon2i" | "argon2id")) => {
|
||||
let alg = if t == "argon2i" {
|
||||
argon2::Algorithm::Argon2i
|
||||
} else {
|
||||
argon2::Algorithm::Argon2id
|
||||
};
|
||||
let params = match argon2::Params::new(
|
||||
jnum(kdf, "memory").unwrap_or(65536) as u32,
|
||||
jnum(kdf, "time").unwrap_or(4) as u32,
|
||||
jnum(kdf, "cpus").unwrap_or(1) as u32,
|
||||
Some(a_ksize),
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
unsupported = Some(format!("parâmetros Argon2: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut out = vec![0u8; a_ksize];
|
||||
if argon2::Argon2::new(alg, argon2::Version::V0x13, params)
|
||||
.hash_password_into(pw, &salt, &mut out)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out
|
||||
}
|
||||
Some(t) => {
|
||||
unsupported = Some(format!("KDF {t}"));
|
||||
continue;
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
let cipher = parse_cipher(a_enc, &dk)?;
|
||||
let mut m = km.clone();
|
||||
for (i, s) in m.chunks_exact_mut(512).enumerate() {
|
||||
cipher.decrypt_sector(s, i as u64, i as u64 * 512);
|
||||
}
|
||||
let vk = af_merge(&m, key_size, stripes, af_hash);
|
||||
if check(&vk) {
|
||||
return finish(vk);
|
||||
}
|
||||
}
|
||||
}
|
||||
match unsupported {
|
||||
Some(u) => Err(UnlockError::Unsupported(u)),
|
||||
None => Err(UnlockError::WrongSecret),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: opens a byte window of the source as a LUKS volume.
|
||||
pub fn unlock_window(
|
||||
base: Arc<dyn Source>,
|
||||
start: u64,
|
||||
len: u64,
|
||||
secrets: &[Secret],
|
||||
) -> UnlockResult {
|
||||
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//! 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.
|
||||
//!
|
||||
|
||||
pub mod bitlocker;
|
||||
pub mod filevault;
|
||||
pub mod luks;
|
||||
|
||||
use crate::source::Source;
|
||||
use aes::cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
use aes::{Aes128, Aes192, Aes256};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// AES cipher with runtime key size selection.
|
||||
#[derive(Clone)]
|
||||
pub enum AesAny {
|
||||
A128(Aes128),
|
||||
A192(Aes192),
|
||||
A256(Aes256),
|
||||
}
|
||||
|
||||
impl AesAny {
|
||||
pub fn new(key: &[u8]) -> Option<Self> {
|
||||
Some(match key.len() {
|
||||
16 => AesAny::A128(Aes128::new_from_slice(key).ok()?),
|
||||
24 => AesAny::A192(Aes192::new_from_slice(key).ok()?),
|
||||
32 => AesAny::A256(Aes256::new_from_slice(key).ok()?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn encrypt(&self, b: &mut [u8; 16]) {
|
||||
let g: &mut Block<Aes128> = (&mut b[..]).try_into().expect("bloco AES de 16 bytes");
|
||||
match self {
|
||||
AesAny::A128(c) => c.encrypt_block(g),
|
||||
AesAny::A192(c) => c.encrypt_block(g),
|
||||
AesAny::A256(c) => c.encrypt_block(g),
|
||||
}
|
||||
}
|
||||
pub fn decrypt(&self, b: &mut [u8; 16]) {
|
||||
let g: &mut Block<Aes128> = (&mut b[..]).try_into().expect("bloco AES de 16 bytes");
|
||||
match self {
|
||||
AesAny::A128(c) => c.decrypt_block(g),
|
||||
AesAny::A192(c) => c.decrypt_block(g),
|
||||
AesAny::A256(c) => c.decrypt_block(g),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn xor16(a: &mut [u8; 16], b: &[u8]) {
|
||||
for i in 0..16 {
|
||||
a[i] ^= b[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// Sector initialization vector (IV) generation mode.
|
||||
#[derive(Clone)]
|
||||
pub enum Iv {
|
||||
/// 512-byte sector number as 64-bit little-endian.
|
||||
Plain64,
|
||||
/// Sector number as 32-bit little-endian.
|
||||
Plain32,
|
||||
Plain64Be,
|
||||
/// ESSIV: AES(SHA-256(key), sector).
|
||||
Essiv(AesAny),
|
||||
/// Data unit number (`usize`-byte sector) as 64-bit little-endian.
|
||||
Unit(u64),
|
||||
/// BitLocker (Windows 10 1511+): AES(key, byte offset).
|
||||
Eboiv(AesAny),
|
||||
/// BitLocker CBC with Elephant diffuser: IV = AES(key, byte offset).
|
||||
Bitlocker(AesAny),
|
||||
}
|
||||
|
||||
impl Iv {
|
||||
fn make(&self, sector_512: u64, byte_off: u64) -> [u8; 16] {
|
||||
let mut iv = [0u8; 16];
|
||||
match self {
|
||||
Iv::Plain64 => iv[..8].copy_from_slice(§or_512.to_le_bytes()),
|
||||
Iv::Plain32 => iv[..4].copy_from_slice(&(sector_512 as u32).to_le_bytes()),
|
||||
Iv::Plain64Be => iv[8..].copy_from_slice(§or_512.to_be_bytes()),
|
||||
Iv::Unit(u) => iv[..8].copy_from_slice(&(byte_off / u).to_le_bytes()),
|
||||
Iv::Essiv(k) => {
|
||||
iv[..8].copy_from_slice(§or_512.to_le_bytes());
|
||||
k.encrypt(&mut iv);
|
||||
}
|
||||
Iv::Eboiv(k) | Iv::Bitlocker(k) => {
|
||||
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
|
||||
k.encrypt(&mut iv);
|
||||
}
|
||||
}
|
||||
iv
|
||||
}
|
||||
}
|
||||
|
||||
/// Sector-level disk cipher.
|
||||
#[derive(Clone)]
|
||||
pub enum SectorCipher {
|
||||
Xts {
|
||||
k1: AesAny,
|
||||
k2: AesAny,
|
||||
iv: Iv,
|
||||
},
|
||||
Cbc {
|
||||
key: AesAny,
|
||||
iv: Iv,
|
||||
},
|
||||
/// BitLocker AES-CBC with Elephant diffuser (data key + tweak key).
|
||||
Elephant {
|
||||
key: AesAny,
|
||||
tweak: AesAny,
|
||||
},
|
||||
}
|
||||
|
||||
fn gf_double(t: &mut [u8; 16]) {
|
||||
let mut carry = 0u8;
|
||||
for b in t.iter_mut() {
|
||||
let next = *b >> 7;
|
||||
*b = (*b << 1) | carry;
|
||||
carry = next;
|
||||
}
|
||||
if carry != 0 {
|
||||
t[0] ^= 0x87;
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorCipher {
|
||||
/// 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 } => {
|
||||
let mut t = iv.make(sector_512, byte_off);
|
||||
k2.encrypt(&mut t);
|
||||
for ch in buf.chunks_exact_mut(16) {
|
||||
let mut b = [0u8; 16];
|
||||
b.copy_from_slice(ch);
|
||||
xor16(&mut b, &t);
|
||||
k1.decrypt(&mut b);
|
||||
xor16(&mut b, &t);
|
||||
ch.copy_from_slice(&b);
|
||||
gf_double(&mut t);
|
||||
}
|
||||
}
|
||||
SectorCipher::Cbc { key, iv } => {
|
||||
cbc_decrypt(key, &iv.make(sector_512, byte_off), buf);
|
||||
}
|
||||
SectorCipher::Elephant { key, tweak } => {
|
||||
bitlocker::elephant_decrypt(key, tweak, buf, byte_off);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cbc_decrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
|
||||
let mut prev = *iv;
|
||||
for ch in buf.chunks_exact_mut(16) {
|
||||
let mut c = [0u8; 16];
|
||||
c.copy_from_slice(ch);
|
||||
let mut b = c;
|
||||
key.decrypt(&mut b);
|
||||
xor16(&mut b, &prev);
|
||||
ch.copy_from_slice(&b);
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cbc_encrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
|
||||
let mut prev = *iv;
|
||||
for ch in buf.chunks_exact_mut(16) {
|
||||
let mut b = [0u8; 16];
|
||||
b.copy_from_slice(ch);
|
||||
xor16(&mut b, &prev);
|
||||
key.encrypt(&mut b);
|
||||
ch.copy_from_slice(&b);
|
||||
prev = b;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Sectors before this threshold are either unencrypted (partially encrypted BitLocker) or
|
||||
/// redirected to a backup copy (BitLocker relocated boot sectors).
|
||||
patches: Vec<Patch>,
|
||||
/// Bytes at or past this payload-relative offset are still unencrypted.
|
||||
plain_from: u64,
|
||||
/// Added to the sector index when computing the IV (LUKS2 `iv_tweak`).
|
||||
iv_tweak: u64,
|
||||
}
|
||||
|
||||
/// Volume range redirected to another offset on the source (BitLocker relocated header sectors).
|
||||
#[derive(Clone)]
|
||||
pub struct Patch {
|
||||
/// Start offset in the logical plaintext volume.
|
||||
pub at: u64,
|
||||
pub len: u64,
|
||||
/// Source offset where the data resides.
|
||||
pub from: u64,
|
||||
/// Whether the source bytes must be decrypted (using cipher offset `from_crypt`).
|
||||
pub decrypt_from: Option<u64>,
|
||||
/// Returns zeros (BitLocker metadata regions masked out by the OS).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
impl CryptSource {
|
||||
pub fn new(
|
||||
inner: Arc<dyn Source>,
|
||||
cipher: SectorCipher,
|
||||
data_offset: u64,
|
||||
len: u64,
|
||||
sector: u64,
|
||||
) -> Self {
|
||||
let sector = sector.max(512);
|
||||
Self {
|
||||
inner,
|
||||
cipher,
|
||||
data_offset,
|
||||
len: len / sector * sector,
|
||||
sector,
|
||||
patches: vec![],
|
||||
plain_from: u64::MAX,
|
||||
iv_tweak: 0,
|
||||
}
|
||||
}
|
||||
pub fn with_patches(mut self, p: Vec<Patch>) -> Self {
|
||||
self.patches = p;
|
||||
self
|
||||
}
|
||||
pub fn with_iv_tweak(mut self, t: u64) -> Self {
|
||||
self.iv_tweak = t;
|
||||
self
|
||||
}
|
||||
pub fn with_plain_from(mut self, off: u64) -> Self {
|
||||
self.plain_from = off;
|
||||
self
|
||||
}
|
||||
|
||||
fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> {
|
||||
// `rel` is sector-aligned; `out.len() == sector`.
|
||||
for p in &self.patches {
|
||||
if rel >= p.at && rel < p.at + p.len {
|
||||
if p.zero {
|
||||
out.fill(0);
|
||||
return Ok(());
|
||||
}
|
||||
let src_off = p.from + (rel - p.at);
|
||||
crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + src_off, out)?;
|
||||
if let Some(pos) = p.decrypt_from {
|
||||
let r = pos + (rel - p.at);
|
||||
self.cipher.decrypt_sector(out, r / 512, r);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + rel, out)?;
|
||||
if rel < self.plain_from {
|
||||
self.cipher
|
||||
.decrypt_sector(out, rel / 512 + self.iv_tweak, rel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for CryptSource {
|
||||
fn size(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if offset >= self.len || buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let want = buf.len().min((self.len - offset) as usize);
|
||||
let s = self.sector;
|
||||
let mut done = 0usize;
|
||||
let mut tmp = vec![0u8; s as usize];
|
||||
while done < want {
|
||||
let pos = offset + done as u64;
|
||||
let rel = pos / s * s;
|
||||
let within = (pos - rel) as usize;
|
||||
let n = (s as usize - within).min(want - done);
|
||||
self.read_sector_plain(rel, &mut tmp)?;
|
||||
buf[done..done + n].copy_from_slice(&tmp[within..within + n]);
|
||||
done += n;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
|
||||
/// User-supplied credential for unlocking an encrypted volume.
|
||||
#[derive(Clone)]
|
||||
pub enum Secret {
|
||||
Password(String),
|
||||
/// BitLocker numeric recovery password (8 groups of 6 digits).
|
||||
RecoveryKey(String),
|
||||
/// Raw contents of a key file (BitLocker .BEK, LUKS keyfile).
|
||||
KeyFile(Vec<u8>),
|
||||
/// Known volume master key (FVEK / LUKS master key).
|
||||
VolumeKey(Vec<u8>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Secret::Password(_) => "Secret::Password(..)",
|
||||
Secret::RecoveryKey(_) => "Secret::RecoveryKey(..)",
|
||||
Secret::KeyFile(_) => "Secret::KeyFile(..)",
|
||||
Secret::VolumeKey(_) => "Secret::VolumeKey(..)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UnlockError {
|
||||
/// None of the supplied passwords or keys unlocked the volume.
|
||||
WrongSecret,
|
||||
Unsupported(String),
|
||||
Corrupt(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UnlockError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
UnlockError::WrongSecret => write!(f, "senha ou chave incorreta"),
|
||||
UnlockError::Unsupported(s) => write!(f, "não suportado: {s}"),
|
||||
UnlockError::Corrupt(s) => write!(f, "cabeçalho inválido: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnlockError> for crate::Error {
|
||||
fn from(e: UnlockError) -> Self {
|
||||
crate::Error::msg(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlocked encrypted volume.
|
||||
pub struct Unlocked {
|
||||
pub source: Arc<dyn Source>,
|
||||
pub description: String,
|
||||
/// Volume master key (can be saved in the session to reopen without a password if permitted).
|
||||
pub volume_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub type UnlockResult = std::result::Result<Unlocked, UnlockError>;
|
||||
|
||||
pub fn to_hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn from_hex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len() / 2)
|
||||
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Fragmented file reassembly during carving.
|
||||
//!
|
||||
//! 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;
|
||||
/// 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;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl ClusterIndex {
|
||||
pub fn new(size: u64) -> Self {
|
||||
Self {
|
||||
bits: vec![0; (size / CLUSTER / 64 + 1) as usize],
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, off: u64) {
|
||||
let c = (off / CLUSTER) as usize;
|
||||
if let Some(w) = self.bits.get_mut(c / 64) {
|
||||
*w |= 1 << (c % 64);
|
||||
}
|
||||
}
|
||||
pub fn get(&self, off: u64) -> bool {
|
||||
let c = (off / CLUSTER) as usize;
|
||||
self.bits
|
||||
.get(c / 64)
|
||||
.map(|w| w >> (c % 64) & 1 == 1)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// 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;
|
||||
while o + CLUSTER <= abs + buf.len() as u64 {
|
||||
let c = &buf[(o - abs) as usize..(o - abs + CLUSTER) as usize];
|
||||
if jpeg_like(c) {
|
||||
self.set(o);
|
||||
}
|
||||
o += CLUSTER;
|
||||
}
|
||||
}
|
||||
pub fn iter_range(&self, from: u64, to: u64) -> impl Iterator<Item = u64> + '_ {
|
||||
let (a, b) = (from.div_ceil(CLUSTER), to / CLUSTER);
|
||||
(a..b)
|
||||
.filter(move |&c| {
|
||||
self.bits
|
||||
.get(c as usize / 64)
|
||||
.map(|w| w >> (c % 64) & 1 == 1)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|c| c * CLUSTER)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jpeg_like(c: &[u8]) -> bool {
|
||||
let mut seen = [false; 256];
|
||||
let mut distinct = 0;
|
||||
for (i, &b) in c.iter().enumerate() {
|
||||
if !seen[b as usize] {
|
||||
seen[b as usize] = true;
|
||||
distinct += 1;
|
||||
}
|
||||
if b == 0xFF {
|
||||
match c.get(i + 1) {
|
||||
Some(&n) if n == 0x00 || (0xD0..=0xD7).contains(&n) => {}
|
||||
Some(0xD9) if i + 2 >= c.len() - 16 => {}
|
||||
None => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
distinct >= 190
|
||||
}
|
||||
|
||||
pub struct Pool<'a> {
|
||||
pub index: &'a ClusterIndex,
|
||||
/// Extents already claimed by other recovered files.
|
||||
pub used: &'a [Extent],
|
||||
}
|
||||
|
||||
impl Pool<'_> {
|
||||
fn free_at(&self, off: u64, len: u64) -> bool {
|
||||
!self
|
||||
.used
|
||||
.iter()
|
||||
.any(|u| off < u.end() && off + len > u.offset)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Analysis {
|
||||
/// Contiguous and valid; `len` includes the EOI marker.
|
||||
Intact { len: u64 },
|
||||
/// Reassembled from multiple non-contiguous fragments.
|
||||
Rebuilt { extents: Vec<Extent>, size: u64 },
|
||||
/// Only the leading prefix is reliable.
|
||||
Broken { good_len: u64 },
|
||||
/// JPEG variant we do not entropy-validate (progressive, arithmetic, etc.).
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.find(|&i| data[i] == 0xFF && data[i + 1] == 0xD9)
|
||||
.map(|i| i + 2)
|
||||
}
|
||||
|
||||
/// Validates the JPEG starting at `off` and attempts fragment reassembly if truncated/broken.
|
||||
pub fn analyze_jpeg(
|
||||
src: &dyn Source,
|
||||
off: u64,
|
||||
hit_len: u64,
|
||||
pool: &Pool,
|
||||
allow_rebuild: bool,
|
||||
) -> Analysis {
|
||||
let read_len = (hit_len as usize).clamp(4096, MAX_FILE);
|
||||
let Ok(buf) = read_vec(src, off, read_len) else {
|
||||
return Analysis::Unsupported;
|
||||
};
|
||||
let Some(h) = jpegscan::parse_header(&buf) else {
|
||||
return Analysis::Unsupported;
|
||||
};
|
||||
let d = jpegscan::decode(&h, &buf, State::start(&h), u32::MAX, 512);
|
||||
let disc = jpegscan::first_discontinuity(&h, &d.dc);
|
||||
if d.stop == Stop::Done && disc.is_none() {
|
||||
if let Some(end) = eoi_after(&buf, d.state.pos) {
|
||||
return Analysis::Intact { len: end as u64 };
|
||||
}
|
||||
}
|
||||
let mut stop_pos = match d.stop {
|
||||
Stop::Marker(_, p) | Stop::Error(p) => p,
|
||||
Stop::Truncated | Stop::Done => buf.len(),
|
||||
};
|
||||
if let Some(m) = disc {
|
||||
let bad = d
|
||||
.snaps
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| (s.mcu as usize) <= m)
|
||||
.map(|s| s.pos)
|
||||
.unwrap_or(h.scan_start);
|
||||
stop_pos = stop_pos.min(bad);
|
||||
}
|
||||
let cl = CLUSTER as usize;
|
||||
if off % CLUSTER != 0 || !allow_rebuild {
|
||||
return Analysis::Broken {
|
||||
good_len: (stop_pos / 512 * 512).max(512) as u64,
|
||||
};
|
||||
}
|
||||
|
||||
let mut pieces: Vec<(u64, usize)> = vec![];
|
||||
let mut joined = buf;
|
||||
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;
|
||||
|
||||
for _ in 0..3 {
|
||||
let top = suspect.saturating_sub(frag_start) / cl * cl;
|
||||
let mut best: Option<(usize, u64, State, f64)> = None;
|
||||
let mut probes = 0usize;
|
||||
for back in 0..4usize {
|
||||
let Some(bc_rel) = top.checked_sub(back * cl).filter(|&b| b > 0) else {
|
||||
break;
|
||||
};
|
||||
let bc = frag_start + bc_rel;
|
||||
let Some(snap) = snaps
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.pos <= bc)
|
||||
.cloned()
|
||||
.or_else(|| (frag_start == 0).then(|| State::start(&h)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut plane = jpegscan::Plane::new(&h);
|
||||
let _ =
|
||||
jpegscan::decode_plane(&h, &joined[..bc], State::start(&h), u32::MAX, &mut plane);
|
||||
let lo = off.saturating_sub(WINDOW_BEFORE);
|
||||
let hi = cur_base + bc_rel as u64 + WINDOW_AFTER;
|
||||
let pre = jpegscan::decode(&h, &joined[..bc], State::start(&h), u32::MAX, 0);
|
||||
let bpm_prefix =
|
||||
(pre.state.pos.saturating_sub(h.scan_start)) as f64 / pre.dc.len().max(1) as f64;
|
||||
let mut ranked: Vec<(f64, u64)> = vec![];
|
||||
for q in pool.index.iter_range(lo, hi) {
|
||||
if q >= cur_base && q < cur_base + bc_rel as u64 + CLUSTER {
|
||||
continue;
|
||||
}
|
||||
if !pool.free_at(q, CLUSTER)
|
||||
|| pieces
|
||||
.iter()
|
||||
.any(|&(po, pl)| q < po + pl as u64 && q + CLUSTER > po)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
probes += 1;
|
||||
if probes > MAX_PROBES {
|
||||
break;
|
||||
}
|
||||
if let Some(sc) =
|
||||
probe_candidate(src, &h, &joined[..bc], &plane, &snap, q, bpm_prefix)
|
||||
{
|
||||
ranked.push((sc, q));
|
||||
}
|
||||
}
|
||||
ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
ranked.truncate(120);
|
||||
// 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;
|
||||
let more = read_vec(src, q, MAX_FILE.min(avail)).unwrap_or_default();
|
||||
let mut j2 = joined[..bc].to_vec();
|
||||
j2.extend_from_slice(&more);
|
||||
let d2 = jpegscan::decode(&h, &j2, snap.clone(), u32::MAX, 0);
|
||||
if d2.stop == Stop::Done && eoi_after(&j2, d2.state.pos).is_some() {
|
||||
best = Some((bc, q, snap.clone(), 0.0));
|
||||
break;
|
||||
}
|
||||
let reach = match d2.stop {
|
||||
Stop::Marker(_, p) | Stop::Error(p) => p.saturating_sub(bc),
|
||||
_ => j2.len() - bc,
|
||||
};
|
||||
if reach >= 3 * cl && longest.map_or(true, |l| sc < l.2) {
|
||||
longest = Some((reach, q, sc));
|
||||
}
|
||||
}
|
||||
if best.is_none() {
|
||||
if let Some((_, q, sc)) = longest {
|
||||
if sc < 25.0 {
|
||||
let cand = (bc, q, snap.clone(), sc);
|
||||
if best.as_ref().map_or(true, |b| sc < b.3) {
|
||||
best = Some(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.as_ref().is_some_and(|b| b.3 == 0.0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((bc, q, snap, _)) = best else { break };
|
||||
pieces.push((cur_base, bc - frag_start));
|
||||
let more = read_vec(src, q, MAX_FILE.min((src.size() - q) as usize)).unwrap_or_default();
|
||||
joined.truncate(bc);
|
||||
joined.extend_from_slice(&more);
|
||||
let d2 = jpegscan::decode(&h, &joined, snap, u32::MAX, 512);
|
||||
if d2.stop == Stop::Done {
|
||||
if let Some(end) = eoi_after(&joined, d2.state.pos) {
|
||||
pieces.push((q, end - bc));
|
||||
return finish(pieces);
|
||||
}
|
||||
}
|
||||
// Broke again: find the new break point.
|
||||
let mut sp = match d2.stop {
|
||||
Stop::Marker(_, p) | Stop::Error(p) => p,
|
||||
_ => joined.len(),
|
||||
};
|
||||
let all = jpegscan::decode(&h, &joined, State::start(&h), u32::MAX, 0);
|
||||
if let Some(m) = jpegscan::first_discontinuity(&h, &all.dc) {
|
||||
let bad = d2
|
||||
.snaps
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| (s.mcu as usize) <= m)
|
||||
.map(|s| s.pos)
|
||||
.unwrap_or(bc);
|
||||
if bad > bc {
|
||||
sp = sp.min(bad);
|
||||
}
|
||||
}
|
||||
snaps = d2.snaps;
|
||||
cur_base = q;
|
||||
frag_start = bc;
|
||||
suspect = sp.max(bc + cl);
|
||||
}
|
||||
let good = pieces.iter().map(|p| p.1).sum::<usize>()
|
||||
+ suspect.saturating_sub(frag_start).min(cl * 4) / 512 * 512;
|
||||
Analysis::Broken {
|
||||
good_len: (stop_pos.min(good.max(512)) / 512 * 512).max(512) as u64,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(pieces: Vec<(u64, usize)>) -> Analysis {
|
||||
let mut extents: Vec<Extent> = vec![];
|
||||
for (o, l) in &pieces {
|
||||
match extents.last_mut() {
|
||||
Some(last) if last.end() == *o => last.len += *l as u64,
|
||||
_ => extents.push(Extent::new(*o, *l as u64)),
|
||||
}
|
||||
}
|
||||
let size = pieces.iter().map(|p| p.1 as u64).sum();
|
||||
if extents.len() == 1 {
|
||||
return Analysis::Intact { len: size };
|
||||
}
|
||||
Analysis::Rebuilt { extents, size }
|
||||
}
|
||||
|
||||
/// 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,
|
||||
prefix: &[u8],
|
||||
plane: &jpegscan::Plane,
|
||||
snap: &State,
|
||||
q: u64,
|
||||
bpm_prefix: f64,
|
||||
) -> Option<f64> {
|
||||
let more = read_vec(src, q, PROBE).ok()?;
|
||||
if more.len() < 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut joined = Vec::with_capacity(prefix.len() + more.len());
|
||||
joined.extend_from_slice(prefix);
|
||||
joined.extend_from_slice(&more);
|
||||
let seam = plane.have;
|
||||
const K: u32 = 36;
|
||||
let mut news: std::collections::HashMap<u32, Vec<u8>> = std::collections::HashMap::new();
|
||||
let mut hook = |mcu: u32, blocks: &[[i32; 64]]| {
|
||||
if mcu >= seam {
|
||||
news.insert(mcu, jpegscan::mcu_luma(h, blocks));
|
||||
}
|
||||
};
|
||||
let want = seam.saturating_sub(snap.mcu) + K + 1;
|
||||
let d = jpegscan::decode_hook(h, &joined, snap.clone(), want, 0, Some(&mut hook));
|
||||
match d.stop {
|
||||
Stop::Error(_) => return None,
|
||||
Stop::Marker(m, _) if m != 0xD9 => return None,
|
||||
_ => {}
|
||||
}
|
||||
let (mw, mh) = (plane.mw, plane.mh);
|
||||
let mx = h.mcus_x;
|
||||
let get = |idx: u32| -> Option<Vec<u8>> {
|
||||
if idx < seam {
|
||||
let (x, y) = ((idx % mx) as usize, (idx / mx) as usize);
|
||||
let mut v = vec![0u8; mw * mh];
|
||||
for r in 0..mh {
|
||||
let o = (y * mh + r) * plane.w + x * mw;
|
||||
v[r * mw..(r + 1) * mw].copy_from_slice(&plane.px[o..o + mw]);
|
||||
}
|
||||
Some(v)
|
||||
} else {
|
||||
news.get(&idx).cloned()
|
||||
}
|
||||
};
|
||||
let (mut sum, mut n) = (0f64, 0u32);
|
||||
for idx in seam + 1..seam + 1 + K {
|
||||
let Some(cur) = news.get(&idx) else { break };
|
||||
if idx % mx > 0 {
|
||||
if let Some(l) = get(idx - 1) {
|
||||
for y in 0..mh {
|
||||
sum += (cur[y * mw] as i32 - l[y * mw + mw - 1] as i32).abs() as f64;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx >= mx {
|
||||
if let Some(t) = get(idx - mx) {
|
||||
for x in 0..mw {
|
||||
sum += (cur[x] as i32 - t[(mh - 1) * mw + x] as i32).abs() as f64;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n < 40 {
|
||||
return None;
|
||||
}
|
||||
// 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 {
|
||||
(used / after) / bpm_prefix.max(0.5)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let sc = sum / n as f64 + 20.0 * ratio.ln().abs();
|
||||
Some(sc)
|
||||
}
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! File-manager style browsing: filtering, sorting, facets
|
||||
//! (types, dates, folders, cameras), and thumbnail caching.
|
||||
|
||||
use crate::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Query {
|
||||
#[serde(default)]
|
||||
pub category: Option<Category>,
|
||||
#[serde(default)]
|
||||
pub min_confidence: Option<Confidence>,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
/// 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>,
|
||||
/// Match only files without a known date.
|
||||
#[serde(default)]
|
||||
pub undated: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub camera: Option<String>,
|
||||
#[serde(default)]
|
||||
pub origin: Option<Origin>,
|
||||
#[serde(default)]
|
||||
pub min_size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_size: Option<u64>,
|
||||
/// Minimum resolution in megapixels.
|
||||
#[serde(default)]
|
||||
pub min_mp: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub has_gps: Option<bool>,
|
||||
/// Deduplicate: show only the first occurrence of identical content.
|
||||
#[serde(default)]
|
||||
pub dedupe: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
200
|
||||
}
|
||||
|
||||
impl Default for Query {
|
||||
fn default() -> Self {
|
||||
serde_json::from_str("{}").unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn needle(&self) -> Option<String> {
|
||||
self.query
|
||||
.as_deref()
|
||||
.map(|x| x.to_lowercase())
|
||||
.filter(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if self.dedupe == Some(true) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
v.retain(|f| f.sig.map(|g| seen.insert((g, f.size))).unwrap_or(true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn matches(&self, f: &Found, needle: &Option<String>) -> bool {
|
||||
if self.category.is_some_and(|c| f.category != c) {
|
||||
return false;
|
||||
}
|
||||
if self.min_confidence.is_some_and(|c| f.confidence < c) {
|
||||
return false;
|
||||
}
|
||||
if self.origin.is_some_and(|o| f.origin != o) {
|
||||
return false;
|
||||
}
|
||||
if self.min_size.is_some_and(|m| f.size < m) || self.max_size.is_some_and(|m| f.size > m) {
|
||||
return false;
|
||||
}
|
||||
if let Some(n) = needle {
|
||||
let hay =
|
||||
format!("{} {}", f.full_path(), f.camera.as_deref().unwrap_or("")).to_lowercase();
|
||||
if !hay.contains(n.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(folder) = &self.folder {
|
||||
let ok = match folder.as_str() {
|
||||
"@carved" => f.origin == Origin::Carved,
|
||||
"@raiz" => {
|
||||
f.origin != Origin::Carved
|
||||
&& f.dir.as_deref().map(str::is_empty).unwrap_or(true)
|
||||
}
|
||||
p => f
|
||||
.dir
|
||||
.as_deref()
|
||||
.is_some_and(|d| d == p || d.starts_with(&format!("{p}/"))),
|
||||
};
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if self.year.is_some() || self.month.is_some() {
|
||||
let Some(t) = f.when() else { return false };
|
||||
let (y, m, _) = crate::meta::ymd(t);
|
||||
if self.year.is_some_and(|yy| yy != y) || self.month.is_some_and(|mm| mm != m) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if self.undated == Some(true) && f.when().is_some() {
|
||||
return false;
|
||||
}
|
||||
if let Some(c) = &self.camera {
|
||||
if f.camera.as_deref() != Some(c.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(mp) = self.min_mp {
|
||||
match f.px {
|
||||
Some((w, h)) if (w as f64 * h as f64) / 1e6 >= mp => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if self.has_gps == Some(true) && !f.gps {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_items(items: &mut [&Found], sort: Option<&str>) {
|
||||
match sort {
|
||||
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
|
||||
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
|
||||
Some("date") => items.sort_by(|a, b| b.when().cmp(&a.when()).then(a.id.cmp(&b.id))),
|
||||
Some("date_asc") => items.sort_by(|a, b| match (a.when(), b.when()) {
|
||||
(Some(x), Some(y)) => x.cmp(&y).then(a.id.cmp(&b.id)),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
_ => a.id.cmp(&b.id),
|
||||
}),
|
||||
Some("confidence") => {
|
||||
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_json(f: &Found) -> Value {
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category,
|
||||
"size": f.size, "origin": f.origin, "confidence": f.confidence,
|
||||
"modified": f.modified, "created": f.created, "accessed": f.accessed,
|
||||
"taken": f.taken, "when": f.when(), "camera": f.camera, "px": f.px, "gps": f.gps,
|
||||
"note": f.note,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut cams: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut folders: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let (mut undated, mut carved, mut root, mut gps) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut conf = [0usize; 3];
|
||||
let mut seen_sig = std::collections::HashSet::new();
|
||||
let mut dups = 0usize;
|
||||
let (mut small, mut mp1) = (0usize, 0usize);
|
||||
for f in found {
|
||||
if f.size < 30 * 1024 {
|
||||
small += 1;
|
||||
}
|
||||
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
|
||||
mp1 += 1;
|
||||
}
|
||||
if let Some(g) = f.sig {
|
||||
if !seen_sig.insert((g, f.size)) {
|
||||
dups += 1;
|
||||
}
|
||||
}
|
||||
*cats.entry(cat_key(f.category)).or_default() += 1;
|
||||
conf[f.confidence as usize] += 1;
|
||||
match f.when() {
|
||||
Some(t) => {
|
||||
let (y, m, _) = crate::meta::ymd(t);
|
||||
let e = years.entry(y).or_default();
|
||||
e.0 += 1;
|
||||
*e.1.entry(m).or_default() += 1;
|
||||
}
|
||||
None => undated += 1,
|
||||
}
|
||||
if let Some(c) = &f.camera {
|
||||
*cams.entry(c.clone()).or_default() += 1;
|
||||
}
|
||||
if f.gps {
|
||||
gps += 1;
|
||||
}
|
||||
if f.origin == Origin::Carved {
|
||||
carved += 1;
|
||||
} else if let Some(d) = f.dir.as_deref().filter(|d| !d.is_empty()) {
|
||||
let mut acc = String::new();
|
||||
for part in d.split('/') {
|
||||
if !acc.is_empty() {
|
||||
acc.push('/');
|
||||
}
|
||||
acc.push_str(part);
|
||||
*folders.entry(acc.clone()).or_default() += 1;
|
||||
}
|
||||
} else {
|
||||
root += 1;
|
||||
}
|
||||
}
|
||||
let cams_v: Vec<Value> = {
|
||||
let mut v: Vec<_> = cams.into_iter().collect();
|
||||
v.sort_by_key(|x| std::cmp::Reverse(x.1));
|
||||
v.into_iter()
|
||||
.map(|(n, c)| json!({ "name": n, "count": c }))
|
||||
.collect()
|
||||
};
|
||||
json!({
|
||||
"total": found.len(),
|
||||
"categories": cats,
|
||||
"confidence": { "low": conf[0], "medium": conf[1], "high": conf[2] },
|
||||
"years": years.iter().rev().map(|(y, (n, ms))| json!({
|
||||
"year": y, "count": n,
|
||||
"months": ms.iter().rev().map(|(m, c)| json!({"month": m, "count": c})).collect::<Vec<_>>()
|
||||
})).collect::<Vec<_>>(),
|
||||
"undated": undated,
|
||||
"cameras": cams_v,
|
||||
"folders": folders.iter().map(|(p, c)| json!({
|
||||
"path": p, "name": p.rsplit('/').next().unwrap_or(p), "depth": p.matches('/').count(), "count": c
|
||||
})).collect::<Vec<_>>(),
|
||||
"carved": carved,
|
||||
"root": root,
|
||||
"gps": gps,
|
||||
"duplicates": dups,
|
||||
"small": small,
|
||||
"mp1": mp1,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cat_key(c: Category) -> &'static str {
|
||||
match c {
|
||||
Category::Image => "image",
|
||||
Category::Document => "document",
|
||||
Category::Video => "video",
|
||||
Category::Audio => "audio",
|
||||
Category::Archive => "archive",
|
||||
Category::Database => "database",
|
||||
Category::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ thumbnail cache
|
||||
|
||||
type Key = (String, u64, u32);
|
||||
|
||||
pub struct ThumbCache {
|
||||
map: HashMap<Key, Arc<Vec<u8>>>,
|
||||
order: VecDeque<Key>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ThumbCache {
|
||||
const CAP: usize = 128 * 1024 * 1024;
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
pub fn get(&self, k: &Key) -> Option<Arc<Vec<u8>>> {
|
||||
self.map.get(k).cloned()
|
||||
}
|
||||
pub fn put(&mut self, k: Key, v: Arc<Vec<u8>>) {
|
||||
self.bytes += v.len();
|
||||
if self.map.insert(k.clone(), v).is_none() {
|
||||
self.order.push_back(k);
|
||||
}
|
||||
while self.bytes > Self::CAP {
|
||||
let Some(old) = self.order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
if let Some(v) = self.map.remove(&old) {
|
||||
self.bytes -= v.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThumbCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds concurrent thumbnail decoding (memory/CPU cap).
|
||||
pub struct Gate {
|
||||
n: Mutex<usize>,
|
||||
cv: Condvar,
|
||||
max: usize,
|
||||
}
|
||||
|
||||
pub struct Permit<'a>(&'a Gate);
|
||||
|
||||
impl Gate {
|
||||
pub fn new(max: usize) -> Self {
|
||||
Self {
|
||||
n: Mutex::new(0),
|
||||
cv: Condvar::new(),
|
||||
max: max.max(1),
|
||||
}
|
||||
}
|
||||
pub fn acquire(&self) -> Permit<'_> {
|
||||
let mut n = self.n.lock().unwrap();
|
||||
while *n >= self.max {
|
||||
n = self.cv.wait(n).unwrap();
|
||||
}
|
||||
*n += 1;
|
||||
Permit(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Permit<'_> {
|
||||
fn drop(&mut self) {
|
||||
*self.0.n.lock().unwrap() -= 1;
|
||||
self.0.cv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let mut h: u64 = 0xcbf29ce484222325 ^ f.size;
|
||||
let mut feed = |off: u64| {
|
||||
let mut buf = vec![0u8; 32 * 1024];
|
||||
let mut got = 0;
|
||||
while got < buf.len() {
|
||||
match read_found_at(src, f, off + got as u64, &mut buf[got..]) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => got += n,
|
||||
}
|
||||
}
|
||||
for &b in &buf[..got] {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
got
|
||||
};
|
||||
let a = feed(0);
|
||||
if a == 0 {
|
||||
return None;
|
||||
}
|
||||
if f.size > 64 * 1024 {
|
||||
feed(f.size - 32 * 1024);
|
||||
}
|
||||
Some(h)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 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};
|
||||
|
||||
/// 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` has no preconditions.
|
||||
unsafe { libc::getuid() }.to_string()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"user".to_string()
|
||||
}
|
||||
});
|
||||
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
|
||||
}
|
||||
|
||||
/// Removes temporary copies older than one day.
|
||||
fn cleanup(dir: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let limit = std::time::Duration::from_secs(24 * 3600);
|
||||
for e in rd.flatten() {
|
||||
if let Ok(age) = e
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.and_then(|t| t.elapsed().map_err(std::io::Error::other))
|
||||
{
|
||||
if age > limit {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return Err(Error::msg("Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos."));
|
||||
}
|
||||
let dir = temp_dir();
|
||||
crate::owner::create_dir_all_owned(&dir)?;
|
||||
cleanup(&dir);
|
||||
let path = dir.join(format!("{}-{}", f.id, sanitize_name(&f.name)));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
crate::recover::write_file(src, f, &path)?;
|
||||
open_path(&path)
|
||||
.map_err(|e| Error::msg(format!("Não foi possível abrir {}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
let mut cmd = if let Some(u) = crate::owner::invoking_user() {
|
||||
let mut c = Command::new("runuser");
|
||||
c.args(["-u", &u.name, "--", "env"]);
|
||||
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
|
||||
c.arg(format!(
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
|
||||
u.uid
|
||||
));
|
||||
c.arg("xdg-open").arg(path);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", "start", ""]).arg(path);
|
||||
c
|
||||
};
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! 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};
|
||||
use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
fn le16(b: &[u8], o: usize) -> u16 {
|
||||
b.get(o..o + 2)
|
||||
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn le32(b: &[u8], o: usize) -> u32 {
|
||||
b.get(o..o + 4)
|
||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn le64(b: &[u8], o: usize) -> u64 {
|
||||
b.get(o..o + 8)
|
||||
.map(|s| u64::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
const OBJ_TYPE_MASK: u32 = 0xFFFF;
|
||||
const T_BTREE_ROOT: u32 = 2;
|
||||
const T_BTREE_NODE: u32 = 3;
|
||||
const T_SPACEMAN: u32 = 5;
|
||||
const T_CIB: u32 = 7;
|
||||
const T_CHECKPOINT_MAP: u32 = 0x0C;
|
||||
const SUBTYPE_FSTREE: u32 = 0x0E;
|
||||
|
||||
// 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;
|
||||
|
||||
/// 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) {
|
||||
s1 = (s1 + u32::from_le_bytes(w.try_into().unwrap()) as u64) % 0xFFFF_FFFF;
|
||||
s2 = (s2 + s1) % 0xFFFF_FFFF;
|
||||
}
|
||||
let c1 = 0xFFFF_FFFF - ((s1 + s2) % 0xFFFF_FFFF);
|
||||
let c2 = 0xFFFF_FFFF - ((s1 + c1) % 0xFFFF_FFFF);
|
||||
(c2 << 32) | c1
|
||||
}
|
||||
|
||||
struct Apfs<'a> {
|
||||
src: &'a dyn Source,
|
||||
base: u64,
|
||||
bs: u64,
|
||||
blocks: u64,
|
||||
}
|
||||
|
||||
struct BNode {
|
||||
flags: u16,
|
||||
level: u16,
|
||||
/// `(key, value)` slice pair for each node entry
|
||||
entries: Vec<(Vec<u8>, Vec<u8>)>,
|
||||
oid: u64,
|
||||
xid: u64,
|
||||
}
|
||||
|
||||
impl<'a> Apfs<'a> {
|
||||
fn block(&self, paddr: u64) -> Option<Vec<u8>> {
|
||||
if paddr >= self.blocks {
|
||||
return None;
|
||||
}
|
||||
let b = read_vec(self.src, self.base + paddr * self.bs, self.bs as usize).ok()?;
|
||||
(b.len() as u64 == self.bs).then_some(b)
|
||||
}
|
||||
|
||||
fn checked_block(&self, paddr: u64) -> Option<Vec<u8>> {
|
||||
let b = self.block(paddr)?;
|
||||
(fletcher64(&b[8..]) == le64(&b, 0)).then_some(b)
|
||||
}
|
||||
|
||||
fn parse_node(&self, b: &[u8], fixed_k: usize, fixed_v: usize) -> Option<BNode> {
|
||||
let flags = le16(b, 32);
|
||||
let level = le16(b, 34);
|
||||
let n = le32(b, 36) as usize;
|
||||
let toc_off = le16(b, 40) as usize;
|
||||
let toc_len = le16(b, 42) as usize;
|
||||
let is_fixed = flags & 4 != 0;
|
||||
let is_root = flags & 1 != 0;
|
||||
let key_base = 56 + toc_off + toc_len;
|
||||
let val_end = b.len() - if is_root { 40 } else { 0 };
|
||||
let mut entries = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let (koff, klen, voff, vlen);
|
||||
if is_fixed {
|
||||
let o = 56 + toc_off + i * 4;
|
||||
koff = le16(b, o) as usize;
|
||||
voff = le16(b, o + 2) as usize;
|
||||
klen = fixed_k;
|
||||
vlen = if level == 0 { fixed_v } else { 8 };
|
||||
} else {
|
||||
let o = 56 + toc_off + i * 8;
|
||||
koff = le16(b, o) as usize;
|
||||
klen = le16(b, o + 2) as usize;
|
||||
voff = le16(b, o + 4) as usize;
|
||||
vlen = le16(b, o + 6) as usize;
|
||||
}
|
||||
let ks = key_base + koff;
|
||||
let k = b.get(ks..ks + klen)?.to_vec();
|
||||
let v = if voff == 0xFFFF {
|
||||
vec![]
|
||||
} else {
|
||||
b.get(val_end.checked_sub(voff)?..)?.get(..vlen)?.to_vec()
|
||||
};
|
||||
entries.push((k, v));
|
||||
}
|
||||
Some(BNode {
|
||||
flags,
|
||||
level,
|
||||
entries,
|
||||
oid: le64(b, 8),
|
||||
xid: le64(b, 16),
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects all entries from a physical B-tree (object map): child pointers are physical block addresses.
|
||||
fn walk_physical(
|
||||
&self,
|
||||
root: u64,
|
||||
fixed_k: usize,
|
||||
fixed_v: usize,
|
||||
f: &mut dyn FnMut(&[u8], &[u8]),
|
||||
) {
|
||||
let mut stack = vec![(root, 0u32)];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some((p, depth)) = stack.pop() {
|
||||
if depth > 8 || !seen.insert(p) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = self.checked_block(p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(n) = self.parse_node(&b, fixed_k, fixed_v) else {
|
||||
continue;
|
||||
};
|
||||
if n.level == 0 {
|
||||
for (k, v) in &n.entries {
|
||||
f(k, v);
|
||||
}
|
||||
} else {
|
||||
for (_, v) in n.entries.iter().rev() {
|
||||
stack.push((le64(v, 0), depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return m;
|
||||
};
|
||||
let tree = le64(&ob, 48);
|
||||
self.walk_physical(tree, 16, 16, &mut |k, v| {
|
||||
m.entry(le64(k, 0))
|
||||
.or_default()
|
||||
.push((le64(k, 8), le64(v, 8)));
|
||||
});
|
||||
for v in m.values_mut() {
|
||||
v.sort();
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let max_entries = (self.bs as usize).saturating_sub(40) / 40;
|
||||
let mut best: Option<(u64, u64)> = None;
|
||||
for i in 0..desc_blocks {
|
||||
let Some(b) = self.checked_block(desc_base + i) else {
|
||||
continue;
|
||||
};
|
||||
if le32(&b, 24) & OBJ_TYPE_MASK != T_CHECKPOINT_MAP {
|
||||
continue;
|
||||
}
|
||||
let xid = le64(&b, 16);
|
||||
if best.is_some_and(|(x, _)| x >= xid) {
|
||||
continue;
|
||||
}
|
||||
let count = (le32(&b, 36) as usize).min(max_entries);
|
||||
for j in 0..count {
|
||||
let o = 40 + j * 40;
|
||||
if le64(&b, o + 24) == oid {
|
||||
best = Some((xid, le64(&b, o + 32)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let sm = self.checked_block(self.ephemeral_paddr(nx, sm_oid)?)?;
|
||||
if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN {
|
||||
return None;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
let cib_count = le32(&sm, 64) as usize;
|
||||
let addr_off = le32(&sm, 80) as usize;
|
||||
let end = addr_off.checked_add(cib_count.checked_mul(8)?)?;
|
||||
if cib_count == 0 || end > sm.len() {
|
||||
return None;
|
||||
}
|
||||
let per_cib = (self.bs as usize).saturating_sub(40) / 32;
|
||||
let mut free = vec![];
|
||||
for ci in 0..cib_count {
|
||||
let cp = le64(&sm, addr_off + ci * 8);
|
||||
let Some(cib) = self.checked_block(cp) else {
|
||||
continue;
|
||||
};
|
||||
if le32(&cib, 24) & OBJ_TYPE_MASK != T_CIB {
|
||||
continue;
|
||||
}
|
||||
let count = (le32(&cib, 36) as usize).min(per_cib);
|
||||
for j in 0..count {
|
||||
let o = 40 + j * 32;
|
||||
let addr = le64(&cib, o + 8);
|
||||
let n = le32(&cib, o + 16) as u64;
|
||||
let bm = le64(&cib, o + 24);
|
||||
if n == 0 || addr >= self.blocks {
|
||||
continue;
|
||||
}
|
||||
let n = n.min(self.blocks - addr);
|
||||
if bm == 0 {
|
||||
free.push(Extent::new(self.base + addr * self.bs, n * self.bs));
|
||||
continue;
|
||||
}
|
||||
let Some(b) = self.block(bm) else {
|
||||
continue;
|
||||
};
|
||||
let mut run: Option<u64> = None;
|
||||
for k in 0..n {
|
||||
let used = b
|
||||
.get((k / 8) as usize)
|
||||
.map(|x| x >> (k % 8) & 1 == 1)
|
||||
.unwrap_or(true);
|
||||
match (used, run) {
|
||||
(false, None) => run = Some(k),
|
||||
(true, Some(s)) => {
|
||||
free.push(Extent::new(
|
||||
self.base + (addr + s) * self.bs,
|
||||
(k - s) * self.bs,
|
||||
));
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(s) = run {
|
||||
free.push(Extent::new(
|
||||
self.base + (addr + s) * self.bs,
|
||||
(n - s) * self.bs,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(merge_extents(free))
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u64> {
|
||||
m.get(&oid)?
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(x, _)| *x <= max_xid)
|
||||
.map(|(_, p)| *p)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- records
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Inode {
|
||||
parent: u64,
|
||||
private_id: u64,
|
||||
mtime: i64,
|
||||
ctime: i64,
|
||||
atime: i64,
|
||||
mode: u16,
|
||||
size: u64,
|
||||
name: Option<String>,
|
||||
has: bool,
|
||||
compressed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FileRec {
|
||||
inode: Inode,
|
||||
names: BTreeMap<u64, String>, // parent_id -> name
|
||||
}
|
||||
|
||||
type Files = HashMap<u64, FileRec>;
|
||||
type Extents = HashMap<u64, BTreeMap<u64, (u64, u64, u64)>>; // private_id -> logical -> (len, paddr, crypto)
|
||||
|
||||
fn ns(t: u64) -> i64 {
|
||||
(t / 1_000_000_000) as i64
|
||||
}
|
||||
|
||||
fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
|
||||
let hdr = le64(k, 0);
|
||||
let (id, ty) = (hdr & 0x0FFF_FFFF_FFFF_FFFF, (hdr >> 60) as u8);
|
||||
match ty {
|
||||
R_INODE if v.len() >= 92 => {
|
||||
let e = files.entry(id).or_default();
|
||||
let mut ino = Inode {
|
||||
parent: le64(v, 0),
|
||||
private_id: le64(v, 8),
|
||||
ctime: ns(le64(v, 16)), // creation timestamp
|
||||
mtime: ns(le64(v, 24)),
|
||||
atime: ns(le64(v, 40)),
|
||||
mode: le16(v, 80),
|
||||
size: le64(v, 84),
|
||||
has: true,
|
||||
..Default::default()
|
||||
};
|
||||
let flags = le64(v, 48);
|
||||
ino.compressed = le32(v, 68) & 0x20 != 0;
|
||||
let _ = flags;
|
||||
// xfields (extended fields)
|
||||
let xf = &v[92..];
|
||||
if xf.len() >= 4 {
|
||||
let n = le16(xf, 0) as usize;
|
||||
let mut p = 4 + n * 4;
|
||||
for i in 0..n {
|
||||
let (xt, xs) = (xf[4 + i * 4], le16(xf, 6 + i * 4) as usize);
|
||||
let data = xf.get(p..p + xs).unwrap_or(&[]);
|
||||
match xt {
|
||||
4 => {
|
||||
ino.name = Some(
|
||||
String::from_utf8_lossy(
|
||||
data.split(|&c| c == 0).next().unwrap_or(&[]),
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
8 if data.len() >= 8 => ino.size = le64(data, 0),
|
||||
_ => {}
|
||||
}
|
||||
p += xs.div_ceil(8) * 8;
|
||||
}
|
||||
}
|
||||
e.inode = ino;
|
||||
}
|
||||
R_DIR_REC if k.len() > 12 && v.len() >= 18 => {
|
||||
let nl = (le32(k, 8) & 0x3FF) as usize;
|
||||
if let Some(name) = k.get(12..12 + nl) {
|
||||
let name = String::from_utf8_lossy(name.split(|&c| c == 0).next().unwrap_or(&[]))
|
||||
.to_string();
|
||||
files.entry(le64(v, 0)).or_default().names.insert(id, name);
|
||||
}
|
||||
}
|
||||
R_FILE_EXTENT if k.len() >= 16 && v.len() >= 24 => {
|
||||
let len = le64(v, 0) & 0x00FF_FFFF_FFFF_FFFF;
|
||||
extents
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.insert(le64(k, 8), (len, le64(v, 8), le64(v, 16)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Apfs<'_> {
|
||||
/// Walks the filesystem B-tree starting from a virtual root OID.
|
||||
fn walk_fstree(
|
||||
&self,
|
||||
root_oid: u64,
|
||||
omap: &HashMap<u64, Vec<(u64, u64)>>,
|
||||
xid: u64,
|
||||
f: &mut dyn FnMut(&[u8], &[u8], u64),
|
||||
) {
|
||||
let Some(rp) = lookup(omap, root_oid, xid) else {
|
||||
return;
|
||||
};
|
||||
let mut stack = vec![(rp, 0u32)];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some((p, depth)) = stack.pop() {
|
||||
if depth > 10 || !seen.insert(p) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = self.checked_block(p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(n) = self.parse_node(&b, 16, 16) else {
|
||||
continue;
|
||||
};
|
||||
if n.level == 0 {
|
||||
for (k, v) in &n.entries {
|
||||
f(k, v, p);
|
||||
}
|
||||
} else {
|
||||
for (_, v) in n.entries.iter().rev() {
|
||||
if let Some(cp) = lookup(omap, le64(v, 0), xid) {
|
||||
stack.push((cp, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(files: &Files, mut id: u64) -> String {
|
||||
let mut parts = vec![];
|
||||
for _ in 0..64 {
|
||||
if id == 2 || id == 1 {
|
||||
break;
|
||||
}
|
||||
match files
|
||||
.get(&id)
|
||||
.and_then(|f| f.names.iter().next().map(|(p, n)| (*p, n.clone())))
|
||||
{
|
||||
Some((p, n)) => {
|
||||
parts.push(n);
|
||||
id = p;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
struct Volume {
|
||||
name: String,
|
||||
omap_paddr: u64,
|
||||
root_oid: u64,
|
||||
snap_tree_oid: u64,
|
||||
encrypted: bool,
|
||||
}
|
||||
|
||||
fn parse_volume(b: &[u8]) -> Option<Volume> {
|
||||
if &b[32..36] != b"APSB" {
|
||||
return None;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&b[704..960])
|
||||
.split('\0')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(Volume {
|
||||
name,
|
||||
omap_paddr: le64(b, 128),
|
||||
root_oid: le64(b, 136),
|
||||
snap_tree_oid: le64(b, 152),
|
||||
encrypted: le64(b, 264) & 1 == 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn open_container<'a>(src: &'a dyn Source, base: u64) -> Option<(Apfs<'a>, Vec<u8>)> {
|
||||
let b0 = read_vec(src, base, 4096).ok()?;
|
||||
if b0.len() < 4096 || &b0[32..36] != b"NXSB" {
|
||||
return None;
|
||||
}
|
||||
let bs = le32(&b0, 36) as u64;
|
||||
let blocks = le64(&b0, 40);
|
||||
if !(4096..=65536).contains(&bs) || !bs.is_power_of_two() {
|
||||
return None;
|
||||
}
|
||||
let a = Apfs {
|
||||
src,
|
||||
base,
|
||||
bs,
|
||||
blocks,
|
||||
};
|
||||
// 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();
|
||||
for i in 0..desc_blocks {
|
||||
if let Some(b) = a.checked_block(desc_base as u64 + i) {
|
||||
if &b[32..36] == b"NXSB" && le64(&b, 16) > le64(&best, 16) {
|
||||
best = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((a, best))
|
||||
}
|
||||
|
||||
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let _ = part_len;
|
||||
let mut res = VolumeScan::default();
|
||||
let Some((a, nx)) = open_container(ctx.src, base) else {
|
||||
return res;
|
||||
};
|
||||
let Some(cont_omap_block) = a.checked_block(le64(&nx, 160)) else {
|
||||
return res;
|
||||
};
|
||||
let _ = cont_omap_block;
|
||||
res.free = a.free_map(&nx);
|
||||
if res.free.is_none() {
|
||||
res.warnings.push(
|
||||
"APFS: não foi possível ler o mapa de espaço livre; a varredura profunda usará todo o volume."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let container_omap = a.omap(le64(&nx, 160));
|
||||
let max_fs = le32(&nx, 180).min(100) as usize;
|
||||
let mut vols: Vec<Volume> = vec![];
|
||||
for i in 0..max_fs {
|
||||
let oid = le64(&nx, 184 + i * 8);
|
||||
if oid == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = lookup(&container_omap, oid, u64::MAX) {
|
||||
if let Some(b) = a.checked_block(p) {
|
||||
if let Some(v) = parse_volume(&b) {
|
||||
vols.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let nvols = vols.len();
|
||||
for v in &vols {
|
||||
if v.encrypted {
|
||||
res.warnings.push(format!("APFS: o volume \"{}\" está cifrado (FileVault); os arquivos só são legíveis com a chave, então use a varredura por assinatura apenas se o volume já estiver aberto.", v.name));
|
||||
continue;
|
||||
}
|
||||
scan_volume(ctx, &a, v, nvols == 1, &mut res);
|
||||
if (ctx.cancelled)() {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
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;
|
||||
// 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();
|
||||
a.walk_fstree(v.root_oid, &omap, current_xid, &mut |k, val, node| {
|
||||
live_nodes.insert(node);
|
||||
add_record(&mut live, &mut live_ext, k, val);
|
||||
});
|
||||
// Volume snapshots.
|
||||
let mut stale: Files = HashMap::new();
|
||||
let mut stale_ext: Extents = HashMap::new();
|
||||
if v.snap_tree_oid != 0 {
|
||||
if let Some(sp) = lookup(&omap, v.snap_tree_oid, current_xid).or(Some(v.snap_tree_oid)) {
|
||||
let mut snaps: Vec<(u64, u64)> = vec![]; // (xid, sblock paddr)
|
||||
let mut stack = vec![sp];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(p) = stack.pop() {
|
||||
if !seen.insert(p) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = a.checked_block(p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(n) = a.parse_node(&b, 0, 0) else {
|
||||
continue;
|
||||
};
|
||||
if n.level == 0 {
|
||||
for (k, val) in &n.entries {
|
||||
let hdr = le64(k, 0);
|
||||
if (hdr >> 60) as u8 == R_SNAP_METADATA && val.len() >= 40 {
|
||||
snaps.push((hdr & 0x0FFF_FFFF_FFFF_FFFF, le64(val, 8)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (_, val) in &n.entries {
|
||||
// snapshot metadata tree: physical or virtual child references
|
||||
let c = le64(val, 0);
|
||||
stack.push(lookup(&omap, c, current_xid).unwrap_or(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (xid, sblock) in snaps {
|
||||
let Some(sb) = a.checked_block(sblock) else {
|
||||
continue;
|
||||
};
|
||||
let Some(sv) = parse_volume(&sb) else {
|
||||
continue;
|
||||
};
|
||||
a.walk_fstree(sv.root_oid, &omap, xid, &mut |k, val, _| {
|
||||
add_record(&mut stale, &mut stale_ext, k, val);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older evicted copy-on-write filesystem tree leaf nodes.
|
||||
if allow_stale_scan {
|
||||
let total = a.blocks;
|
||||
for p in 0..total {
|
||||
if p % 4096 == 0 {
|
||||
if (ctx.cancelled)() {
|
||||
return;
|
||||
}
|
||||
(ctx.progress)(p, total);
|
||||
}
|
||||
let Some(b) = a.block(p) else { continue };
|
||||
let ty = le32(&b, 24) & OBJ_TYPE_MASK;
|
||||
if (ty != T_BTREE_NODE && ty != T_BTREE_ROOT)
|
||||
|| le32(&b, 28) != SUBTYPE_FSTREE
|
||||
|| le16(&b, 34) != 0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if live_nodes.contains(&p) || fletcher64(&b[8..]) != le64(&b, 0) {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = a.parse_node(&b, 16, 16) {
|
||||
let _ = (n.oid, n.xid, n.flags);
|
||||
for (k, val) in &n.entries {
|
||||
add_record(&mut stale, &mut stale_ext, k, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
for id in ids {
|
||||
if live.get(id).is_some_and(|f| f.inode.has) {
|
||||
continue;
|
||||
}
|
||||
let rec = &stale[id];
|
||||
let ino = &rec.inode;
|
||||
if !ino.has || ino.mode & 0o170000 != 0o100000 || ino.size == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(ex) = stale_ext.get(&ino.private_id) else {
|
||||
continue;
|
||||
};
|
||||
if ex.values().any(|e| e.2 != 0) {
|
||||
continue; // encrypted extent
|
||||
}
|
||||
// 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 {
|
||||
Some(n) => (n.clone(), ino.parent),
|
||||
None => (format!("arquivo_{id}"), ino.parent),
|
||||
},
|
||||
};
|
||||
let merged: Files = stale
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.chain(live.iter().map(|(k, v)| (*k, v.clone())))
|
||||
.collect();
|
||||
let dir = Some(format!("/{}", path_of(&merged, parent)).replace("//", "/"));
|
||||
let mut extents: Vec<Extent> = vec![];
|
||||
let mut pos = 0u64;
|
||||
let mut ok = true;
|
||||
for (&lo, &(len, paddr, _)) in ex {
|
||||
if lo > pos {
|
||||
extents.push(Extent {
|
||||
offset: 0,
|
||||
len: lo - pos,
|
||||
sparse: true,
|
||||
});
|
||||
}
|
||||
if paddr == 0 {
|
||||
extents.push(Extent {
|
||||
offset: 0,
|
||||
len,
|
||||
sparse: true,
|
||||
});
|
||||
} else if paddr < a.blocks {
|
||||
extents.push(Extent::new(a.base + paddr * a.bs, len));
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
pos = lo + len;
|
||||
}
|
||||
if !ok || pos < ino.size.min(1) {
|
||||
continue;
|
||||
}
|
||||
let extents = super::fat::trim_extents(extents, ino.size);
|
||||
let Some(first) = extents.iter().find(|e| !e.sparse) else {
|
||||
continue;
|
||||
};
|
||||
if !seen_first.insert(first.offset) {
|
||||
continue;
|
||||
}
|
||||
let note = if ino.compressed {
|
||||
"arquivo comprimido pelo macOS (decmpfs): o conteúdo pode estar incompleto"
|
||||
} else {
|
||||
"versão antiga da árvore ou instantâneo do APFS"
|
||||
};
|
||||
let ts = |t: i64| (t > 0).then_some(t);
|
||||
let nf = NewFound {
|
||||
name,
|
||||
dir,
|
||||
size: ino.size,
|
||||
origin: Origin::Apfs,
|
||||
data: DataLoc::Extents { extents },
|
||||
modified: ts(ino.mtime),
|
||||
created: ts(ino.ctime),
|
||||
accessed: ts(ino.atime),
|
||||
base: if ino.compressed {
|
||||
Confidence::Low
|
||||
} else {
|
||||
Confidence::Medium
|
||||
},
|
||||
note: Some(note),
|
||||
};
|
||||
let f = make_found(ctx, nf);
|
||||
(ctx.emit)(f);
|
||||
}
|
||||
let _ = res;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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![];
|
||||
};
|
||||
let cont = a.omap(le64(&nx, 160));
|
||||
let mut out = vec![];
|
||||
for i in 0..le32(&nx, 180).min(100) as usize {
|
||||
let oid = le64(&nx, 184 + i * 8);
|
||||
let Some(p) = lookup(&cont, oid, u64::MAX) else {
|
||||
continue;
|
||||
};
|
||||
let Some(vb) = a.checked_block(p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(v) = parse_volume(&vb) else { continue };
|
||||
if v.encrypted {
|
||||
continue;
|
||||
}
|
||||
let omap = a.omap(v.omap_paddr);
|
||||
let mut files: Files = HashMap::new();
|
||||
let mut ext: Extents = HashMap::new();
|
||||
a.walk_fstree(v.root_oid, &omap, u64::MAX, &mut |k, val, _| {
|
||||
add_record(&mut files, &mut ext, k, val)
|
||||
});
|
||||
for (&id, f) in &files {
|
||||
if !f.inode.has || id <= 2 {
|
||||
continue;
|
||||
}
|
||||
out.push((
|
||||
path_of(&files, id),
|
||||
f.inode.size,
|
||||
f.inode.mode & 0o170000 == 0o040000,
|
||||
));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
//! 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};
|
||||
use crate::types::{Confidence, DataLoc, Extent, Origin, Packed, PackedCodec, Part};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
const MAGIC: &[u8; 8] = b"_BHRfS_M";
|
||||
const SB_OFF: u64 = 0x10000;
|
||||
|
||||
// key item types
|
||||
const INODE_ITEM: u8 = 1;
|
||||
const INODE_REF: u8 = 12;
|
||||
const EXTENT_DATA: u8 = 108;
|
||||
const ROOT_ITEM: u8 = 132;
|
||||
const EXTENT_ITEM: u8 = 168;
|
||||
const METADATA_ITEM: u8 = 169;
|
||||
const CHUNK_ITEM: u8 = 228;
|
||||
|
||||
fn le16(b: &[u8], o: usize) -> u16 {
|
||||
b.get(o..o + 2)
|
||||
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn le32(b: &[u8], o: usize) -> u32 {
|
||||
b.get(o..o + 4)
|
||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn le64(b: &[u8], o: usize) -> u64 {
|
||||
b.get(o..o + 8)
|
||||
.map(|s| u64::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn crc32c(data: &[u8]) -> u32 {
|
||||
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
|
||||
let t = TABLE.get_or_init(|| {
|
||||
let mut t = [0u32; 256];
|
||||
for i in 0..256u32 {
|
||||
let mut c = i;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 {
|
||||
(c >> 1) ^ 0x82F6_3B78
|
||||
} else {
|
||||
c >> 1
|
||||
};
|
||||
}
|
||||
t[i as usize] = c;
|
||||
}
|
||||
t
|
||||
});
|
||||
let mut c = !0u32;
|
||||
for &b in data {
|
||||
c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Key {
|
||||
pub objectid: u64,
|
||||
pub ty: u8,
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
fn key_at(b: &[u8], o: usize) -> Key {
|
||||
Key {
|
||||
objectid: le64(b, o),
|
||||
ty: b.get(o + 8).copied().unwrap_or(0),
|
||||
offset: le64(b, o + 9),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Chunk {
|
||||
logical: u64,
|
||||
len: u64,
|
||||
ty: u64,
|
||||
/// Physical byte offsets of each stripe replica.
|
||||
phys: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum NodeKind {
|
||||
Leaf(Vec<(Key, Vec<u8>)>),
|
||||
Internal(Vec<(Key, u64)>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Node {
|
||||
bytenr: u64,
|
||||
generation: u64,
|
||||
owner: u64,
|
||||
kind: NodeKind,
|
||||
}
|
||||
|
||||
struct Fs<'a> {
|
||||
src: &'a dyn Source,
|
||||
base: u64,
|
||||
nodesize: u64,
|
||||
sectorsize: u64,
|
||||
fsid: [u8; 16],
|
||||
chunks: Vec<Chunk>,
|
||||
root: u64,
|
||||
chunk_root: u64,
|
||||
}
|
||||
|
||||
impl<'a> Fs<'a> {
|
||||
fn open(src: &'a dyn Source, base: u64) -> Option<Fs<'a>> {
|
||||
let sb = read_vec(src, base + SB_OFF, 4096).ok()?;
|
||||
if sb.len() < 4096 || &sb[64..72] != MAGIC {
|
||||
return None;
|
||||
}
|
||||
let csum_type = le16(&sb, 196);
|
||||
if csum_type == 0 && crc32c(&sb[32..4096]) != le32(&sb, 0) {
|
||||
return None;
|
||||
}
|
||||
let nodesize = le32(&sb, 148) as u64;
|
||||
let sectorsize = le32(&sb, 144) as u64;
|
||||
if !nodesize.is_power_of_two()
|
||||
|| !(4096..=65536).contains(&nodesize)
|
||||
|| !sectorsize.is_power_of_two()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut fsid = [0u8; 16];
|
||||
fsid.copy_from_slice(&sb[32..48]);
|
||||
let mut fs = Fs {
|
||||
src,
|
||||
base,
|
||||
nodesize,
|
||||
sectorsize,
|
||||
fsid,
|
||||
chunks: vec![],
|
||||
root: le64(&sb, 80),
|
||||
chunk_root: le64(&sb, 88),
|
||||
};
|
||||
// sys_chunk_array
|
||||
let n = le32(&sb, 160) as usize;
|
||||
let arr = sb.get(0x32B..0x32B + n.min(2048))?;
|
||||
let mut p = 0;
|
||||
while p + 17 + 48 <= arr.len() {
|
||||
let k = key_at(arr, p);
|
||||
if k.ty != CHUNK_ITEM {
|
||||
break;
|
||||
}
|
||||
let (ch, used) = parse_chunk(&arr[p + 17..], k.offset)?;
|
||||
fs.chunks.push(ch);
|
||||
p += 17 + used;
|
||||
}
|
||||
// full chunk tree
|
||||
let mut more = vec![];
|
||||
let cr = fs.chunk_root;
|
||||
fs.walk(cr, &mut |k, d| {
|
||||
if k.ty == CHUNK_ITEM {
|
||||
if let Some((ch, _)) = parse_chunk(d, k.offset) {
|
||||
more.push(ch);
|
||||
}
|
||||
}
|
||||
});
|
||||
for c in more {
|
||||
if !fs.chunks.iter().any(|x| x.logical == c.logical) {
|
||||
fs.chunks.push(c);
|
||||
}
|
||||
}
|
||||
fs.chunks.sort_by_key(|c| c.logical);
|
||||
Some(fs)
|
||||
}
|
||||
|
||||
fn phys(&self, logical: u64) -> Option<u64> {
|
||||
let i = self
|
||||
.chunks
|
||||
.partition_point(|c| c.logical + c.len <= logical);
|
||||
let c = self.chunks.get(i)?;
|
||||
(c.logical <= logical)
|
||||
.then(|| {
|
||||
c.phys
|
||||
.first()
|
||||
.map(|p| self.base + p + (logical - c.logical))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn read_node(&self, logical: u64) -> Option<Node> {
|
||||
let p = self.phys(logical)?;
|
||||
let raw = read_vec(self.src, p, self.nodesize as usize).ok()?;
|
||||
self.parse_node(&raw, Some(logical))
|
||||
}
|
||||
|
||||
fn parse_node(&self, raw: &[u8], expect: Option<u64>) -> Option<Node> {
|
||||
if raw.len() as u64 != self.nodesize || raw[32..48] != self.fsid {
|
||||
return None;
|
||||
}
|
||||
let bytenr = le64(raw, 48);
|
||||
if expect.is_some_and(|e| e != bytenr) {
|
||||
return None;
|
||||
}
|
||||
if crc32c(&raw[32..]) != le32(raw, 0) {
|
||||
return None;
|
||||
}
|
||||
let nritems = le32(raw, 96) as usize;
|
||||
let level = raw[100];
|
||||
if nritems > (self.nodesize as usize) / 25 {
|
||||
return None;
|
||||
}
|
||||
let hdr = 101usize;
|
||||
let kind = if level == 0 {
|
||||
let mut items = Vec::with_capacity(nritems);
|
||||
for i in 0..nritems {
|
||||
let o = hdr + i * 25;
|
||||
let k = key_at(raw, o);
|
||||
let (off, size) = (le32(raw, o + 17) as usize, le32(raw, o + 21) as usize);
|
||||
let s = hdr + off;
|
||||
if s + size > raw.len() {
|
||||
return None;
|
||||
}
|
||||
items.push((k, raw[s..s + size].to_vec()));
|
||||
}
|
||||
NodeKind::Leaf(items)
|
||||
} else {
|
||||
let mut ptrs = Vec::with_capacity(nritems);
|
||||
for i in 0..nritems {
|
||||
let o = hdr + i * 33;
|
||||
if o + 33 > raw.len() {
|
||||
return None;
|
||||
}
|
||||
ptrs.push((key_at(raw, o), le64(raw, o + 17)));
|
||||
}
|
||||
NodeKind::Internal(ptrs)
|
||||
};
|
||||
Some(Node {
|
||||
bytenr,
|
||||
generation: le64(raw, 80),
|
||||
owner: le64(raw, 88),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
fn walk_nodes(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8]), nodes: &mut dyn FnMut(&Node)) {
|
||||
let mut seen = HashSet::new();
|
||||
self.walk_inner(root, f, &mut seen, nodes, 0);
|
||||
}
|
||||
|
||||
fn walk_inner(
|
||||
&self,
|
||||
at: u64,
|
||||
f: &mut dyn FnMut(&Key, &[u8]),
|
||||
seen: &mut HashSet<u64>,
|
||||
nodes: &mut dyn FnMut(&Node),
|
||||
depth: u32,
|
||||
) {
|
||||
if depth > 10 || !seen.insert(at) {
|
||||
return;
|
||||
}
|
||||
let Some(n) = self.read_node(at) else { return };
|
||||
nodes(&n);
|
||||
match &n.kind {
|
||||
NodeKind::Leaf(items) => {
|
||||
for (k, d) in items {
|
||||
f(k, d);
|
||||
}
|
||||
}
|
||||
NodeKind::Internal(ptrs) => {
|
||||
for (_, child) in ptrs {
|
||||
self.walk_inner(*child, f, seen, nodes, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_chunk(d: &[u8], logical: u64) -> Option<(Chunk, usize)> {
|
||||
if d.len() < 48 {
|
||||
return None;
|
||||
}
|
||||
let len = le64(d, 0);
|
||||
let ty = le64(d, 24);
|
||||
let nstripes = le16(d, 44) as usize;
|
||||
if nstripes == 0 || nstripes > 32 || d.len() < 48 + nstripes * 32 {
|
||||
return None;
|
||||
}
|
||||
let phys = (0..nstripes).map(|i| le64(d, 48 + i * 32 + 8)).collect();
|
||||
Some((
|
||||
Chunk {
|
||||
logical,
|
||||
len,
|
||||
ty,
|
||||
phys,
|
||||
},
|
||||
48 + nstripes * 32,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- inodes
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct InodeData {
|
||||
gen: u64,
|
||||
size: u64,
|
||||
mode: u32,
|
||||
mtime: i64,
|
||||
ctime: i64,
|
||||
atime: i64,
|
||||
has_inode: bool,
|
||||
refs: HashMap<u64, String>,
|
||||
/// `file_offset -> (node_generation, extent_item)`
|
||||
extents: BTreeMap<u64, (u64, Vec<u8>)>,
|
||||
}
|
||||
|
||||
type Files = HashMap<(u64, u64), InodeData>;
|
||||
|
||||
fn add_item(files: &mut Files, owner: u64, gen: u64, k: &Key, d: &[u8]) {
|
||||
match k.ty {
|
||||
INODE_ITEM if d.len() >= 160 => {
|
||||
let e = files.entry((owner, k.objectid)).or_default();
|
||||
let g = le64(d, 0);
|
||||
if !e.has_inode || g >= e.gen {
|
||||
e.has_inode = true;
|
||||
e.gen = g;
|
||||
e.size = le64(d, 16);
|
||||
e.mode = le32(d, 52);
|
||||
e.atime = le64(d, 112) as i64;
|
||||
e.ctime = le64(d, 124) as i64;
|
||||
e.mtime = le64(d, 136) as i64;
|
||||
}
|
||||
}
|
||||
INODE_REF if d.len() >= 10 => {
|
||||
let nlen = le16(d, 8) as usize;
|
||||
if let Some(n) = d.get(10..10 + nlen) {
|
||||
files
|
||||
.entry((owner, k.objectid))
|
||||
.or_default()
|
||||
.refs
|
||||
.insert(k.offset, String::from_utf8_lossy(n).to_string());
|
||||
}
|
||||
}
|
||||
EXTENT_DATA if d.len() >= 21 => {
|
||||
let e = files.entry((owner, k.objectid)).or_default();
|
||||
match e.extents.get(&k.offset) {
|
||||
Some((g, _)) if *g > gen => {}
|
||||
_ => {
|
||||
e.extents.insert(k.offset, (gen, d.to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fs_tree(id: u64) -> bool {
|
||||
id == 5 || (256..(1u64 << 48)).contains(&id)
|
||||
}
|
||||
|
||||
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let _ = part_len;
|
||||
let mut res = VolumeScan::default();
|
||||
let Some(fs) = Fs::open(ctx.src, base) else {
|
||||
return res;
|
||||
};
|
||||
if fs
|
||||
.chunks
|
||||
.iter()
|
||||
.any(|c| c.ty & 0x1 << 3 != 0 || c.ty & 0x10 != 0 || c.ty & 0x100 != 0 || c.ty & 0x200 != 0)
|
||||
{
|
||||
res.warnings.push("Btrfs em RAID 0/10/5/6: a leitura de metadados ainda não suporta esses perfis; usando só assinaturas.".into());
|
||||
return res;
|
||||
}
|
||||
|
||||
// 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| {
|
||||
if k.ty == ROOT_ITEM && d.len() >= 239 {
|
||||
let bytenr = le64(d, 176);
|
||||
if k.objectid == 2 {
|
||||
extent_root = bytenr;
|
||||
} else if is_fs_tree(k.objectid) {
|
||||
roots.push((k.objectid, bytenr));
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut live_nodes: HashSet<u64> = HashSet::new();
|
||||
let mut live: Files = HashMap::new();
|
||||
for &(id, bytenr) in &roots {
|
||||
let mut tmp: Vec<(Key, Vec<u8>)> = vec![];
|
||||
let mut gens: HashMap<u64, u64> = HashMap::new();
|
||||
let mut seen_nodes: Vec<(u64, u64)> = vec![];
|
||||
{
|
||||
let mut items = |k: &Key, d: &[u8]| tmp.push((*k, d.to_vec()));
|
||||
let mut nodes = |n: &Node| {
|
||||
seen_nodes.push((n.bytenr, n.generation));
|
||||
};
|
||||
fs.walk_nodes(bytenr, &mut items, &mut nodes);
|
||||
}
|
||||
for (b, g) in seen_nodes {
|
||||
live_nodes.insert(b);
|
||||
gens.insert(b, g);
|
||||
}
|
||||
for (k, d) in &tmp {
|
||||
add_item(&mut live, id, u64::MAX, k, d);
|
||||
}
|
||||
if (ctx.cancelled)() {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
fs.walk(extent_root, &mut |k, _| {
|
||||
if k.ty == EXTENT_ITEM {
|
||||
allocated.push(Extent::new(k.objectid, k.offset));
|
||||
} else if k.ty == METADATA_ITEM {
|
||||
allocated.push(Extent::new(k.objectid, nodesize));
|
||||
}
|
||||
});
|
||||
}
|
||||
let allocated = merge_extents(allocated);
|
||||
let mut free: Vec<Extent> = vec![];
|
||||
for c in fs.chunks.iter().filter(|c| c.ty & 0x1 != 0) {
|
||||
// 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..] {
|
||||
if e.offset >= c.logical + c.len {
|
||||
break;
|
||||
}
|
||||
if e.offset > cur {
|
||||
if let Some(p) = fs.phys(cur) {
|
||||
free.push(Extent::new(p, e.offset - cur));
|
||||
}
|
||||
}
|
||||
cur = cur.max(e.end());
|
||||
}
|
||||
if cur < c.logical + c.len {
|
||||
if let Some(p) = fs.phys(cur) {
|
||||
free.push(Extent::new(p, c.logical + c.len - cur));
|
||||
}
|
||||
}
|
||||
}
|
||||
res.free = Some(merge_extents(free));
|
||||
|
||||
// 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;
|
||||
let md_chunks: Vec<Chunk> = fs
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|c| c.ty & 0x4 != 0)
|
||||
.cloned()
|
||||
.collect();
|
||||
let total_md: u64 = md_chunks.iter().map(|c| c.len).sum();
|
||||
let mut done_md = 0u64;
|
||||
for c in &md_chunks {
|
||||
let Some(p0) = c.phys.first().copied() else {
|
||||
continue;
|
||||
};
|
||||
let mut off = 0u64;
|
||||
let block = 8usize << 20;
|
||||
while off < c.len {
|
||||
if (ctx.cancelled)() {
|
||||
return res;
|
||||
}
|
||||
let n = ((c.len - off) as usize).min(block + ns);
|
||||
let Ok(buf) = read_vec(ctx.src, fs.base + p0 + off, n) else {
|
||||
break;
|
||||
};
|
||||
let mut i = 0usize;
|
||||
while i + ns <= buf.len() && (off + i as u64) < c.len {
|
||||
let logical = c.logical + off + i as u64;
|
||||
// cheap header sanity check before computing CRC32C
|
||||
if buf[i + 32..i + 48] == fs.fsid
|
||||
&& le64(&buf[i..], 48) == logical
|
||||
&& buf[i + 100] == 0
|
||||
{
|
||||
if let Some(node) = fs.parse_node(&buf[i..i + ns], Some(logical)) {
|
||||
if is_fs_tree(node.owner) && !live_nodes.contains(&logical) {
|
||||
if let NodeKind::Leaf(items) = &node.kind {
|
||||
for (k, d) in items {
|
||||
add_item(&mut stale, node.owner, node.generation, k, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += step;
|
||||
}
|
||||
off += block as u64;
|
||||
done_md += block as u64;
|
||||
(ctx.progress)(done_md.min(total_md), total_md.max(1));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if ino == 256 {
|
||||
break;
|
||||
}
|
||||
let e = stale.get(&(owner, ino)).or_else(|| live.get(&(owner, ino)));
|
||||
match e.and_then(|e| e.refs.iter().next()) {
|
||||
Some((parent, name)) => {
|
||||
parts.push(name.clone());
|
||||
ino = *parent;
|
||||
}
|
||||
None => {
|
||||
parts.push("(pasta perdida)".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
parts.join("/")
|
||||
};
|
||||
let mut keys: Vec<&(u64, u64)> = stale.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
let (owner, ino) = *key;
|
||||
if live.contains_key(&(owner, ino)) {
|
||||
continue;
|
||||
}
|
||||
let e = &stale[&(owner, ino)];
|
||||
if !e.has_inode || e.mode & 0o170000 != 0o100000 || e.size == 0 || e.extents.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((name, parent)) = e.refs.iter().next().map(|(p, n)| (n.clone(), *p)) else {
|
||||
continue;
|
||||
};
|
||||
let dir = {
|
||||
let p = path_of(owner, parent);
|
||||
Some(if p.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{p}")
|
||||
})
|
||||
};
|
||||
let Some((data, any_alloc, all_ok)) = build_data(&fs, e, &allocated) else {
|
||||
continue;
|
||||
};
|
||||
let (conf, note) = if !all_ok {
|
||||
(
|
||||
Confidence::Low,
|
||||
"faltam trechos do arquivo na versão antiga da árvore".to_string(),
|
||||
)
|
||||
} else if any_alloc {
|
||||
(Confidence::Medium, "versão antiga do Btrfs; alguns blocos ainda constam como alocados (podem ser de outro arquivo)".to_string())
|
||||
} else {
|
||||
(
|
||||
Confidence::High,
|
||||
"versão antiga da árvore do Btrfs; blocos de dados livres".to_string(),
|
||||
)
|
||||
};
|
||||
let ts = |t: i64| (t > 0).then_some(t);
|
||||
let nf = NewFound {
|
||||
name,
|
||||
dir,
|
||||
size: e.size,
|
||||
origin: Origin::Btrfs,
|
||||
data,
|
||||
modified: ts(e.mtime),
|
||||
created: ts(e.ctime),
|
||||
accessed: ts(e.atime),
|
||||
base: conf,
|
||||
note: Some(¬e),
|
||||
};
|
||||
let f = make_found(ctx, nf);
|
||||
(ctx.emit)(f);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let mut any_alloc = false;
|
||||
let mut all_ok = true;
|
||||
let mut plain_only = true;
|
||||
let mut hole = |parts: &mut Vec<Part>, len: u64| {
|
||||
if len > 0 {
|
||||
parts.push(Part::Run { offset: 0, len });
|
||||
// placeholder: offset 0 + sparse hole handled below
|
||||
}
|
||||
};
|
||||
let _ = &mut hole;
|
||||
let mut sparse_marks: Vec<usize> = vec![];
|
||||
for (&foff, (_, d)) in &e.extents {
|
||||
if foff >= e.size {
|
||||
break;
|
||||
}
|
||||
if foff > pos {
|
||||
sparse_marks.push(parts.len());
|
||||
parts.push(Part::Run {
|
||||
offset: 0,
|
||||
len: foff - pos,
|
||||
});
|
||||
plain_only = false;
|
||||
pos = foff;
|
||||
}
|
||||
let ty = d[20];
|
||||
let comp = d[16];
|
||||
if ty == 0 {
|
||||
// inline
|
||||
let raw = &d[21..];
|
||||
let ram = le64(d, 8) as usize;
|
||||
let bytes = match comp {
|
||||
0 => raw.to_vec(),
|
||||
c => decode(c, raw, ram)?,
|
||||
};
|
||||
let len = (bytes.len() as u64).min(e.size - foff);
|
||||
parts.push(Part::Bytes {
|
||||
bytes: bytes[..len as usize].to_vec(),
|
||||
});
|
||||
plain_only = false;
|
||||
pos = foff + len;
|
||||
continue;
|
||||
}
|
||||
if d.len() < 53 {
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
let (disk, disk_len, offset, num) = (le64(d, 21), le64(d, 29), le64(d, 37), le64(d, 45));
|
||||
let num = num.min(e.size.saturating_sub(foff));
|
||||
if disk == 0 || ty == 2 {
|
||||
sparse_marks.push(parts.len());
|
||||
parts.push(Part::Run {
|
||||
offset: 0,
|
||||
len: num,
|
||||
});
|
||||
plain_only = false;
|
||||
} else {
|
||||
let Some(phys) = fs.phys(disk) else {
|
||||
all_ok = false;
|
||||
break;
|
||||
};
|
||||
let overlaps = {
|
||||
let i = allocated.partition_point(|x| x.end() <= disk);
|
||||
allocated.get(i).is_some_and(|x| x.offset < disk + disk_len)
|
||||
};
|
||||
any_alloc |= overlaps;
|
||||
if comp == 0 {
|
||||
parts.push(Part::Run {
|
||||
offset: phys + offset,
|
||||
len: num,
|
||||
});
|
||||
} else {
|
||||
let codec = match comp {
|
||||
1 => PackedCodec::Zlib,
|
||||
2 => PackedCodec::Zstd,
|
||||
3..=6 => PackedCodec::Lzo,
|
||||
_ => {
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
parts.push(Part::Packed(Packed {
|
||||
offset: phys,
|
||||
disk_len,
|
||||
ram_len: le64(d, 8),
|
||||
skip: offset,
|
||||
len: num,
|
||||
codec,
|
||||
}));
|
||||
plain_only = false;
|
||||
}
|
||||
}
|
||||
pos = foff + num;
|
||||
}
|
||||
if pos < e.size {
|
||||
all_ok = false;
|
||||
}
|
||||
if plain_only {
|
||||
let ex: Vec<Extent> = parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let Part::Run { offset, len } = p {
|
||||
Some(Extent::new(*offset, *len))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Some((DataLoc::Extents { extents: ex }, any_alloc, all_ok));
|
||||
}
|
||||
// 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) {
|
||||
if let Part::Run { len, .. } = p {
|
||||
out.push(Part::Zeros { len });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
Some((DataLoc::Parts { parts: out }, any_alloc, all_ok))
|
||||
}
|
||||
|
||||
fn decode(codec: u8, raw: &[u8], ram: usize) -> Option<Vec<u8>> {
|
||||
match codec {
|
||||
1 => {
|
||||
use std::io::Read;
|
||||
let mut v = Vec::with_capacity(ram);
|
||||
flate2::read::ZlibDecoder::new(raw)
|
||||
.read_to_end(&mut v)
|
||||
.ok()?;
|
||||
Some(v)
|
||||
}
|
||||
2 => {
|
||||
use std::io::Read;
|
||||
let mut dec = ruzstd::decoding::StreamingDecoder::new(raw).ok()?;
|
||||
let mut v = Vec::with_capacity(ram);
|
||||
dec.read_to_end(&mut v).ok()?;
|
||||
Some(v)
|
||||
}
|
||||
_ => lzo_btrfs(raw, ram),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let total = le32(raw, 0) as usize;
|
||||
let mut out = Vec::with_capacity(ram);
|
||||
let mut p = 4usize;
|
||||
while p + 4 <= raw.len().min(total) && out.len() < ram {
|
||||
// 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;
|
||||
continue;
|
||||
}
|
||||
let seg = le32(raw, p) as usize;
|
||||
p += 4;
|
||||
let chunk = raw.get(p..p + seg)?;
|
||||
let want = (ram - out.len()).min(4096);
|
||||
out.extend(lzo1x_decompress(chunk, want)?);
|
||||
p += seg;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let byte = |i: usize| src.get(i).copied().map(|b| b as usize);
|
||||
let lits = |out: &mut Vec<u8>, ip: &mut usize, n: usize| -> Option<()> {
|
||||
out.extend_from_slice(src.get(*ip..*ip + n)?);
|
||||
*ip += n;
|
||||
Some(())
|
||||
};
|
||||
let ext = |ip: &mut usize, base: usize| -> Option<usize> {
|
||||
let mut extra = 0usize;
|
||||
while byte(*ip)? == 0 {
|
||||
extra += 255;
|
||||
*ip += 1;
|
||||
}
|
||||
let v = base + extra + byte(*ip)?;
|
||||
*ip += 1;
|
||||
Some(v)
|
||||
};
|
||||
// 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;
|
||||
ip = 1;
|
||||
lits(&mut out, &mut ip, t)?;
|
||||
state = if t < 4 { t } else { 4 };
|
||||
} else {
|
||||
state = 0;
|
||||
}
|
||||
loop {
|
||||
if out.len() > max_out + 4096 {
|
||||
return None;
|
||||
}
|
||||
let t = byte(ip)?;
|
||||
ip += 1;
|
||||
if t < 16 {
|
||||
if state == 0 {
|
||||
let n = if t == 0 { ext(&mut ip, 15)? } else { t } + 3;
|
||||
lits(&mut out, &mut ip, n)?;
|
||||
state = 4;
|
||||
continue;
|
||||
}
|
||||
let h = byte(ip)?;
|
||||
ip += 1;
|
||||
let (dist, len) = if state == 4 {
|
||||
((t >> 2) + (h << 2) + 2049, 3)
|
||||
} else {
|
||||
((t >> 2) + (h << 2) + 1, 2)
|
||||
};
|
||||
copy_match(&mut out, dist, len)?;
|
||||
let next = t & 3;
|
||||
lits(&mut out, &mut ip, next)?;
|
||||
state = next;
|
||||
} else if t >= 64 {
|
||||
let h = byte(ip)?;
|
||||
ip += 1;
|
||||
copy_match(&mut out, 1 + ((t >> 2) & 7) + (h << 3), (t >> 5) + 1)?;
|
||||
let next = t & 3;
|
||||
lits(&mut out, &mut ip, next)?;
|
||||
state = next;
|
||||
} else if t >= 32 {
|
||||
let len = if t & 31 == 0 {
|
||||
ext(&mut ip, 31)?
|
||||
} else {
|
||||
t & 31
|
||||
} + 2;
|
||||
let b = (byte(ip)?) | (byte(ip + 1)? << 8);
|
||||
ip += 2;
|
||||
copy_match(&mut out, 1 + (b >> 2), len)?;
|
||||
let next = b & 3;
|
||||
lits(&mut out, &mut ip, next)?;
|
||||
state = next;
|
||||
} else {
|
||||
let len = if t & 7 == 0 { ext(&mut ip, 7)? } else { t & 7 } + 2;
|
||||
let b = (byte(ip)?) | (byte(ip + 1)? << 8);
|
||||
ip += 2;
|
||||
let d = ((t & 8) << 11) + (b >> 2);
|
||||
if d == 0 {
|
||||
return Some(out);
|
||||
}
|
||||
copy_match(&mut out, d + 0x4000, len)?;
|
||||
let next = b & 3;
|
||||
lits(&mut out, &mut ip, next)?;
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_match(out: &mut Vec<u8>, dist: usize, len: usize) -> Option<()> {
|
||||
if dist == 0 || dist > out.len() {
|
||||
return None;
|
||||
}
|
||||
let start = out.len() - dist;
|
||||
for i in 0..len {
|
||||
let b = out[start + i];
|
||||
out.push(b);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// 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![];
|
||||
};
|
||||
let mut roots: Vec<(u64, u64)> = vec![];
|
||||
fs.walk(fs.root, &mut |k, d| {
|
||||
if k.ty == ROOT_ITEM && d.len() >= 239 && is_fs_tree(k.objectid) {
|
||||
roots.push((k.objectid, le64(d, 176)));
|
||||
}
|
||||
});
|
||||
let mut files: Files = HashMap::new();
|
||||
for (id, b) in roots {
|
||||
fs.walk(b, &mut |k, d| add_item(&mut files, id, u64::MAX, k, d));
|
||||
}
|
||||
let mut out = vec![];
|
||||
for (&(owner, ino), e) in &files {
|
||||
if !e.has_inode || ino == 256 {
|
||||
continue;
|
||||
}
|
||||
let mut parts = vec![];
|
||||
let mut cur = ino;
|
||||
for _ in 0..64 {
|
||||
if cur == 256 {
|
||||
break;
|
||||
}
|
||||
match files.get(&(owner, cur)).and_then(|x| x.refs.iter().next()) {
|
||||
Some((p, n)) => {
|
||||
parts.push(n.clone());
|
||||
cur = *p;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
out.push((parts.join("/"), e.size, e.mode & 0o170000 == 0o040000));
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Root locations of core Btrfs trees, used by tests that simulate COW deletions.
|
||||
pub struct TreeLocations {
|
||||
pub nodesize: u64,
|
||||
/// Physical byte offset (in the source) of each tree root node.
|
||||
pub root_tree: u64,
|
||||
pub fs_tree: u64,
|
||||
pub extent_tree: u64,
|
||||
/// Corresponding logical `bytenr` addresses.
|
||||
pub root_tree_logical: u64,
|
||||
pub fs_tree_logical: u64,
|
||||
pub extent_tree_logical: u64,
|
||||
/// Translates a logical `bytenr` to a physical source offset.
|
||||
pub chunks: Vec<(u64, u64, u64)>,
|
||||
}
|
||||
|
||||
pub fn locate_trees(src: &dyn Source, base: u64) -> Option<TreeLocations> {
|
||||
let fs = Fs::open(src, base)?;
|
||||
let (mut fs_t, mut ext_t) = (0u64, 0u64);
|
||||
fs.walk(fs.root, &mut |k, d| {
|
||||
if k.ty == ROOT_ITEM && d.len() >= 239 {
|
||||
if k.objectid == 5 {
|
||||
fs_t = le64(d, 176);
|
||||
} else if k.objectid == 2 {
|
||||
ext_t = le64(d, 176);
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(TreeLocations {
|
||||
nodesize: fs.nodesize,
|
||||
root_tree: fs.phys(fs.root)?,
|
||||
fs_tree: fs.phys(fs_t)?,
|
||||
extent_tree: fs.phys(ext_t)?,
|
||||
root_tree_logical: fs.root,
|
||||
fs_tree_logical: fs_t,
|
||||
extent_tree_logical: ext_t,
|
||||
chunks: fs
|
||||
.chunks
|
||||
.iter()
|
||||
.filter_map(|c| c.phys.first().map(|p| (c.logical, c.len, fs.base + p)))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -174,6 +174,14 @@ fn walk(
|
||||
let mdate = u16::from_le_bytes([e[14], e[15]]);
|
||||
let mtime = u16::from_le_bytes([e[12], e[13]]);
|
||||
let mod_unix = super::dos_to_unix(mdate, mtime);
|
||||
let cre_unix = super::dos_to_unix(
|
||||
u16::from_le_bytes([e[10], e[11]]),
|
||||
u16::from_le_bytes([e[8], e[9]]),
|
||||
);
|
||||
let acc_unix = super::dos_to_unix(
|
||||
u16::from_le_bytes([e[18], e[19]]),
|
||||
u16::from_le_bytes([e[16], e[17]]),
|
||||
);
|
||||
let mut stream: Option<&[u8]> = None;
|
||||
let mut name_u16: Vec<u8> = vec![];
|
||||
for s in 1..=secondary.min(19) {
|
||||
@@ -206,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
|
||||
@@ -259,6 +267,8 @@ fn walk(
|
||||
origin: Origin::Exfat,
|
||||
data: DataLoc::Extents { extents: ex },
|
||||
modified: mod_unix,
|
||||
created: cre_unix,
|
||||
accessed: acc_unix,
|
||||
base: conf,
|
||||
note,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -237,6 +237,11 @@ impl Walk<'_, '_, '_, '_> {
|
||||
u16::from_le_bytes([e[24], e[25]]),
|
||||
u16::from_le_bytes([e[22], e[23]]),
|
||||
);
|
||||
let ctime = super::dos_to_unix(
|
||||
u16::from_le_bytes([e[16], e[17]]),
|
||||
u16::from_le_bytes([e[14], e[15]]),
|
||||
);
|
||||
let atime = super::dos_to_unix(u16::from_le_bytes([e[18], e[19]]), 0);
|
||||
self.count += 1;
|
||||
if self.count % 512 == 0 {
|
||||
(self.ctx.progress)(self.count, 0);
|
||||
@@ -252,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
|
||||
@@ -277,7 +282,7 @@ impl Walk<'_, '_, '_, '_> {
|
||||
if size == 0 || !self.fat.valid(start) {
|
||||
continue;
|
||||
}
|
||||
self.deleted_file(&name, path, start, size, mtime);
|
||||
self.deleted_file(&name, path, start, size, (ctime, mtime, atime));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +296,14 @@ impl Walk<'_, '_, '_, '_> {
|
||||
v
|
||||
}
|
||||
|
||||
fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option<i64>) {
|
||||
fn deleted_file(
|
||||
&mut self,
|
||||
name: &str,
|
||||
dir: &str,
|
||||
start: u32,
|
||||
size: u64,
|
||||
times: (Option<i64>, Option<i64>, Option<i64>),
|
||||
) {
|
||||
let fat = self.fat;
|
||||
let cb = fat.cluster_bytes();
|
||||
let need = size.div_ceil(cb);
|
||||
@@ -336,7 +348,9 @@ impl Walk<'_, '_, '_, '_> {
|
||||
data: DataLoc::Extents {
|
||||
extents: trim_extents(extents, size),
|
||||
},
|
||||
modified: mtime,
|
||||
modified: times.1,
|
||||
created: times.0,
|
||||
accessed: times.2,
|
||||
base: conf,
|
||||
note,
|
||||
};
|
||||
@@ -345,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![];
|
||||
@@ -402,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;
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
//! HFS+ / HFSX (including volumes embedded inside a classic HFS wrapper).
|
||||
//!
|
||||
//! 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};
|
||||
use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
const HFS_EPOCH: i64 = 2_082_844_800;
|
||||
|
||||
fn be16(b: &[u8], o: usize) -> u16 {
|
||||
b.get(o..o + 2)
|
||||
.map(|s| u16::from_be_bytes([s[0], s[1]]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
b.get(o..o + 4)
|
||||
.map(|s| u32::from_be_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn be64(b: &[u8], o: usize) -> u64 {
|
||||
b.get(o..o + 8)
|
||||
.map(|s| u64::from_be_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Fork {
|
||||
size: u64,
|
||||
blocks: u32,
|
||||
extents: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
fn parse_fork(b: &[u8]) -> Fork {
|
||||
let mut f = Fork {
|
||||
size: be64(b, 0),
|
||||
blocks: be32(b, 12),
|
||||
extents: vec![],
|
||||
};
|
||||
for i in 0..8 {
|
||||
let (s, c) = (be32(b, 16 + i * 8), be32(b, 20 + i * 8));
|
||||
if c != 0 {
|
||||
f.extents.push((s, c));
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
match &h[0..2] {
|
||||
b"H+" | b"HX" => Some(base),
|
||||
b"BD" if &h[124..126] == b"H+" => {
|
||||
let alblk = be32(&h, 20) as u64;
|
||||
let alblst = be16(&h, 28) as u64;
|
||||
let start = be16(&h, 126) as u64;
|
||||
Some(base + alblst * 512 + start * alblk)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
struct Vol<'a> {
|
||||
src: &'a dyn Source,
|
||||
base: u64,
|
||||
bs: u64,
|
||||
total: u64,
|
||||
catalog: Fork,
|
||||
extents_file: Fork,
|
||||
alloc: Fork,
|
||||
next_cnid: u32,
|
||||
journal_info: u32,
|
||||
}
|
||||
|
||||
impl<'a> Vol<'a> {
|
||||
fn open(src: &'a dyn Source, base: u64) -> Option<Vol<'a>> {
|
||||
let h = read_vec(src, base + 1024, 512).ok()?;
|
||||
if h.len() < 512 || !(&h[0..2] == b"H+" || &h[0..2] == b"HX") {
|
||||
return None;
|
||||
}
|
||||
let bs = be32(&h, 40) as u64;
|
||||
if bs < 512 || !bs.is_power_of_two() {
|
||||
return None;
|
||||
}
|
||||
Some(Vol {
|
||||
src,
|
||||
base,
|
||||
bs,
|
||||
total: be32(&h, 44) as u64,
|
||||
alloc: parse_fork(&h[112..192]),
|
||||
extents_file: parse_fork(&h[192..272]),
|
||||
catalog: parse_fork(&h[272..352]),
|
||||
next_cnid: be32(&h, 64),
|
||||
journal_info: be32(&h, 12),
|
||||
})
|
||||
}
|
||||
|
||||
fn block_off(&self, b: u64) -> u64 {
|
||||
self.base + b * self.bs
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if have < f.blocks as u64 && file_id >= 2 {
|
||||
ex.extend(self.overflow_extents(file_id, fork_type, have as u32));
|
||||
have = ex.iter().map(|e| e.1 as u64).sum();
|
||||
let _ = have;
|
||||
}
|
||||
merge_extents(
|
||||
ex.into_iter()
|
||||
.map(|(s, c)| Extent::new(self.block_off(s as u64), c as u64 * self.bs))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
Some(n) => n,
|
||||
None => return out,
|
||||
};
|
||||
let total_nodes = self.extents_file.size / node_size as u64;
|
||||
for n in 1..total_nodes.min(100_000) {
|
||||
let Some(node) = self.read_tree_node(&self.extents_file, n as u32, node_size) else {
|
||||
continue;
|
||||
};
|
||||
if node[8] != 0xFF {
|
||||
continue;
|
||||
}
|
||||
for r in records(&node) {
|
||||
// key: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32
|
||||
if r.len() < 12 + 64 || be16(r, 0) != 10 {
|
||||
continue;
|
||||
}
|
||||
if r[2] == fork_type && be32(r, 4) == file_id && be32(r, 8) == start {
|
||||
let mut got = 0;
|
||||
for i in 0..8 {
|
||||
let (s, c) = (be32(r, 12 + i * 8), be32(r, 16 + i * 8));
|
||||
if c != 0 {
|
||||
out.push((s, c));
|
||||
got += c;
|
||||
}
|
||||
}
|
||||
start += got;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tree_node_size(&self, f: &Fork) -> Option<usize> {
|
||||
let first = self.read_fork_bytes(f, 0, 64)?;
|
||||
let ns = be16(&first, 32) as usize;
|
||||
(512..=65536).contains(&ns).then_some(ns)
|
||||
}
|
||||
|
||||
fn read_tree_node(&self, f: &Fork, n: u32, ns: usize) -> Option<Vec<u8>> {
|
||||
self.read_fork_bytes(f, n as u64 * ns as u64, ns)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
for &(s, c) in &f.extents {
|
||||
let elen = c as u64 * self.bs;
|
||||
if off + out.len() as u64 >= pos && off + (out.len() as u64) < pos + elen {
|
||||
let within = off + out.len() as u64 - pos;
|
||||
let n = ((elen - within) as usize).min(len - out.len());
|
||||
out.extend(read_vec(self.src, self.block_off(s as u64) + within, n).ok()?);
|
||||
if out.len() == len {
|
||||
return Some(out);
|
||||
}
|
||||
}
|
||||
pos += elen;
|
||||
}
|
||||
(out.len() == len).then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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![];
|
||||
for i in 0..=n {
|
||||
let p = node.len().saturating_sub(2 * (i + 1));
|
||||
offs.push(be16(node, p) as usize);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for i in 0..n {
|
||||
let (a, b) = (offs[i], offs[i + 1]);
|
||||
if a >= 14 && b > a && b <= node.len() {
|
||||
out.push(&node[a..b]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn utf16be(b: &[u8]) -> String {
|
||||
let u: Vec<u16> = b
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
String::from_utf16_lossy(&u)
|
||||
.replace('/', "_")
|
||||
.replace(':', "/")
|
||||
}
|
||||
|
||||
struct CatFile {
|
||||
parent: u32,
|
||||
name: String,
|
||||
id: u32,
|
||||
data: Fork,
|
||||
created: u32,
|
||||
modified: u32,
|
||||
accessed: u32,
|
||||
stale: bool,
|
||||
}
|
||||
|
||||
fn date(d: u32) -> Option<i64> {
|
||||
(d != 0).then(|| d as i64 - HFS_EPOCH)
|
||||
}
|
||||
|
||||
/// Decodes a catalog record (key + data). `max_id` filters out garbage CNIDs.
|
||||
fn decode_record(
|
||||
r: &[u8],
|
||||
max_id: u32,
|
||||
files: &mut Vec<CatFile>,
|
||||
threads: &mut HashMap<u32, (u32, String)>,
|
||||
folders: &mut HashMap<(u32, String), u32>,
|
||||
stale: bool,
|
||||
) {
|
||||
let klen = be16(r, 0) as usize;
|
||||
if klen < 6 || klen + 2 > r.len() {
|
||||
return;
|
||||
}
|
||||
let parent = be32(r, 2);
|
||||
let nlen = be16(r, 6) as usize;
|
||||
if nlen > 255 || klen != 6 + nlen * 2 && klen != 6 {
|
||||
return;
|
||||
}
|
||||
let name = utf16be(r.get(8..8 + nlen * 2).unwrap_or(&[]));
|
||||
let mut d = 2 + klen;
|
||||
if d % 2 == 1 {
|
||||
d += 1;
|
||||
}
|
||||
let Some(rec) = r.get(d..) else { return };
|
||||
match be16(rec, 0) {
|
||||
1 if rec.len() >= 88 => {
|
||||
let id = be32(rec, 8);
|
||||
if id >= 2 && id < max_id.max(16) {
|
||||
folders.entry((parent, name)).or_insert(id);
|
||||
}
|
||||
}
|
||||
3 if rec.len() >= 10 => {
|
||||
// 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((
|
||||
be32(rec, 4),
|
||||
utf16be(rec.get(10..10 + pn * 2).unwrap_or(&[])),
|
||||
));
|
||||
}
|
||||
}
|
||||
2 if rec.len() >= 248 => {
|
||||
let id = be32(rec, 8);
|
||||
if id < 16 || id >= max_id.max(16) {
|
||||
return;
|
||||
}
|
||||
let data = parse_fork(&rec[88..168]);
|
||||
files.push(CatFile {
|
||||
parent,
|
||||
name,
|
||||
id,
|
||||
data,
|
||||
created: be32(rec, 12),
|
||||
modified: be32(rec, 16),
|
||||
accessed: be32(rec, 24),
|
||||
stale,
|
||||
});
|
||||
}
|
||||
4 if rec.len() >= 10 => {
|
||||
let pn = be16(rec, 8) as usize;
|
||||
if pn <= 255 {
|
||||
threads.entry(parent).or_insert((
|
||||
be32(rec, 4),
|
||||
utf16be(rec.get(10..10 + pn * 2).unwrap_or(&[])),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
files: &mut Vec<CatFile>,
|
||||
threads: &mut HashMap<u32, (u32, String)>,
|
||||
folders: &mut HashMap<(u32, String), u32>,
|
||||
) {
|
||||
let n = be16(node, 10) as usize;
|
||||
let table = node.len().saturating_sub(2 * (n + 1));
|
||||
let used_end = (be16(node, table) as usize).max(14);
|
||||
let mut p = used_end + (used_end % 2);
|
||||
while p + 40 < table {
|
||||
let klen = be16(node, p) as usize;
|
||||
let nlen = be16(node, p + 6) as usize;
|
||||
if klen == 6 + nlen * 2 && (1..=255).contains(&nlen) && p + 2 + klen + 2 < table {
|
||||
let parent = be32(node, p + 2);
|
||||
if parent >= 1 && parent < max_id.max(16) {
|
||||
let d = (p + 2 + klen + 1) & !1;
|
||||
let rt = be16(node, d);
|
||||
if matches!(rt, 1..=4) {
|
||||
let end = (d + 248).min(table);
|
||||
decode_record(&node[p..end], max_id, files, threads, folders, true);
|
||||
p = d + if rt == 2 { 248 } else { 16 };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
p += 2;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let mut res = VolumeScan::default();
|
||||
let _ = part_len;
|
||||
let Some(vbase) = locate(ctx.src, base) else {
|
||||
return res;
|
||||
};
|
||||
let Some(vol) = Vol::open(ctx.src, vbase) else {
|
||||
return res;
|
||||
};
|
||||
|
||||
// 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;
|
||||
for b in 0..vol.total {
|
||||
let used = bm
|
||||
.get((b / 8) as usize)
|
||||
.map(|x| x >> (7 - (b % 8)) & 1 == 1)
|
||||
.unwrap_or(true);
|
||||
match (used, run) {
|
||||
(false, None) => run = Some(b),
|
||||
(true, Some(s)) => {
|
||||
free.push(Extent::new(vol.block_off(s), (b - s) * vol.bs));
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(s) = run {
|
||||
free.push(Extent::new(vol.block_off(s), (vol.total - s) * vol.bs));
|
||||
}
|
||||
res.free = Some(merge_extents(free));
|
||||
}
|
||||
|
||||
let Some(ns) = vol.tree_node_size(&vol.catalog) else {
|
||||
res.warnings.push("HFS+: catálogo ilegível.".into());
|
||||
return res;
|
||||
};
|
||||
let Some(hdr) = vol.read_tree_node(&vol.catalog, 0, ns) else {
|
||||
return res;
|
||||
};
|
||||
let first_leaf = be32(&hdr, 24);
|
||||
let max_id = vol.next_cnid.max(16);
|
||||
let mut files: Vec<CatFile> = vec![];
|
||||
let mut threads: HashMap<u32, (u32, String)> = HashMap::new();
|
||||
let mut folders: HashMap<(u32, String), u32> = HashMap::new();
|
||||
let mut live_ids: HashSet<u32> = HashSet::new();
|
||||
|
||||
let mut n = first_leaf;
|
||||
let mut guard = 0;
|
||||
while n != 0 && guard < 5_000_000 {
|
||||
guard += 1;
|
||||
if (ctx.cancelled)() {
|
||||
return res;
|
||||
}
|
||||
let Some(node) = vol.read_tree_node(&vol.catalog, n, ns) else {
|
||||
break;
|
||||
};
|
||||
if node[8] != 0xFF {
|
||||
break;
|
||||
}
|
||||
let mut live_here: Vec<CatFile> = vec![];
|
||||
for r in records(&node) {
|
||||
decode_record(r, max_id, &mut live_here, &mut threads, &mut folders, false);
|
||||
}
|
||||
for f in &live_here {
|
||||
live_ids.insert(f.id);
|
||||
}
|
||||
files.extend(live_here);
|
||||
scan_slack(&node, max_id, &mut files, &mut threads, &mut folders);
|
||||
n = be32(&node, 0);
|
||||
}
|
||||
|
||||
// Journal: older copies of catalog leaf nodes.
|
||||
if vol.journal_info != 0 {
|
||||
scan_journal(
|
||||
&vol,
|
||||
ns,
|
||||
max_id,
|
||||
&mut files,
|
||||
&mut threads,
|
||||
&mut folders,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// Reconstruct folder paths from folder thread records.
|
||||
let path_of = |parent: u32| -> String {
|
||||
let mut parts = vec![];
|
||||
let mut cur = parent;
|
||||
for _ in 0..64 {
|
||||
if cur <= 2 {
|
||||
break;
|
||||
}
|
||||
match threads.get(&cur) {
|
||||
Some((p, name)) => {
|
||||
parts.push(name.clone());
|
||||
cur = *p;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
format!("/{}", parts.join("/"))
|
||||
};
|
||||
|
||||
let free_set = res.free.clone().unwrap_or_default();
|
||||
let mut seen: HashSet<(u32, u64)> = HashSet::new();
|
||||
for f in files
|
||||
.iter()
|
||||
.filter(|f| f.stale && !live_ids.contains(&f.id))
|
||||
{
|
||||
if f.data.size == 0 || f.data.extents.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ex = vol.fork_extents(&f.data, f.id, 0);
|
||||
let total_len: u64 = ex.iter().map(|e| e.len).sum();
|
||||
if ex.is_empty()
|
||||
|| total_len < f.data.size
|
||||
|| ex.iter().any(|e| e.end() > vol.base + vol.total * vol.bs)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !seen.insert((f.id, ex[0].offset)) {
|
||||
continue;
|
||||
}
|
||||
// Trim extents to the logical file size.
|
||||
let extents = super::fat::trim_extents(ex, f.data.size);
|
||||
// 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 {
|
||||
(
|
||||
Confidence::High,
|
||||
"registro apagado do catálogo HFS+; blocos ainda livres".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Confidence::Low,
|
||||
"alguns blocos do arquivo já foram reaproveitados".to_string(),
|
||||
)
|
||||
};
|
||||
let nf = NewFound {
|
||||
name: f.name.clone(),
|
||||
dir: Some(path_of(f.parent)),
|
||||
size: f.data.size,
|
||||
origin: Origin::Hfs,
|
||||
data: DataLoc::Extents { extents },
|
||||
modified: date(f.modified),
|
||||
created: date(f.created),
|
||||
accessed: date(f.accessed),
|
||||
base: conf,
|
||||
note: Some(¬e),
|
||||
};
|
||||
let found = make_found(ctx, nf);
|
||||
(ctx.emit)(found);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
fn covered_by(free: &[Extent], e: &Extent) -> bool {
|
||||
let i = free.partition_point(|f| f.end() <= e.offset);
|
||||
free.get(i)
|
||||
.map(|f| f.offset <= e.offset && f.end() >= e.end())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
impl Vol<'_> {
|
||||
fn read_fork_all(&self, f: &Fork, max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
for &(s, c) in &f.extents {
|
||||
let n = (c as u64 * self.bs) as usize;
|
||||
out.extend(
|
||||
read_vec(
|
||||
self.src,
|
||||
self.block_off(s as u64),
|
||||
n.min(max.saturating_sub(out.len())),
|
||||
)
|
||||
.ok()?,
|
||||
);
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scans the HFS+ journal buffer for historical catalog leaf nodes.
|
||||
fn scan_journal(
|
||||
vol: &Vol,
|
||||
ns: usize,
|
||||
max_id: u32,
|
||||
files: &mut Vec<CatFile>,
|
||||
threads: &mut HashMap<u32, (u32, String)>,
|
||||
folders: &mut HashMap<(u32, String), u32>,
|
||||
ctx: &Ctx,
|
||||
) {
|
||||
let Some(info) = read_vec(vol.src, vol.block_off(vol.journal_info as u64), 52)
|
||||
.ok()
|
||||
.filter(|b| b.len() == 52)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (joff, jsize) = (be64(&info, 36), be64(&info, 44));
|
||||
if jsize == 0 || jsize > 1 << 30 || vol.base + joff + jsize > vol.src.size() {
|
||||
return;
|
||||
}
|
||||
let mut pos = 0u64;
|
||||
let chunk = 4 << 20;
|
||||
while pos < jsize {
|
||||
if (ctx.cancelled)() {
|
||||
return;
|
||||
}
|
||||
let n = chunk.min(jsize - pos) as usize;
|
||||
let Ok(buf) = read_vec(vol.src, vol.base + joff + pos, n + ns) else {
|
||||
break;
|
||||
};
|
||||
let mut i = 0;
|
||||
while i + ns <= buf.len().min(n + ns) && i < n {
|
||||
let nd = &buf[i..i + ns];
|
||||
// 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
|
||||
&& be16(nd, table) as usize > 14
|
||||
&& (be16(nd, table) as usize) <= table
|
||||
{
|
||||
for r in records(nd) {
|
||||
decode_record(r, max_id, files, threads, folders, true);
|
||||
}
|
||||
scan_slack(nd, max_id, files, threads, folders);
|
||||
}
|
||||
}
|
||||
i += 512;
|
||||
}
|
||||
pos += n as u64;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
//! 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;
|
||||
pub mod exfat;
|
||||
pub mod ext;
|
||||
pub mod fat;
|
||||
pub mod hfsplus;
|
||||
pub mod ntfs;
|
||||
pub mod xfs;
|
||||
|
||||
use crate::signatures::{category_for_ext, matches_ext};
|
||||
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),
|
||||
}
|
||||
@@ -30,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>,
|
||||
}
|
||||
@@ -47,11 +51,13 @@ pub struct NewFound<'a> {
|
||||
pub origin: Origin,
|
||||
pub data: DataLoc,
|
||||
pub modified: Option<i64>,
|
||||
pub created: Option<i64>,
|
||||
pub accessed: Option<i64>,
|
||||
pub base: Confidence,
|
||||
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
|
||||
@@ -63,6 +69,7 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
|
||||
let mut note = n.note.map(String::from);
|
||||
let head: Vec<u8> = match &n.data {
|
||||
DataLoc::Resident { bytes } => bytes.iter().take(32).copied().collect(),
|
||||
DataLoc::Parts { .. } => vec![],
|
||||
DataLoc::Extents { extents } => extents
|
||||
.iter()
|
||||
.find(|e| !e.sparse)
|
||||
@@ -98,11 +105,18 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
|
||||
confidence: conf,
|
||||
data: n.data,
|
||||
modified: n.modified,
|
||||
created: n.created,
|
||||
accessed: n.accessed,
|
||||
taken: None,
|
||||
camera: None,
|
||||
px: None,
|
||||
gps: false,
|
||||
sig: None,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -121,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)
|
||||
@@ -145,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>,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ struct Rec {
|
||||
parent: u64,
|
||||
name: Option<(String, u8)>,
|
||||
mtime: Option<i64>,
|
||||
ctime: Option<i64>,
|
||||
atime: Option<i64>,
|
||||
data: Option<Data>,
|
||||
has_attr_list: bool,
|
||||
}
|
||||
@@ -59,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)
|
||||
@@ -94,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>;
|
||||
@@ -159,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 {
|
||||
@@ -199,6 +201,8 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||
origin: Origin::Ntfs,
|
||||
data,
|
||||
modified: rec.mtime,
|
||||
created: rec.ctime,
|
||||
accessed: rec.atime,
|
||||
base: conf,
|
||||
note: if rec.has_attr_list {
|
||||
Some("registro com lista de atributos: pode estar incompleto")
|
||||
@@ -287,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;
|
||||
@@ -304,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];
|
||||
@@ -312,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,
|
||||
@@ -320,6 +324,8 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
||||
parent: 0,
|
||||
name: None,
|
||||
mtime: None,
|
||||
ctime: None,
|
||||
atime: None,
|
||||
data: None,
|
||||
has_attr_list: false,
|
||||
};
|
||||
@@ -341,7 +347,11 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
||||
0x10 if !nonres => {
|
||||
let vo = le16(r, p + 20) as usize;
|
||||
if p + vo + 16 <= r.len() {
|
||||
rec.ctime = filetime_to_unix(le64(r, p + vo));
|
||||
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
|
||||
if p + vo + 32 <= r.len() {
|
||||
rec.atime = filetime_to_unix(le64(r, p + vo + 24));
|
||||
}
|
||||
}
|
||||
}
|
||||
0x20 => rec.has_attr_list = true,
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! 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;
|
||||
use crate::source::{read_vec, Source};
|
||||
use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
fn be16(b: &[u8], o: usize) -> u16 {
|
||||
b.get(o..o + 2)
|
||||
.map(|s| u16::from_be_bytes([s[0], s[1]]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
b.get(o..o + 4)
|
||||
.map(|s| u32::from_be_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn be64(b: &[u8], o: usize) -> u64 {
|
||||
b.get(o..o + 8)
|
||||
.map(|s| u64::from_be_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
struct Xfs<'a> {
|
||||
src: &'a dyn Source,
|
||||
base: u64,
|
||||
bs: u64,
|
||||
agblocks: u64,
|
||||
agcount: u64,
|
||||
inodesize: u64,
|
||||
inopblog: u32,
|
||||
agblklog: u32,
|
||||
sectsize: u64,
|
||||
v5: bool,
|
||||
ftype: bool,
|
||||
root_ino: u64,
|
||||
dblocks: u64,
|
||||
}
|
||||
|
||||
impl<'a> Xfs<'a> {
|
||||
fn open(src: &'a dyn Source, base: u64) -> Option<Xfs<'a>> {
|
||||
let sb = read_vec(src, base, 512).ok()?;
|
||||
if sb.len() < 512 || &sb[0..4] != b"XFSB" {
|
||||
return None;
|
||||
}
|
||||
let ver = be16(&sb, 100) & 0xF;
|
||||
let bs = be32(&sb, 4) as u64;
|
||||
let agblocks = be32(&sb, 84) as u64;
|
||||
let agcount = be32(&sb, 88) as u64;
|
||||
let inodesize = be16(&sb, 104) as u64;
|
||||
if !(512..=65536).contains(&bs)
|
||||
|| !bs.is_power_of_two()
|
||||
|| agblocks == 0
|
||||
|| agcount == 0
|
||||
|| !(256..=2048).contains(&inodesize)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let incompat = be32(&sb, 216);
|
||||
let features2 = be32(&sb, 200);
|
||||
let v5 = ver == 5;
|
||||
Some(Xfs {
|
||||
src,
|
||||
base,
|
||||
bs,
|
||||
agblocks,
|
||||
agcount,
|
||||
inodesize,
|
||||
inopblog: sb[123] as u32,
|
||||
agblklog: sb[124] as u32,
|
||||
sectsize: be16(&sb, 102) as u64,
|
||||
v5,
|
||||
ftype: if v5 {
|
||||
incompat & 1 != 0
|
||||
} else {
|
||||
features2 & 0x200 != 0
|
||||
},
|
||||
root_ino: be64(&sb, 56),
|
||||
dblocks: be64(&sb, 8),
|
||||
})
|
||||
}
|
||||
|
||||
fn ag_off(&self, agno: u64) -> u64 {
|
||||
self.base + agno * self.agblocks * self.bs
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn ino_off(&self, ino: u64) -> Option<u64> {
|
||||
let shift = self.agblklog + self.inopblog;
|
||||
let agno = ino >> shift;
|
||||
let agbno = (ino >> self.inopblog) & ((1 << self.agblklog) - 1);
|
||||
let idx = ino & ((1 << self.inopblog) - 1);
|
||||
(agno < self.agcount && agbno < self.agblocks)
|
||||
.then(|| self.ag_off(agno) + agbno * self.bs + idx * self.inodesize)
|
||||
}
|
||||
|
||||
fn read_ino(&self, ino: u64) -> Option<Vec<u8>> {
|
||||
let b = read_vec(self.src, self.ino_off(ino)?, self.inodesize as usize).ok()?;
|
||||
(b.len() as u64 == self.inodesize && &b[0..2] == b"IN").then_some(b)
|
||||
}
|
||||
|
||||
fn core_size(&self, ino: &[u8]) -> usize {
|
||||
if ino[4] >= 3 {
|
||||
176
|
||||
} else {
|
||||
100
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let fork_end = if ino[82] != 0 {
|
||||
start.max(100 + ino[82] as usize * 8)
|
||||
} else {
|
||||
self.inodesize as usize
|
||||
};
|
||||
let lit = &ino[start..fork_end.min(ino.len())];
|
||||
let mut out = vec![];
|
||||
match fmt {
|
||||
2 => {
|
||||
let n = (be32(ino, 76) as usize).min(lit.len() / 16);
|
||||
for i in 0..n {
|
||||
out.push(unpack_extent(&lit[i * 16..i * 16 + 16]));
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// 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;
|
||||
let ptrs = 4 + maxrecs * 8;
|
||||
for i in 0..nrecs.min(maxrecs) {
|
||||
let fsb = be64(lit, ptrs + i * 8);
|
||||
self.walk_bmbt(fsb, level - 1, &mut out, 0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.retain(|e| e.2 > 0 && e.2 <= max_blocks);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_bmbt(&self, fsb: u64, level: usize, out: &mut Vec<(u64, u64, u64)>, depth: u32) {
|
||||
if depth > 6 || out.len() > 1_000_000 {
|
||||
return;
|
||||
}
|
||||
let Some(off) = self.fsb_off(fsb) else { return };
|
||||
let Ok(b) = read_vec(self.src, off, self.bs as usize) else {
|
||||
return;
|
||||
};
|
||||
if b.len() < 24 || !(&b[0..4] == b"BMAP" || &b[0..4] == b"BMA3") {
|
||||
return;
|
||||
}
|
||||
let hdr = if self.v5 { 72 } else { 24 };
|
||||
let numrecs = be16(&b, 6) as usize;
|
||||
if level == 0 {
|
||||
for i in 0..numrecs {
|
||||
if let Some(r) = b.get(hdr + i * 16..hdr + i * 16 + 16) {
|
||||
out.push(unpack_extent(r));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let maxrecs = (self.bs as usize - hdr) / 16;
|
||||
let ptrs = hdr + maxrecs * 8;
|
||||
for i in 0..numrecs {
|
||||
self.walk_bmbt(be64(&b, ptrs + i * 8), level - 1, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
let startoff = (hi >> 9) & ((1 << 54) - 1);
|
||||
let startblock = ((hi & 0x1FF) << 43) | (lo >> 21);
|
||||
let count = lo & 0x1F_FFFF;
|
||||
(startoff, startblock, count)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
root: u64,
|
||||
levels: u32,
|
||||
rec: usize,
|
||||
key_size: usize,
|
||||
f: &mut dyn FnMut(&[u8]),
|
||||
) {
|
||||
let mut cur = root;
|
||||
// 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;
|
||||
};
|
||||
if b.len() < 24 {
|
||||
return;
|
||||
}
|
||||
let hdr = if x.v5 { 56 } else { 16 };
|
||||
let maxrecs = (x.bs as usize - hdr) / (key_size + 4);
|
||||
let ptrs = hdr + maxrecs * key_size;
|
||||
let _ = lvl;
|
||||
cur = be32(&b, ptrs) as u64;
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
while cur != 0 && cur != u32::MAX as u64 && seen.insert(cur) {
|
||||
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else {
|
||||
return;
|
||||
};
|
||||
if b.len() < 24 {
|
||||
return;
|
||||
}
|
||||
let hdr = if x.v5 { 56 } else { 16 };
|
||||
let n = be16(&b, 6) as usize;
|
||||
for i in 0..n {
|
||||
if let Some(r) = b.get(hdr + i * rec..hdr + (i + 1) * rec) {
|
||||
f(r);
|
||||
}
|
||||
}
|
||||
cur = be32(&b, 12) as u64; // rightsib
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
let _ = part_len;
|
||||
let mut res = VolumeScan::default();
|
||||
let Some(x) = Xfs::open(ctx.src, base) else {
|
||||
return res;
|
||||
};
|
||||
|
||||
// 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![]; // (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;
|
||||
}
|
||||
let agf = read_vec(ctx.src, x.ag_off(agno) + x.sectsize, 64).unwrap_or_default();
|
||||
if agf.len() >= 64 && &agf[0..4] == b"XAGF" {
|
||||
let (root, levels) = (be32(&agf, 16) as u64, be32(&agf, 28));
|
||||
walk_short_btree(&x, agno, root, levels, 8, 8, &mut |r| {
|
||||
let (s, c) = (be32(r, 0) as u64, be32(r, 4) as u64);
|
||||
free.push(Extent::new(x.ag_off(agno) + s * x.bs, c * x.bs));
|
||||
free_set.push(((agno << x.agblklog) | s, c));
|
||||
});
|
||||
}
|
||||
let agi = read_vec(ctx.src, x.ag_off(agno) + 2 * x.sectsize, 64).unwrap_or_default();
|
||||
if agi.len() >= 64 && &agi[0..4] == b"XAGI" {
|
||||
let (root, levels) = (be32(&agi, 20) as u64, be32(&agi, 24));
|
||||
walk_short_btree(&x, agno, root, levels, 16, 4, &mut |r| {
|
||||
chunks.push((agno, be32(r, 0) as u64, be64(r, 8)));
|
||||
});
|
||||
}
|
||||
(ctx.progress)(agno, x.agcount);
|
||||
}
|
||||
free.sort_by_key(|e| e.offset);
|
||||
let free = merge_extents(free);
|
||||
res.free = Some(free.clone());
|
||||
free_set.sort();
|
||||
let is_free = |fsb: u64, count: u64| -> bool {
|
||||
let i = free_set.partition_point(|&(s, c)| s + c <= fsb);
|
||||
free_set
|
||||
.get(i)
|
||||
.is_some_and(|&(s, c)| s <= fsb && s + c >= fsb + count)
|
||||
};
|
||||
|
||||
// Names: freed directory entries (preserve filename and low 32 bits of the inode number).
|
||||
let (live_dirs, stale_names) = collect_names(&x, ctx);
|
||||
|
||||
// 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 {
|
||||
if (ctx.cancelled)() {
|
||||
return res;
|
||||
}
|
||||
for i in 0..64u64 {
|
||||
if mask >> i & 1 == 0 {
|
||||
continue; // allocated inode
|
||||
}
|
||||
let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i);
|
||||
let Some(raw) = x.read_ino(ino) else { continue };
|
||||
let mode = be16(&raw, 2);
|
||||
if mode != 0 && be32(&raw, 16) != 0 {
|
||||
continue;
|
||||
}
|
||||
// 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..];
|
||||
// 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 {
|
||||
let e = unpack_extent(&lit[k * 16..k * 16 + 16]);
|
||||
let valid = e.2 > 0
|
||||
&& e.2 < 1 << 21
|
||||
&& e.0 == next_off
|
||||
&& x.fsb_off(e.1).is_some()
|
||||
&& (e.1 & ((1 << x.agblklog) - 1)) + e.2 <= x.agblocks;
|
||||
if !valid {
|
||||
break;
|
||||
}
|
||||
next_off = e.0 + e.2;
|
||||
ext.push(e);
|
||||
}
|
||||
if ext.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let total_blocks: u64 = ext.iter().map(|e| e.2).sum();
|
||||
if total_blocks > max_blocks {
|
||||
continue;
|
||||
}
|
||||
let all_free = ext.iter().all(|e| is_free(e.1, e.2));
|
||||
let extents: Vec<Extent> = ext
|
||||
.iter()
|
||||
.filter_map(|e| x.fsb_off(e.1).map(|o| Extent::new(o, e.2 * x.bs)))
|
||||
.collect();
|
||||
if extents.is_empty() || !seen_first.insert(extents[0].offset) {
|
||||
continue;
|
||||
}
|
||||
let mut size = total_blocks * x.bs;
|
||||
let mut conf = if all_free {
|
||||
Confidence::High
|
||||
} else {
|
||||
Confidence::Low
|
||||
};
|
||||
let mut note = if all_free {
|
||||
"inode XFS liberado; lista de extents recuperada do próprio inode".to_string()
|
||||
} else {
|
||||
"blocos do arquivo já reaproveitados".to_string()
|
||||
};
|
||||
// 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()
|
||||
.and_then(|head| signatures::detect(ctx.src, extents[0].offset, &head, size))
|
||||
{
|
||||
if h.len > 0 && h.len <= size {
|
||||
size = h.len;
|
||||
est = false;
|
||||
}
|
||||
}
|
||||
if est {
|
||||
conf = conf.min(Confidence::Medium);
|
||||
note.push_str("; tamanho exato perdido: usando todos os blocos");
|
||||
}
|
||||
let extents = super::fat::trim_extents(extents, size);
|
||||
let low = ino & 0xFFFF_FFFF;
|
||||
let (dir, name) = match stale_names.get(&low) {
|
||||
Some((parent, n)) => (
|
||||
Some(live_dirs.get(parent).cloned().unwrap_or_default()),
|
||||
n.clone(),
|
||||
),
|
||||
None => (None, format!("inode_{ino}")),
|
||||
};
|
||||
let ts = |s: u32| (s != 0).then_some(s as i64);
|
||||
let nf = NewFound {
|
||||
name,
|
||||
dir,
|
||||
size,
|
||||
origin: Origin::Xfs,
|
||||
data: DataLoc::Extents { extents },
|
||||
modified: ts(be32(&raw, 40)),
|
||||
created: if raw[4] >= 3 {
|
||||
ts(be32(&raw, 144))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
accessed: ts(be32(&raw, 32)),
|
||||
base: conf,
|
||||
note: Some(¬e),
|
||||
};
|
||||
let f = make_found(ctx, nf);
|
||||
(ctx.emit)(f);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut queue: Vec<(u64, String)> = vec![(x.root_ino, String::new())];
|
||||
let mut seen: HashSet<u64> = HashSet::new();
|
||||
while let Some((dino, path)) = queue.pop() {
|
||||
if !seen.insert(dino) || (ctx.cancelled)() || seen.len() > 200_000 {
|
||||
continue;
|
||||
}
|
||||
let Some(raw) = x.read_ino(dino) else {
|
||||
continue;
|
||||
};
|
||||
if be16(&raw, 2) & 0o170000 != 0o040000 {
|
||||
continue;
|
||||
}
|
||||
paths.insert(dino, path.clone());
|
||||
let start = x.core_size(&raw);
|
||||
match raw[5] {
|
||||
1 => {
|
||||
// shortform directory stored inline inside the inode
|
||||
let lit = &raw[start..];
|
||||
let count = lit[0] as usize;
|
||||
let i8 = lit[1] != 0;
|
||||
let isz = if i8 { 8 } else { 4 };
|
||||
let mut p = 2 + isz;
|
||||
for _ in 0..count.max(lit[1] as usize) {
|
||||
if p + 3 > lit.len() {
|
||||
break;
|
||||
}
|
||||
let nl = lit[p] as usize;
|
||||
p += 3;
|
||||
let name =
|
||||
String::from_utf8_lossy(lit.get(p..p + nl).unwrap_or(&[])).to_string();
|
||||
p += nl + if x.ftype { 1 } else { 0 };
|
||||
let ino = if i8 {
|
||||
be64(lit, p)
|
||||
} else {
|
||||
be32(lit, p) as u64
|
||||
};
|
||||
p += isz;
|
||||
queue.push((ino, format!("{path}/{name}")));
|
||||
}
|
||||
}
|
||||
2 | 3 => {
|
||||
let size = be64(&raw, 56).min(64 << 20);
|
||||
let ex = x.extents_of(&raw, 1 << 20);
|
||||
let _ = size;
|
||||
for (off, fsb, cnt) in ex {
|
||||
// directory data blocks live below the 32 GiB logical offset (leaf/freeindex blocks live above)
|
||||
if off * x.bs >= 32u64 << 30 {
|
||||
continue;
|
||||
}
|
||||
for k in 0..cnt.min(256) {
|
||||
let Some(o) = x.fsb_off(fsb + k) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(b) = read_vec(x.src, o, x.bs as usize) else {
|
||||
continue;
|
||||
};
|
||||
parse_dir_block(x, &b, dino, &path, &mut queue, &mut stale);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(paths, stale)
|
||||
}
|
||||
|
||||
fn parse_dir_block(
|
||||
x: &Xfs,
|
||||
b: &[u8],
|
||||
dino: u64,
|
||||
path: &str,
|
||||
queue: &mut Vec<(u64, String)>,
|
||||
stale: &mut HashMap<u64, (u64, String)>,
|
||||
) {
|
||||
let magic = &b[0..4];
|
||||
let hdr = match magic {
|
||||
b"XD2B" | b"XD2D" => 16,
|
||||
b"XDB3" | b"XDD3" => 64,
|
||||
_ => return,
|
||||
};
|
||||
let is_block = magic == b"XD2B" || magic == b"XDB3";
|
||||
// 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)
|
||||
} else {
|
||||
b.len()
|
||||
};
|
||||
let mut p = hdr;
|
||||
while p + 8 <= end {
|
||||
if be16(b, p) == 0xFFFF {
|
||||
let len = be16(b, p + 2) as usize;
|
||||
if len < 8 || p + len > end {
|
||||
break;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
let ino = be64(b, p);
|
||||
let nl = b[p + 8] as usize;
|
||||
if nl == 0 {
|
||||
break;
|
||||
}
|
||||
let ft = if x.ftype { 1 } else { 0 };
|
||||
let size = (8 + 1 + nl + ft + 2).div_ceil(8) * 8;
|
||||
if p + size > end {
|
||||
break;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&b[p + 9..p + 9 + nl]).to_string();
|
||||
if name != "." && name != ".." {
|
||||
queue.push((ino, format!("{path}/{name}")));
|
||||
}
|
||||
p += size;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u64, String)>) {
|
||||
// 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)?;
|
||||
if nl == 0 || name.iter().any(|&c| c < 0x20 || c == b'/' || c == 0x7F) {
|
||||
return None;
|
||||
}
|
||||
stale
|
||||
.entry(low)
|
||||
.or_insert((dir_ino, String::from_utf8_lossy(name).to_string()));
|
||||
Some((8 + 1 + nl + if x.ftype { 1 } else { 0 } + 2).div_ceil(8) * 8)
|
||||
};
|
||||
if r.len() >= 16 {
|
||||
let low = be32(r, 4) as u64;
|
||||
if let Some(sz) = try_at(0, low, stale) {
|
||||
// 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);
|
||||
if ino == 0 || ino > 1 << 56 {
|
||||
p += 8;
|
||||
continue;
|
||||
}
|
||||
match try_at(p, ino & 0xFFFF_FFFF, stale) {
|
||||
Some(s) => p += s,
|
||||
None => p += 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Forensic integrity hashes: computes MD5, SHA-1, and SHA-256 in a single pass.
|
||||
|
||||
use md5::Md5;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Hashes {
|
||||
pub md5: String,
|
||||
pub sha1: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MultiHasher {
|
||||
md5: Md5,
|
||||
sha1: Sha1,
|
||||
sha256: Sha256,
|
||||
}
|
||||
|
||||
impl MultiHasher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn update(&mut self, data: &[u8]) {
|
||||
self.md5.update(data);
|
||||
self.sha1.update(data);
|
||||
self.sha256.update(data);
|
||||
}
|
||||
pub fn finalize(self) -> Hashes {
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
Hashes {
|
||||
md5: hex(&self.md5.finalize()),
|
||||
sha1: hex(&self.sha1.finalize()),
|
||||
sha256: hex(&self.sha256.finalize()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_bytes(data: &[u8]) -> Hashes {
|
||||
let mut h = MultiHasher::new();
|
||||
h.update(data);
|
||||
h.finalize()
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
//! 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;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Health {
|
||||
/// Whether SMART assessment was possible (disk images and unsupported bridges return `false`).
|
||||
pub available: bool,
|
||||
/// Overall status: `"ok"`, `"warning"`, `"failing"`, or `"unknown"`.
|
||||
pub status: String,
|
||||
pub summary: String,
|
||||
pub details: Vec<(String, String)>,
|
||||
pub advice: Vec<String>,
|
||||
/// CrystalDiskInfo-style device summary (model, firmware, interface, etc.).
|
||||
pub info: Vec<(String, String)>,
|
||||
/// 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 host bytes read and written, when reported by the drive.
|
||||
pub host_reads: Option<u64>,
|
||||
pub host_writes: Option<u64>,
|
||||
/// Full table of SMART attributes.
|
||||
pub attributes: Vec<Attr>,
|
||||
/// Actual device path queried (the containing whole disk when `path` is a partition).
|
||||
pub disk: String,
|
||||
/// Original partition path when the query was redirected to its parent disk.
|
||||
pub partition: Option<String>,
|
||||
}
|
||||
|
||||
/// A single row in the SMART attribute table.
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Attr {
|
||||
/// Two-digit hex attribute ID as displayed by CrystalDiskInfo (`"05"`, `"C5"`).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Normalized ATA values: current, worst, and failure threshold.
|
||||
pub value: Option<i64>,
|
||||
pub worst: Option<i64>,
|
||||
pub thresh: Option<i64>,
|
||||
pub raw: String,
|
||||
/// Attribute status: `"ok"`, `"warning"`, or `"failing"`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
fn unknown(msg: &str) -> Self {
|
||||
Health {
|
||||
available: false,
|
||||
status: "unknown".into(),
|
||||
summary: msg.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
/// Evaluates disk health for `path`. Regular image files return an unassessed report.
|
||||
pub fn check(path: &str) -> Health {
|
||||
#[cfg(unix)]
|
||||
let is_dev = {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.file_type().is_block_device() || m.file_type().is_char_device())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let is_dev = path.starts_with(r"\\.\");
|
||||
if !is_dev {
|
||||
return Health::unknown("Imagem de disco: não há SMART para avaliar.");
|
||||
}
|
||||
let disk = parent_disk(path);
|
||||
let mut h = match run_smartctl(&disk) {
|
||||
Some(v) => parse_smartctl(&v),
|
||||
None => Health::unknown("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."),
|
||||
};
|
||||
kernel_errors(path, &mut h);
|
||||
if disk != path {
|
||||
h.partition = Some(path.to_string());
|
||||
}
|
||||
h.disk = disk;
|
||||
h
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
{
|
||||
let name = path.trim_start_matches("/dev/");
|
||||
let sys = format!("/sys/class/block/{name}");
|
||||
if std::path::Path::new(&format!("{sys}/partition")).exists() {
|
||||
if let Ok(real) = std::fs::canonicalize(&sys) {
|
||||
if let Some(p) = real.parent().and_then(|p| p.file_name()) {
|
||||
return format!("/dev/{}", p.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
for pre in ["/dev/rdisk", "/dev/disk"] {
|
||||
if let Some(rest) = path.strip_prefix(pre) {
|
||||
if let Some(i) = rest.find('s') {
|
||||
return format!("{pre}{}", &rest[..i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn smartctl_name(path: &str) -> String {
|
||||
// Windows: \\.\PhysicalDriveN -> /dev/sdX (smartctl device naming convention)
|
||||
if let Some(n) = path
|
||||
.strip_prefix(r"\\.\PhysicalDrive")
|
||||
.and_then(|n| n.parse::<u8>().ok())
|
||||
{
|
||||
return format!("/dev/sd{}", (b'a' + n.min(25)) as char);
|
||||
}
|
||||
// macOS: /dev/rdiskN -> /dev/diskN
|
||||
if let Some(rest) = path.strip_prefix("/dev/rdisk") {
|
||||
return format!("/dev/disk{rest}");
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn run_smartctl(path: &str) -> Option<Value> {
|
||||
let dev = smartctl_name(path);
|
||||
let out = Command::new("smartctl")
|
||||
.args(["--json=o", "-a", &dev])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
// smartctl encodes warnings in exit status bits; the JSON output is still valid
|
||||
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
// 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"))
|
||||
&& v.get("smart_status").is_none()
|
||||
&& v.get("ata_smart_attributes").is_none()
|
||||
&& v.get("nvme_smart_health_information_log").is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn attr(v: &Value, id: u64) -> Option<i64> {
|
||||
v.pointer("/ata_smart_attributes/table")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|a| a["id"] == id)?
|
||||
.pointer("/raw/value")?
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// 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 = 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));
|
||||
};
|
||||
fill_info(v, &mut h);
|
||||
fill_attributes(v, &mut h);
|
||||
if let Some(m) = v["model_name"].as_str() {
|
||||
h.details.push(("Modelo".into(), m.to_string()));
|
||||
}
|
||||
if let Some(s) = v["serial_number"].as_str() {
|
||||
h.details.push(("Nº de série".into(), s.to_string()));
|
||||
}
|
||||
if let Some(hrs) = v.pointer("/power_on_time/hours").and_then(|x| x.as_i64()) {
|
||||
h.details.push(("Horas ligado".into(), hrs.to_string()));
|
||||
}
|
||||
if let Some(t) = v.pointer("/temperature/current").and_then(|x| x.as_i64()) {
|
||||
if t >= 60 {
|
||||
note(&mut h, 1, "Temperatura", format!("{t} °C (alta)"));
|
||||
} else {
|
||||
h.details.push(("Temperatura".into(), format!("{t} °C")));
|
||||
}
|
||||
}
|
||||
if v.pointer("/smart_status/passed") == Some(&Value::Bool(false)) {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Autoavaliação SMART",
|
||||
"REPROVADO: o próprio disco prevê falha".into(),
|
||||
);
|
||||
} else if v.pointer("/smart_status/passed") == Some(&Value::Bool(true)) {
|
||||
h.details
|
||||
.push(("Autoavaliação SMART".into(), "aprovado".into()));
|
||||
}
|
||||
// ATA (SATA HDDs and SSDs)
|
||||
if let Some(n) = attr(v, 5) {
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
if n >= 50 { 2 } else { 1 },
|
||||
"Setores realocados",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 197) {
|
||||
if n > 0 {
|
||||
note(&mut h, 2, "Setores pendentes de realocação", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 198) {
|
||||
if n > 0 {
|
||||
note(&mut h, 2, "Setores incorrigíveis offline", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 187) {
|
||||
if n > 0 {
|
||||
note(&mut h, 1, "Erros incorrigíveis reportados", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 199) {
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
1,
|
||||
"Erros de CRC na interface (cabo ou porta)",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(n) = v
|
||||
.pointer("/ata_smart_error_log/summary/count")
|
||||
.and_then(|x| x.as_i64())
|
||||
{
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
1,
|
||||
"Erros registrados no log do disco",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// NVMe
|
||||
if let Some(n) = v.get("nvme_smart_health_information_log") {
|
||||
if n["critical_warning"].as_i64().unwrap_or(0) != 0 {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Aviso crítico NVMe",
|
||||
n["critical_warning"].to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(u) = n["percentage_used"].as_i64() {
|
||||
if u >= 100 {
|
||||
note(&mut h, 2, "Vida útil usada", format!("{u}%"));
|
||||
} else if u >= 90 {
|
||||
note(&mut h, 1, "Vida útil usada", format!("{u}%"));
|
||||
} else {
|
||||
h.details.push(("Vida útil usada".into(), format!("{u}%")));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n["media_errors"].as_i64() {
|
||||
if m > 0 {
|
||||
note(&mut h, 2, "Erros de mídia", m.to_string());
|
||||
}
|
||||
}
|
||||
if let (Some(s), Some(t)) = (
|
||||
n["available_spare"].as_i64(),
|
||||
n["available_spare_threshold"].as_i64(),
|
||||
) {
|
||||
if s < t {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Reserva de blocos abaixo do limite",
|
||||
format!("{s}% (limite {t}%)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.attributes.iter().any(|a| a.status == "failing") {
|
||||
bad = 2;
|
||||
}
|
||||
match bad {
|
||||
2 => {
|
||||
h.status = "failing".into();
|
||||
h.summary = "Este disco mostra sinais sérios de defeito.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
1 => {
|
||||
h.status = "warning".into();
|
||||
h.summary = "Este disco mostra sinais de desgaste ou erros.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
_ => h.summary = "Nenhum sinal de defeito encontrado pelo SMART.".into(),
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn s(v: &Value, p: &str) -> Option<String> {
|
||||
v.pointer(p)
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|x| x.trim().to_string())
|
||||
.filter(|x| !x.is_empty())
|
||||
}
|
||||
fn n(v: &Value, p: &str) -> Option<i64> {
|
||||
v.pointer(p).and_then(|x| x.as_i64())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
h.info.push((k.to_string(), val));
|
||||
}
|
||||
};
|
||||
put(
|
||||
"Modelo",
|
||||
s(v, "/model_name").or_else(|| {
|
||||
let a = s(v, "/scsi_vendor")?;
|
||||
Some(format!("{a} {}", s(v, "/scsi_product").unwrap_or_default()))
|
||||
}),
|
||||
);
|
||||
put("Firmware", s(v, "/firmware_version"));
|
||||
put("Nº de série", s(v, "/serial_number"));
|
||||
put("Família", s(v, "/model_family"));
|
||||
let proto = s(v, "/device/protocol");
|
||||
let iface = s(v, "/sata_version/string").or_else(|| {
|
||||
let sp = v
|
||||
.pointer("/nvme_pci_vendor")
|
||||
.map(|_| "NVM Express".to_string());
|
||||
sp.or_else(|| proto.clone())
|
||||
});
|
||||
put("Interface", iface);
|
||||
let speed = |p: &str| s(v, &format!("/interface_speed/{p}/string"));
|
||||
put(
|
||||
"Modo de transferência",
|
||||
match (speed("current"), speed("max")) {
|
||||
(Some(c), Some(m)) if c != m => Some(format!("{c} (máx. {m})")),
|
||||
(Some(c), _) => Some(c),
|
||||
(None, Some(m)) => Some(m),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
put(
|
||||
"Capacidade",
|
||||
n(v, "/user_capacity/bytes")
|
||||
.or_else(|| n(v, "/nvme_total_capacity"))
|
||||
.map(|b| decimal_size(b as u64)),
|
||||
);
|
||||
put(
|
||||
"Rotação",
|
||||
n(v, "/rotation_rate").map(|r| {
|
||||
if r == 0 {
|
||||
"SSD (sem rotação)".into()
|
||||
} else {
|
||||
format!("{r} RPM")
|
||||
}
|
||||
}),
|
||||
);
|
||||
put("Formato", s(v, "/form_factor/name"));
|
||||
let mut feats = Vec::new();
|
||||
if v.pointer("/smart_support/enabled") == Some(&Value::Bool(true))
|
||||
|| v.get("nvme_smart_health_information_log").is_some()
|
||||
{
|
||||
feats.push("S.M.A.R.T.");
|
||||
}
|
||||
if v.pointer("/trim/supported") == Some(&Value::Bool(true)) {
|
||||
feats.push("TRIM");
|
||||
}
|
||||
if v.pointer("/write_cache/enabled") == Some(&Value::Bool(true)) {
|
||||
feats.push("Cache de gravação");
|
||||
}
|
||||
if v.get("ata_security").and_then(|x| x.get("enabled")) == Some(&Value::Bool(true)) {
|
||||
feats.push("Segurança ATA");
|
||||
}
|
||||
if !feats.is_empty() {
|
||||
put("Recursos", Some(feats.join(", ")));
|
||||
}
|
||||
|
||||
h.temperature_c = n(v, "/temperature/current");
|
||||
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") {
|
||||
// 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"]
|
||||
.as_i64()
|
||||
.map(|u| (100 - u.clamp(0, 100)) as u8);
|
||||
h.power_cycles = h.power_cycles.or(l["power_cycles"].as_i64());
|
||||
} else {
|
||||
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);
|
||||
// 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()
|
||||
.find_map(|&id| attr_field(v, id, "value"))
|
||||
.filter(|_| n(v, "/rotation_rate") == Some(0))
|
||||
.map(|x| x.clamp(0, 100) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let mut i = 0;
|
||||
while x >= 1000.0 && i < units.len() - 1 {
|
||||
x /= 1000.0;
|
||||
i += 1;
|
||||
}
|
||||
format!("{x:.1} {}", units[i]).replace('.', ",")
|
||||
}
|
||||
|
||||
fn attr_field(v: &Value, id: u64, f: &str) -> Option<i64> {
|
||||
v.pointer("/ata_smart_attributes/table")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|a| a["id"] == id)?
|
||||
.get(f)?
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if let Some(t) = v
|
||||
.pointer("/ata_smart_attributes/table")
|
||||
.and_then(|t| t.as_array())
|
||||
{
|
||||
for a in t {
|
||||
let id = a["id"].as_u64().unwrap_or(0);
|
||||
let value = a["value"].as_i64();
|
||||
let thresh = a["thresh"].as_i64();
|
||||
let raw_n = a
|
||||
.pointer("/raw/value")
|
||||
.and_then(|x| x.as_i64())
|
||||
.unwrap_or(0);
|
||||
let failing = a
|
||||
.pointer("/when_failed")
|
||||
.and_then(|x| x.as_str())
|
||||
.is_some_and(|w| w == "now")
|
||||
|| matches!((value, thresh), (Some(v), Some(t)) if t > 0 && v <= t);
|
||||
let status = if failing {
|
||||
"failing"
|
||||
} else if RAW_WARN.contains(&id) && raw_n > 0 {
|
||||
"warning"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
h.attributes.push(Attr {
|
||||
id: format!("{id:02X}"),
|
||||
name: a["name"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown_Attribute")
|
||||
.replace('_', " "),
|
||||
value,
|
||||
worst: a["worst"].as_i64(),
|
||||
thresh,
|
||||
raw: a
|
||||
.pointer("/raw/string")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| raw_n.to_string()),
|
||||
status: status.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(l) = v.get("nvme_smart_health_information_log") {
|
||||
// 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"),
|
||||
(3, "Available Spare", "available_spare"),
|
||||
(4, "Available Spare Threshold", "available_spare_threshold"),
|
||||
(5, "Percentage Used", "percentage_used"),
|
||||
(6, "Data Units Read", "data_units_read"),
|
||||
(7, "Data Units Written", "data_units_written"),
|
||||
(8, "Host Read Commands", "host_reads"),
|
||||
(9, "Host Write Commands", "host_writes"),
|
||||
(10, "Controller Busy Time", "controller_busy_time"),
|
||||
(11, "Power Cycles", "power_cycles"),
|
||||
(12, "Power On Hours", "power_on_hours"),
|
||||
(13, "Unsafe Shutdowns", "unsafe_shutdowns"),
|
||||
(14, "Media and Data Integrity Errors", "media_errors"),
|
||||
(
|
||||
15,
|
||||
"Number of Error Information Log Entries",
|
||||
"num_err_log_entries",
|
||||
),
|
||||
];
|
||||
let spare_low = matches!((l["available_spare"].as_i64(), l["available_spare_threshold"].as_i64()), (Some(s), Some(t)) if s < t);
|
||||
for (id, name, key) in rows {
|
||||
let Some(x) = l.get(key).and_then(|x| x.as_i64()) else {
|
||||
continue;
|
||||
};
|
||||
let status = match key {
|
||||
"critical_warning" | "media_errors" if x > 0 => "failing",
|
||||
"available_spare" if spare_low => "failing",
|
||||
"percentage_used" if x >= 100 => "failing",
|
||||
"percentage_used" if x >= 90 => "warning",
|
||||
_ => "ok",
|
||||
};
|
||||
h.attributes.push(Attr {
|
||||
id: format!("{id:02X}"),
|
||||
name: name.into(),
|
||||
raw: x.to_string(),
|
||||
status: status.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux kernel sysfs I/O error counters: supplement SMART warnings.
|
||||
fn kernel_errors(path: &str, h: &mut Health) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let name = path.trim_start_matches("/dev/");
|
||||
let base = name.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||
for n in [name, base] {
|
||||
if let Ok(s) = std::fs::read_to_string(format!("/sys/block/{n}/device/ioerr_cnt")) {
|
||||
if let Ok(c) = u64::from_str_radix(s.trim().trim_start_matches("0x"), 16) {
|
||||
if c > 0 {
|
||||
h.details.push((
|
||||
"Erros de leitura/gravação vistos pelo sistema".into(),
|
||||
c.to_string(),
|
||||
));
|
||||
if h.status == "ok" || h.status == "unknown" {
|
||||
h.available = true;
|
||||
h.status = "warning".into();
|
||||
h.summary = "O sistema já registrou erros de E/S neste disco.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = (path, h);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 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};
|
||||
|
||||
pub fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
/// 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(), ""));
|
||||
let is_file = path.starts_with("/file/");
|
||||
let is_thumb = path.starts_with("/thumb/");
|
||||
if !(is_file || is_thumb) {
|
||||
return Some(req);
|
||||
}
|
||||
if req.method() == &Method::Options {
|
||||
let r = Response::empty(204)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"))
|
||||
.with_header(header("Access-Control-Allow-Headers", "Range"))
|
||||
.with_header(header("Access-Control-Allow-Methods", "GET, OPTIONS"));
|
||||
let _ = req.respond(r);
|
||||
return None;
|
||||
}
|
||||
if req.method() != &Method::Get {
|
||||
let _ = req.respond(Response::from_string("método não permitido").with_status_code(405));
|
||||
return None;
|
||||
}
|
||||
if !query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token))
|
||||
{
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return None;
|
||||
}
|
||||
let mut it = path
|
||||
.trim_start_matches("/file/")
|
||||
.trim_start_matches("/thumb/")
|
||||
.split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return None;
|
||||
};
|
||||
if is_thumb {
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
match eng.thumb(job, id, size) {
|
||||
Ok(bytes) => {
|
||||
let r = Response::from_data(bytes.as_ref().clone())
|
||||
.with_header(header("Content-Type", "image/jpeg"))
|
||||
.with_header(header("Cache-Control", "private, max-age=3600"))
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"));
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("sem miniatura")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> = slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp = Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("indisponível")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 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;
|
||||
use crate::source::Source;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -12,7 +12,11 @@ use std::path::Path;
|
||||
pub struct ImageReport {
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
|
||||
#[serde(default)]
|
||||
pub md5: String,
|
||||
#[serde(default)]
|
||||
pub sha1: String,
|
||||
/// `(offset, length)` of unreadable byte ranges that were zero-filled in the output image.
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
@@ -20,6 +24,111 @@ pub struct ImageReport {
|
||||
const BLOCK: usize = 1 << 20;
|
||||
const SECTOR: usize = 512;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum ImageFormat {
|
||||
#[default]
|
||||
Raw,
|
||||
/// Expert Witness Format (EnCase 6): segmented `.E01`.
|
||||
E01 {
|
||||
#[serde(default)]
|
||||
case_number: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
evidence_number: String,
|
||||
#[serde(default)]
|
||||
examiner: String,
|
||||
#[serde(default)]
|
||||
notes: String,
|
||||
/// Maximum size of each segment in MiB (0 = single segment).
|
||||
#[serde(default = "default_segment_mib")]
|
||||
segment_mib: u64,
|
||||
/// 0 = uncompressed, 1..=9 = deflate compression level.
|
||||
#[serde(default = "default_compression")]
|
||||
compression: u32,
|
||||
},
|
||||
/// AFF4 (v1.0 standard, snappy-compressed chunks).
|
||||
Aff4 {
|
||||
#[serde(default)]
|
||||
case_number: String,
|
||||
#[serde(default)]
|
||||
examiner: String,
|
||||
#[serde(default)]
|
||||
evidence_number: String,
|
||||
#[serde(default)]
|
||||
notes: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_segment_mib() -> u64 {
|
||||
1500
|
||||
}
|
||||
fn default_compression() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn create_image_as(
|
||||
src: &dyn Source,
|
||||
dest: &Path,
|
||||
format: &ImageFormat,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<ImageReport> {
|
||||
match format {
|
||||
ImageFormat::Raw => create_image(src, dest, cancelled, progress),
|
||||
ImageFormat::Aff4 {
|
||||
case_number,
|
||||
examiner,
|
||||
evidence_number,
|
||||
notes,
|
||||
} => {
|
||||
let opts = crate::aff4::Aff4Options {
|
||||
case_name: case_number.clone(),
|
||||
examiner: examiner.clone(),
|
||||
notes: notes.clone(),
|
||||
evidence_number: evidence_number.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let dest = if dest
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("aff4"))
|
||||
{
|
||||
dest.to_path_buf()
|
||||
} else {
|
||||
dest.with_extension("aff4")
|
||||
};
|
||||
crate::aff4::write_aff4(src, &dest, &opts, cancelled, progress)
|
||||
}
|
||||
ImageFormat::E01 {
|
||||
case_number,
|
||||
description,
|
||||
evidence_number,
|
||||
examiner,
|
||||
notes,
|
||||
segment_mib,
|
||||
compression,
|
||||
} => {
|
||||
let opts = crate::ewf::E01Options {
|
||||
case_number: case_number.clone(),
|
||||
description: description.clone(),
|
||||
evidence_number: evidence_number.clone(),
|
||||
examiner: examiner.clone(),
|
||||
notes: notes.clone(),
|
||||
segment_size: segment_mib * 1024 * 1024,
|
||||
compression: *compression,
|
||||
..Default::default()
|
||||
};
|
||||
// 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(),
|
||||
};
|
||||
crate::ewf::write_e01(src, &base, &opts, cancelled, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_image(
|
||||
src: &dyn Source,
|
||||
dest: &Path,
|
||||
@@ -36,9 +145,10 @@ pub fn create_image(
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
crate::owner::give_to_user(dest);
|
||||
let size = src.size();
|
||||
let mut rep = ImageReport::default();
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = MultiHasher::new();
|
||||
let mut buf = vec![0u8; BLOCK];
|
||||
let mut pos = 0u64;
|
||||
while pos < size {
|
||||
@@ -50,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);
|
||||
@@ -72,13 +182,12 @@ pub fn create_image(
|
||||
}
|
||||
out.flush()?;
|
||||
rep.bytes = pos;
|
||||
rep.sha256 = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let h = hasher.finalize();
|
||||
rep.sha256 = h.sha256;
|
||||
rep.md5 = h.md5;
|
||||
rep.sha1 = h.sha1;
|
||||
let map = dest.with_extension("map.json");
|
||||
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Baseline sequential JPEG Huffman entropy decoder (8-bit), plus lightweight IDCT.
|
||||
//!
|
||||
//! 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 {
|
||||
// canonical Huffman decoding tables
|
||||
mincode: [i32; 17],
|
||||
maxcode: [i32; 18],
|
||||
valptr: [i32; 17],
|
||||
vals: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Huff {
|
||||
fn new(counts: &[u8; 16], vals: Vec<u8>) -> Huff {
|
||||
let mut h = Huff {
|
||||
mincode: [0; 17],
|
||||
maxcode: [-1; 18],
|
||||
valptr: [0; 17],
|
||||
vals,
|
||||
};
|
||||
let mut code = 0i32;
|
||||
let mut k = 0i32;
|
||||
for l in 1..=16 {
|
||||
h.valptr[l] = k;
|
||||
h.mincode[l] = code;
|
||||
code += counts[l - 1] as i32;
|
||||
k += counts[l - 1] as i32;
|
||||
h.maxcode[l] = if counts[l - 1] > 0 { code - 1 } else { -1 };
|
||||
code <<= 1;
|
||||
}
|
||||
h.maxcode[17] = i32::MAX;
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Comp {
|
||||
pub h: u8,
|
||||
pub v: u8,
|
||||
pub tq: usize,
|
||||
pub dc_tbl: usize,
|
||||
pub ac_tbl: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Header {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub comps: Vec<Comp>,
|
||||
pub restart: u32,
|
||||
pub mcus_x: u32,
|
||||
pub mcus_y: u32,
|
||||
/// Byte offset where entropy-coded scan data begins (immediately after SOS).
|
||||
pub scan_start: usize,
|
||||
/// Quantization tables (in zigzag order).
|
||||
pub qt: [[u16; 64]; 4],
|
||||
dc: [Option<Huff>; 4],
|
||||
ac: [Option<Huff>; 4],
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn total_mcus(&self) -> u32 {
|
||||
self.mcus_x * self.mcus_y
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let mut p = 2;
|
||||
let mut dc: [Option<Huff>; 4] = Default::default();
|
||||
let mut ac: [Option<Huff>; 4] = Default::default();
|
||||
let mut sof: Option<(u32, u32, Vec<(u8, u8, usize)>)> = None;
|
||||
let mut qt = [[1u16; 64]; 4];
|
||||
let mut restart = 0u32;
|
||||
while p + 4 <= d.len() {
|
||||
if d[p] != 0xFF {
|
||||
return None;
|
||||
}
|
||||
let m = d[p + 1];
|
||||
if m == 0xFF {
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
if (0xD0..=0xD8).contains(&m) || m == 0x01 {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
let len = u16::from_be_bytes([d[p + 2], d[p + 3]]) as usize;
|
||||
if len < 2 || p + 2 + len > d.len() {
|
||||
return None;
|
||||
}
|
||||
let seg = &d[p + 4..p + 2 + len];
|
||||
match m {
|
||||
0xC0 | 0xC1 => {
|
||||
if seg.len() < 6 || seg[0] != 8 {
|
||||
return None;
|
||||
}
|
||||
let h = u16::from_be_bytes([seg[1], seg[2]]) as u32;
|
||||
let w = u16::from_be_bytes([seg[3], seg[4]]) as u32;
|
||||
let n = seg[5] as usize;
|
||||
if n == 0 || n > 4 || seg.len() < 6 + n * 3 || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let comps = (0..n)
|
||||
.map(|i| {
|
||||
(
|
||||
seg[7 + i * 3] >> 4,
|
||||
seg[7 + i * 3] & 15,
|
||||
seg[8 + i * 3] as usize & 3,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sof = Some((w, h, comps));
|
||||
}
|
||||
0xC2 | 0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return None, // progressive, arithmetic coding, etc.
|
||||
0xC4 => {
|
||||
let mut q = 0;
|
||||
while q + 17 <= seg.len() {
|
||||
let (cls, id) = (seg[q] >> 4, (seg[q] & 15) as usize);
|
||||
let mut counts = [0u8; 16];
|
||||
counts.copy_from_slice(&seg[q + 1..q + 17]);
|
||||
let n: usize = counts.iter().map(|&c| c as usize).sum();
|
||||
if id > 3 || q + 17 + n > seg.len() || n > 256 {
|
||||
return None;
|
||||
}
|
||||
let h = Huff::new(&counts, seg[q + 17..q + 17 + n].to_vec());
|
||||
if cls == 0 {
|
||||
dc[id] = Some(h);
|
||||
} else {
|
||||
ac[id] = Some(h);
|
||||
}
|
||||
q += 17 + n;
|
||||
}
|
||||
}
|
||||
0xDB => {
|
||||
let mut q = 0;
|
||||
while q < seg.len() {
|
||||
let (prec, id) = (seg[q] >> 4, (seg[q] & 15) as usize);
|
||||
q += 1;
|
||||
let n = if prec == 0 { 64 } else { 128 };
|
||||
if id > 3 || q + n > seg.len() {
|
||||
return None;
|
||||
}
|
||||
for i in 0..64 {
|
||||
qt[id][i] = if prec == 0 {
|
||||
seg[q + i] as u16
|
||||
} else {
|
||||
u16::from_be_bytes([seg[q + 2 * i], seg[q + 2 * i + 1]])
|
||||
};
|
||||
}
|
||||
q += n;
|
||||
}
|
||||
}
|
||||
0xDD => {
|
||||
if seg.len() >= 2 {
|
||||
restart = u16::from_be_bytes([seg[0], seg[1]]) as u32;
|
||||
}
|
||||
}
|
||||
0xDA => {
|
||||
let (w, h, comps) = sof?;
|
||||
let ns = *seg.first()? as usize;
|
||||
if ns != comps.len() || seg.len() < 1 + ns * 2 + 3 {
|
||||
return None; // non-interleaved scan
|
||||
}
|
||||
let mut cs = vec![];
|
||||
for i in 0..ns {
|
||||
let t = seg[2 + i * 2];
|
||||
cs.push(Comp {
|
||||
h: comps[i].0,
|
||||
v: comps[i].1,
|
||||
tq: comps[i].2,
|
||||
dc_tbl: (t >> 4) as usize & 3,
|
||||
ac_tbl: (t & 15) as usize & 3,
|
||||
});
|
||||
}
|
||||
let hmax = cs.iter().map(|c| c.h).max()? as u32;
|
||||
let vmax = cs.iter().map(|c| c.v).max()? as u32;
|
||||
if hmax == 0 || vmax == 0 || hmax > 4 || vmax > 4 {
|
||||
return None;
|
||||
}
|
||||
for c in &cs {
|
||||
if c.h == 0 || c.v == 0 || dc[c.dc_tbl].is_none() || ac[c.ac_tbl].is_none() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some(Header {
|
||||
width: w,
|
||||
height: h,
|
||||
comps: cs,
|
||||
restart,
|
||||
mcus_x: w.div_ceil(8 * hmax),
|
||||
mcus_y: h.div_ceil(8 * vmax),
|
||||
scan_start: p + 2 + len,
|
||||
qt,
|
||||
dc,
|
||||
ac,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Stop {
|
||||
/// All MCUs in the frame were decoded.
|
||||
Done,
|
||||
/// Unexpected marker (including early EOI) at the given byte offset.
|
||||
Marker(u8, usize),
|
||||
/// Invalid Huffman code or DCT coefficient index out of bounds.
|
||||
Error(usize),
|
||||
/// Truncated bitstream (ran out of input bytes).
|
||||
Truncated,
|
||||
}
|
||||
|
||||
/// Complete entropy decoder snapshot at a byte boundary, allowing resumption on candidate fragments.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct State {
|
||||
/// Next byte offset to read from the entropy stream.
|
||||
pub pos: usize,
|
||||
bitbuf: u32,
|
||||
bitcnt: u32,
|
||||
pred: [i32; 4],
|
||||
pub mcu: u32,
|
||||
rst_next: u8,
|
||||
mcus_since_rst: u32,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Last luminance DC predictor value at this checkpoint.
|
||||
pub fn luma_pred(&self) -> i32 {
|
||||
self.pred[0]
|
||||
}
|
||||
pub fn start(h: &Header) -> State {
|
||||
State {
|
||||
pos: h.scan_start,
|
||||
bitbuf: 0,
|
||||
bitcnt: 0,
|
||||
pred: [0; 4],
|
||||
mcu: 0,
|
||||
rst_next: 0,
|
||||
mcus_since_rst: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Decoded {
|
||||
/// Absolute luminance DC coefficient for each decoded MCU.
|
||||
pub dc: Vec<i32>,
|
||||
/// Periodic decoder checkpoints for resumption: `(mcu_index, state_after_mcu)`.
|
||||
pub snaps: Vec<State>,
|
||||
pub stop: Stop,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
struct Bits<'a> {
|
||||
d: &'a [u8],
|
||||
st: State,
|
||||
marker: Option<(u8, usize)>,
|
||||
}
|
||||
|
||||
impl Bits<'_> {
|
||||
fn fill(&mut self) {
|
||||
while self.st.bitcnt <= 24 && self.marker.is_none() {
|
||||
let Some(&b) = self.d.get(self.st.pos) else {
|
||||
break;
|
||||
};
|
||||
if b == 0xFF {
|
||||
match self.d.get(self.st.pos + 1) {
|
||||
Some(0) => {
|
||||
self.st.bitbuf |= 0xFF << (24 - self.st.bitcnt);
|
||||
self.st.bitcnt += 8;
|
||||
self.st.pos += 2;
|
||||
}
|
||||
Some(&m) => {
|
||||
self.marker = Some((m, self.st.pos));
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
} else {
|
||||
self.st.bitbuf |= (b as u32) << (24 - self.st.bitcnt);
|
||||
self.st.bitcnt += 8;
|
||||
self.st.pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn get(&mut self, n: u32) -> Option<u32> {
|
||||
if n == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
if self.st.bitcnt < n {
|
||||
self.fill();
|
||||
if self.st.bitcnt < n {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let v = self.st.bitbuf >> (32 - n);
|
||||
self.st.bitbuf <<= n;
|
||||
self.st.bitcnt -= n;
|
||||
Some(v)
|
||||
}
|
||||
fn huff(&mut self, h: &Huff) -> Option<u8> {
|
||||
self.fill();
|
||||
let mut code = 0i32;
|
||||
for l in 1..=16u32 {
|
||||
if self.st.bitcnt < l {
|
||||
return None;
|
||||
}
|
||||
code = (self.st.bitbuf >> (32 - l)) as i32;
|
||||
if h.maxcode[l as usize] >= 0
|
||||
&& code <= h.maxcode[l as usize]
|
||||
&& code >= h.mincode[l as usize]
|
||||
{
|
||||
self.st.bitbuf <<= l;
|
||||
self.st.bitcnt -= l;
|
||||
let idx = h.valptr[l as usize] + code - h.mincode[l as usize];
|
||||
return h.vals.get(idx as usize).copied();
|
||||
}
|
||||
}
|
||||
let _ = code;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn extend(v: u32, t: u32) -> i32 {
|
||||
if t == 0 {
|
||||
0
|
||||
} else if v < (1 << (t - 1)) {
|
||||
v as i32 - (1 << t) + 1
|
||||
} else {
|
||||
v as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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],
|
||||
state: State,
|
||||
max_mcus: u32,
|
||||
snap_every: usize,
|
||||
mut hook: Option<&mut dyn FnMut(u32, &[[i32; 64]])>,
|
||||
) -> Decoded {
|
||||
let mut lblocks: Vec<[i32; 64]> = Vec::new();
|
||||
let mut b = Bits {
|
||||
d: data,
|
||||
st: state,
|
||||
marker: None,
|
||||
};
|
||||
let total = h.total_mcus();
|
||||
let mut dc_out = Vec::new();
|
||||
let mut snaps = Vec::new();
|
||||
let mut last_snap = b.st.pos;
|
||||
let mut stop = Stop::Done;
|
||||
let mut decoded = 0u32;
|
||||
'mcu: while b.st.mcu < total && decoded < max_mcus {
|
||||
// Expected restart marker between MCUs.
|
||||
if h.restart > 0 && b.st.mcus_since_rst == h.restart {
|
||||
// discard byte-align padding bits and verify RSTn marker
|
||||
b.st.bitbuf = 0;
|
||||
b.st.bitcnt = 0;
|
||||
let p = b.st.pos;
|
||||
match (data.get(p), data.get(p + 1)) {
|
||||
(Some(0xFF), Some(&m)) if m == 0xD0 + b.st.rst_next => {
|
||||
b.st.pos += 2;
|
||||
b.st.rst_next = (b.st.rst_next + 1) & 7;
|
||||
b.st.mcus_since_rst = 0;
|
||||
b.st.pred = [0; 4];
|
||||
b.marker = None;
|
||||
}
|
||||
(Some(0xFF), Some(&m)) => {
|
||||
stop = Stop::Marker(m, p);
|
||||
break 'mcu;
|
||||
}
|
||||
(None, _) | (_, None) => {
|
||||
stop = Stop::Truncated;
|
||||
break 'mcu;
|
||||
}
|
||||
_ => {
|
||||
stop = Stop::Error(p);
|
||||
break 'mcu;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut luma_dc = 0;
|
||||
lblocks.clear();
|
||||
for (ci, c) in h.comps.iter().enumerate() {
|
||||
let dct = h.dc[c.dc_tbl].as_ref().unwrap();
|
||||
let act = h.ac[c.ac_tbl].as_ref().unwrap();
|
||||
for blk in 0..(c.h as u32 * c.v as u32) {
|
||||
let Some(t) = b.huff(dct) else {
|
||||
stop = fail(&b, data);
|
||||
break 'mcu;
|
||||
};
|
||||
if t > 11 {
|
||||
stop = Stop::Error(b.st.pos);
|
||||
break 'mcu;
|
||||
}
|
||||
let Some(bits) = b.get(t as u32) else {
|
||||
stop = fail(&b, data);
|
||||
break 'mcu;
|
||||
};
|
||||
b.st.pred[ci] += extend(bits, t as u32);
|
||||
if ci == 0 && blk == 0 {
|
||||
luma_dc = b.st.pred[0];
|
||||
}
|
||||
let mut coef = [0i32; 64];
|
||||
if ci == 0 {
|
||||
coef[0] = b.st.pred[0];
|
||||
}
|
||||
let mut k = 1;
|
||||
while k < 64 {
|
||||
let Some(rs) = b.huff(act) else {
|
||||
stop = fail(&b, data);
|
||||
break 'mcu;
|
||||
};
|
||||
let (r, s) = ((rs >> 4) as usize, (rs & 15) as u32);
|
||||
if s == 0 {
|
||||
if r == 15 {
|
||||
k += 16;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
k += r;
|
||||
if k > 63 {
|
||||
stop = Stop::Error(b.st.pos);
|
||||
break 'mcu;
|
||||
}
|
||||
let Some(vbits) = b.get(s) else {
|
||||
stop = fail(&b, data);
|
||||
break 'mcu;
|
||||
};
|
||||
if ci == 0 && hook.is_some() {
|
||||
coef[k] = extend(vbits, s);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
if ci == 0 && hook.is_some() {
|
||||
lblocks.push(coef);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(hk) = hook.as_mut() {
|
||||
hk(b.st.mcu, &lblocks);
|
||||
}
|
||||
dc_out.push(luma_dc);
|
||||
b.st.mcu += 1;
|
||||
b.st.mcus_since_rst += 1;
|
||||
decoded += 1;
|
||||
if snap_every > 0 && b.st.pos >= last_snap + snap_every {
|
||||
snaps.push(b.st.clone());
|
||||
last_snap = b.st.pos;
|
||||
}
|
||||
}
|
||||
Decoded {
|
||||
dc: dc_out,
|
||||
snaps,
|
||||
stop,
|
||||
state: b.st,
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(b: &Bits, data: &[u8]) -> Stop {
|
||||
if let Some((m, p)) = b.marker {
|
||||
Stop::Marker(m, p)
|
||||
} else if b.st.pos >= data.len() {
|
||||
Stop::Truncated
|
||||
} else {
|
||||
Stop::Error(b.st.pos)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
.map(|i| {
|
||||
if i % mx > 0 {
|
||||
(dc[i] - dc[i - 1]).abs() as f64
|
||||
} else if i >= mx {
|
||||
(dc[i] - dc[i - mx]).abs() as f64
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let win = 24usize;
|
||||
if d.len() < win * 2 {
|
||||
return None;
|
||||
}
|
||||
// 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();
|
||||
for x in &d[..win] {
|
||||
hist[(*x as usize).min(2047)] += 1;
|
||||
seen += 1;
|
||||
}
|
||||
for i in win..d.len() {
|
||||
sum += d[i] - d[i - win];
|
||||
if i >= 64 + win && i % 8 == 0 {
|
||||
let mut acc = 0u32;
|
||||
let mut med = 0usize;
|
||||
for (v, &c) in hist.iter().enumerate() {
|
||||
acc += c;
|
||||
if acc * 2 >= seen {
|
||||
med = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if sum / win as f64 > (6.0 * med as f64).max(40.0) {
|
||||
return Some(i + 1 - win);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- pixels
|
||||
|
||||
const ZIGZAG: [usize; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20,
|
||||
13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59,
|
||||
52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
fn cos_table() -> &'static [[f32; 8]; 8] {
|
||||
static T: std::sync::OnceLock<[[f32; 8]; 8]> = std::sync::OnceLock::new();
|
||||
T.get_or_init(|| {
|
||||
let mut t = [[0f32; 8]; 8];
|
||||
for x in 0..8 {
|
||||
for u in 0..8 {
|
||||
let cu = if u == 0 {
|
||||
std::f32::consts::FRAC_1_SQRT_2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
t[x][u] = 0.5
|
||||
* cu
|
||||
* (((2 * x + 1) as f32 * u as f32 * std::f32::consts::PI) / 16.0).cos();
|
||||
}
|
||||
}
|
||||
t
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let n = ZIGZAG[z];
|
||||
f[n / 8][n % 8] = (coef[z] * qt[z] as i32) as f32;
|
||||
}
|
||||
let t = cos_table();
|
||||
// rows
|
||||
let mut tmp = [[0f32; 8]; 8];
|
||||
for v in 0..8 {
|
||||
for x in 0..8 {
|
||||
let mut s = 0.0;
|
||||
for u in 0..8 {
|
||||
s += t[x][u] * f[v][u];
|
||||
}
|
||||
tmp[v][x] = s;
|
||||
}
|
||||
}
|
||||
let mut out = [0u8; 64];
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let mut s = 0.0;
|
||||
for v in 0..8 {
|
||||
s += t[y][v] * tmp[v][x];
|
||||
}
|
||||
out[y * 8 + x] = (s + 128.0).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let w = bw * 8;
|
||||
let mut out = vec![0u8; w * bh * 8];
|
||||
for (i, blk) in blocks.iter().enumerate().take(bw * bh) {
|
||||
let px = idct_block(blk, &h.qt[c.tq]);
|
||||
let (bx, by) = (i % bw, i / bw);
|
||||
for y in 0..8 {
|
||||
out[(by * 8 + y) * w + bx * 8..(by * 8 + y) * w + bx * 8 + 8]
|
||||
.copy_from_slice(&px[y * 8..y * 8 + 8]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Luma pixel plane of the decoded image prefix.
|
||||
pub struct Plane {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub px: Vec<u8>,
|
||||
pub mw: usize,
|
||||
pub mh: usize,
|
||||
pub have: u32,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
pub fn new(h: &Header) -> Plane {
|
||||
let c = &h.comps[0];
|
||||
let (mw, mh) = (c.h as usize * 8, c.v as usize * 8);
|
||||
Plane {
|
||||
w: mw * h.mcus_x as usize,
|
||||
h: mh * h.mcus_y as usize,
|
||||
px: vec![0; mw * h.mcus_x as usize * mh * h.mcus_y as usize],
|
||||
mw,
|
||||
mh,
|
||||
have: 0,
|
||||
}
|
||||
}
|
||||
pub fn put(&mut self, h: &Header, mcu: u32, luma: &[u8]) {
|
||||
let (mx, my) = ((mcu % h.mcus_x) as usize, (mcu / h.mcus_x) as usize);
|
||||
for y in 0..self.mh {
|
||||
let dst = (my * self.mh + y) * self.w + mx * self.mw;
|
||||
self.px[dst..dst + self.mw].copy_from_slice(&luma[y * self.mw..(y + 1) * self.mw]);
|
||||
}
|
||||
self.have = self.have.max(mcu + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes `data` from `state` and renders the luma plane.
|
||||
pub fn decode_plane(
|
||||
h: &Header,
|
||||
data: &[u8],
|
||||
state: State,
|
||||
max_mcus: u32,
|
||||
plane: &mut Plane,
|
||||
) -> Decoded {
|
||||
let mut hook = |mcu: u32, blocks: &[[i32; 64]]| {
|
||||
let l = mcu_luma(h, blocks);
|
||||
plane.put(h, mcu, &l);
|
||||
};
|
||||
decode_hook(h, data, state, max_mcus, 512, Some(&mut hook))
|
||||
}
|
||||
@@ -1,20 +1,58 @@
|
||||
//! Guará — núcleo de recuperação de arquivos.
|
||||
#![allow(
|
||||
clippy::type_complexity,
|
||||
clippy::too_many_arguments,
|
||||
clippy::if_same_then_else,
|
||||
clippy::large_enum_variant,
|
||||
clippy::needless_range_loop
|
||||
)]
|
||||
//! 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;
|
||||
pub mod audit;
|
||||
pub mod carve;
|
||||
pub mod crypt;
|
||||
pub mod defrag;
|
||||
pub mod device;
|
||||
pub mod error;
|
||||
pub mod ewf;
|
||||
pub mod explorer;
|
||||
pub mod external;
|
||||
pub mod formats;
|
||||
pub mod fs;
|
||||
pub mod hashing;
|
||||
pub mod health;
|
||||
pub mod http;
|
||||
pub mod image;
|
||||
pub mod jpegscan;
|
||||
pub mod lvm;
|
||||
pub mod meta;
|
||||
pub mod mp4rebuild;
|
||||
pub mod notify;
|
||||
pub mod owner;
|
||||
pub mod partition;
|
||||
pub mod pdf;
|
||||
pub mod raid;
|
||||
pub mod raid_detect;
|
||||
pub mod reconnect;
|
||||
pub mod recover;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod settings;
|
||||
pub mod sign;
|
||||
pub mod signatures;
|
||||
pub mod source;
|
||||
pub mod sqlite;
|
||||
pub mod thumb;
|
||||
pub mod types;
|
||||
pub mod update;
|
||||
pub mod vdisk;
|
||||
pub mod volume;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
//! 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};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MDA_MAGIC: &[u8; 16] = b" LVM2 x[5A%r0N*>";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PvInfo {
|
||||
/// 32-character LVM UUID normalized without hyphens.
|
||||
pub uuid: String,
|
||||
pub size: u64,
|
||||
pub seqno: u64,
|
||||
pub vg_name: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let sector = (0..4).find(|&s| &head[s * 512..s * 512 + 8] == b"LABELONE")?;
|
||||
let lab = &head[sector * 512..(sector + 1) * 512];
|
||||
if &lab[24..32] != b"LVM2 001" {
|
||||
return None;
|
||||
}
|
||||
let off = u32::from_le_bytes(lab[20..24].try_into().ok()?) as usize;
|
||||
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()?);
|
||||
// Null-terminated list of data area descriptors followed by metadata area descriptors.
|
||||
let mut o = 40;
|
||||
let rd = |o: usize| -> Option<(u64, u64)> {
|
||||
Some((
|
||||
u64::from_le_bytes(pv.get(o..o + 8)?.try_into().ok()?),
|
||||
u64::from_le_bytes(pv.get(o + 8..o + 16)?.try_into().ok()?),
|
||||
))
|
||||
};
|
||||
loop {
|
||||
let (a, b) = rd(o)?;
|
||||
o += 16;
|
||||
if a == 0 && b == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut mdas = vec![];
|
||||
loop {
|
||||
let (a, b) = rd(o)?;
|
||||
o += 16;
|
||||
if a == 0 && b == 0 {
|
||||
break;
|
||||
}
|
||||
mdas.push((a, b));
|
||||
}
|
||||
let mut best: Option<PvInfo> = None;
|
||||
for (mda_off, mda_size) in mdas {
|
||||
let Ok(h) = read_vec(src, mda_off, 512) else {
|
||||
continue;
|
||||
};
|
||||
if h.len() < 512 || &h[4..20] != MDA_MAGIC {
|
||||
continue;
|
||||
}
|
||||
// raw_locn[0]
|
||||
let loc_off = u64::from_le_bytes(h[40..48].try_into().unwrap());
|
||||
let loc_size = u64::from_le_bytes(h[48..56].try_into().unwrap());
|
||||
if loc_size == 0 || loc_size > 16 << 20 || mda_size < 1024 {
|
||||
continue;
|
||||
}
|
||||
let mut text = vec![];
|
||||
if loc_off + loc_size <= mda_size {
|
||||
if let Ok(t) = read_vec(src, mda_off + loc_off, loc_size as usize) {
|
||||
text = t;
|
||||
}
|
||||
} else {
|
||||
// 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),
|
||||
read_vec(src, mda_off + 512, (loc_size - first) as usize),
|
||||
) {
|
||||
text = [a, b].concat();
|
||||
}
|
||||
}
|
||||
let s = String::from_utf8_lossy(&text)
|
||||
.trim_end_matches('\0')
|
||||
.to_string();
|
||||
let Some(tree) = parse(&s) else { continue };
|
||||
let Some((vg_name, vg)) = tree
|
||||
.sections
|
||||
.iter()
|
||||
.find(|(_, sec)| sec.values.contains_key("extent_size"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let seqno = vg.int("seqno").unwrap_or(0);
|
||||
let cand = PvInfo {
|
||||
uuid: uuid.clone(),
|
||||
size,
|
||||
seqno,
|
||||
vg_name: vg_name.clone(),
|
||||
text: s.clone(),
|
||||
};
|
||||
if best.as_ref().map(|b| cand.seqno > b.seqno).unwrap_or(true) {
|
||||
best = Some(cand);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ text parser
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Val {
|
||||
Int(i64),
|
||||
Str(String),
|
||||
List(Vec<Val>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Section {
|
||||
pub values: BTreeMap<String, Val>,
|
||||
/// Child sections in declaration order.
|
||||
pub sections: Vec<(String, Section)>,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub fn int(&self, k: &str) -> Option<u64> {
|
||||
match self.values.get(k)? {
|
||||
Val::Int(i) => Some(*i as u64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn str(&self, k: &str) -> Option<&str> {
|
||||
match self.values.get(k)? {
|
||||
Val::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn list(&self, k: &str) -> Option<&[Val]> {
|
||||
match self.values.get(k)? {
|
||||
Val::List(l) => Some(l),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn sub(&self, name: &str) -> Option<&Section> {
|
||||
self.sections
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, s)| s)
|
||||
}
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl Lexer<'_> {
|
||||
fn skip(&mut self) {
|
||||
loop {
|
||||
while self.i < self.b.len()
|
||||
&& (self.b[self.i].is_ascii_whitespace() || self.b[self.i] == b',')
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
if self.i < self.b.len() && self.b[self.i] == b'#' {
|
||||
while self.i < self.b.len() && self.b[self.i] != b'\n' {
|
||||
self.i += 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn ident(&mut self) -> Option<String> {
|
||||
self.skip();
|
||||
let s = self.i;
|
||||
while self.i < self.b.len()
|
||||
&& !self.b[self.i].is_ascii_whitespace()
|
||||
&& !matches!(
|
||||
self.b[self.i],
|
||||
b'=' | b'{' | b'}' | b'[' | b']' | b'"' | b'#'
|
||||
)
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
(self.i > s).then(|| String::from_utf8_lossy(&self.b[s..self.i]).to_string())
|
||||
}
|
||||
fn value(&mut self) -> Option<Val> {
|
||||
self.skip();
|
||||
match *self.b.get(self.i)? {
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
let s = self.i;
|
||||
while self.i < self.b.len() && self.b[self.i] != b'"' {
|
||||
if self.b[self.i] == b'\\' {
|
||||
self.i += 1;
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
let v = String::from_utf8_lossy(&self.b[s..self.i.min(self.b.len())]).to_string();
|
||||
self.i += 1;
|
||||
Some(Val::Str(v))
|
||||
}
|
||||
b'[' => {
|
||||
self.i += 1;
|
||||
let mut l = vec![];
|
||||
loop {
|
||||
self.skip();
|
||||
if *self.b.get(self.i)? == b']' {
|
||||
self.i += 1;
|
||||
break;
|
||||
}
|
||||
l.push(self.value()?);
|
||||
}
|
||||
Some(Val::List(l))
|
||||
}
|
||||
_ => {
|
||||
let s = self.i;
|
||||
while self.i < self.b.len()
|
||||
&& (self.b[self.i].is_ascii_digit() || self.b[self.i] == b'-')
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
if self.i == s {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(&self.b[s..self.i])
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()
|
||||
.map(Val::Int)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn section(&mut self, top: bool) -> Option<Section> {
|
||||
let mut sec = Section::default();
|
||||
loop {
|
||||
self.skip();
|
||||
if self.i >= self.b.len() {
|
||||
return top.then_some(sec);
|
||||
}
|
||||
if self.b[self.i] == b'}' {
|
||||
self.i += 1;
|
||||
return (!top).then_some(sec);
|
||||
}
|
||||
let name = self.ident()?;
|
||||
self.skip();
|
||||
match *self.b.get(self.i)? {
|
||||
b'=' => {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
sec.values.insert(name, v);
|
||||
}
|
||||
b'{' => {
|
||||
self.i += 1;
|
||||
let s = self.section(false)?;
|
||||
sec.sections.push((name, s));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Option<Section> {
|
||||
Lexer {
|
||||
b: text.as_bytes(),
|
||||
i: 0,
|
||||
}
|
||||
.section(true)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- assembly
|
||||
|
||||
fn dashed(u: &str) -> String {
|
||||
if u.len() != 32 {
|
||||
return u.to_string();
|
||||
}
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}-{}-{}",
|
||||
&u[0..6],
|
||||
&u[6..10],
|
||||
&u[10..14],
|
||||
&u[14..18],
|
||||
&u[18..22],
|
||||
&u[22..26],
|
||||
&u[26..32]
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Map {
|
||||
/// Linear segment: reads from `src` starting at `base`.
|
||||
Linear {
|
||||
src: Option<Arc<dyn Source>>,
|
||||
base: u64,
|
||||
},
|
||||
Striped {
|
||||
srcs: Vec<(Option<Arc<dyn Source>>, u64)>,
|
||||
stripe: u64,
|
||||
},
|
||||
Mirror {
|
||||
legs: Vec<(Option<Arc<dyn Source>>, u64)>,
|
||||
},
|
||||
Raid(Arc<RaidSource>),
|
||||
}
|
||||
|
||||
struct Seg {
|
||||
start: u64,
|
||||
len: u64,
|
||||
map: Map,
|
||||
}
|
||||
|
||||
pub struct LvSource {
|
||||
segs: Vec<Seg>,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
impl Source for LvSource {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if offset >= self.size || buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let want = buf.len().min((self.size - offset) as usize);
|
||||
let mut done = 0;
|
||||
while done < want {
|
||||
let pos = offset + done as u64;
|
||||
let i = self.segs.partition_point(|s| s.start + s.len <= pos);
|
||||
let Some(s) = self.segs.get(i) else { break };
|
||||
let rel = pos - s.start;
|
||||
let mut n = ((s.len - rel) as usize).min(want - done);
|
||||
let dst = &mut buf[done..done + n];
|
||||
match &s.map {
|
||||
Map::Linear { src, base } => match src {
|
||||
Some(src) => n = read_full(src.as_ref(), base + rel, dst)?,
|
||||
None => dst.fill(0),
|
||||
},
|
||||
Map::Striped { srcs, stripe } => {
|
||||
let ns = srcs.len() as u64;
|
||||
let (idx, within) = (rel / stripe, rel % stripe);
|
||||
n = n.min((stripe - within) as usize);
|
||||
let (src, base) = &srcs[(idx % ns) as usize];
|
||||
let off = base + (idx / ns) * stripe + within;
|
||||
match src {
|
||||
Some(src) => n = read_full(src.as_ref(), off, &mut dst[..n])?,
|
||||
None => dst[..n].fill(0),
|
||||
}
|
||||
}
|
||||
Map::Mirror { legs } => {
|
||||
let mut ok = false;
|
||||
for (src, base) in legs {
|
||||
if let Some(src) = src {
|
||||
if let Ok(g) = read_full(src.as_ref(), base + rel, dst) {
|
||||
n = g;
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
dst.fill(0);
|
||||
}
|
||||
}
|
||||
Map::Raid(r) => n = read_full(r.as_ref(), rel, dst)?,
|
||||
}
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_full(src: &dyn Source, off: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let mut got = 0;
|
||||
while got < buf.len() {
|
||||
let n = src.read_at(off + got as u64, &mut buf[got..])?;
|
||||
if n == 0 {
|
||||
buf[got..].fill(0);
|
||||
return Ok(buf.len());
|
||||
}
|
||||
got += n;
|
||||
}
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
pub struct VgResult {
|
||||
pub name: String,
|
||||
pub lvs: Vec<(String, Arc<dyn Source>)>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn mk_stripes(list: &[Val]) -> Vec<(String, u64)> {
|
||||
let mut out = vec![];
|
||||
for pair in list.chunks(2) {
|
||||
if let [Val::Str(n), Val::Int(e)] = pair {
|
||||
out.push((n.clone(), *e as u64));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Ctx<'a> {
|
||||
ext: u64,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn build(&mut self, name: &str, depth: u32) -> Option<Arc<dyn Source>> {
|
||||
if let Some(c) = self.cache.get(name) {
|
||||
return c.clone();
|
||||
}
|
||||
if depth > 6 {
|
||||
return None;
|
||||
}
|
||||
let lv = self.lvs.sub(name)?.clone();
|
||||
let nsegs = lv.int("segment_count").unwrap_or(0);
|
||||
let mut segs: Vec<Seg> = vec![];
|
||||
for i in 1..=nsegs {
|
||||
let Some(seg) = lv.sub(&format!("segment{i}")) else {
|
||||
self.warnings.push(format!("{name}: segmento {i} ausente"));
|
||||
return None;
|
||||
};
|
||||
let start = seg.int("start_extent").unwrap_or(0) * self.ext;
|
||||
let len = seg.int("extent_count").unwrap_or(0) * self.ext;
|
||||
let ty = seg.str("type").unwrap_or("").to_string();
|
||||
let map = match ty.as_str() {
|
||||
"striped" => {
|
||||
let st = mk_stripes(seg.list("stripes").unwrap_or(&[]));
|
||||
let srcs: Vec<(Option<Arc<dyn Source>>, u64)> = st
|
||||
.iter()
|
||||
.map(|(pv, e)| match self.pvs.get(pv) {
|
||||
Some((s, pe)) => (s.clone(), pe + e * self.ext),
|
||||
None => (None, 0),
|
||||
})
|
||||
.collect();
|
||||
if srcs.iter().any(|(s, _)| s.is_none()) {
|
||||
self.warnings.push(format!(
|
||||
"{name}: falta um volume físico; as áreas dele aparecem zeradas"
|
||||
));
|
||||
}
|
||||
if srcs.len() == 1 {
|
||||
let (src, base) = srcs.into_iter().next().unwrap();
|
||||
Map::Linear { src, base }
|
||||
} else {
|
||||
Map::Striped {
|
||||
srcs,
|
||||
stripe: seg.int("stripe_size").unwrap_or(128) * 512,
|
||||
}
|
||||
}
|
||||
}
|
||||
"mirror" => {
|
||||
let st = mk_stripes(seg.list("mirrors").unwrap_or(&[]));
|
||||
let legs = st
|
||||
.iter()
|
||||
.filter_map(|(pv, e)| {
|
||||
self.pvs
|
||||
.get(pv)
|
||||
.map(|(s, pe)| (s.clone(), pe + e * self.ext))
|
||||
})
|
||||
.collect();
|
||||
Map::Mirror { legs }
|
||||
}
|
||||
t if t.starts_with("raid") => {
|
||||
let raids = seg.list("raids").unwrap_or(&[]);
|
||||
let imgs: Vec<String> = raids
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
if let Val::Str(s) = v {
|
||||
Some(s.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// 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![];
|
||||
for n in &data_lvs {
|
||||
members.push(self.build(n, depth + 1).map(|src| Member {
|
||||
src,
|
||||
data_offset: 0,
|
||||
}));
|
||||
}
|
||||
let chunk = seg.int("stripe_size").unwrap_or(128) * 512;
|
||||
let (level, layout) = match t {
|
||||
"raid1" => (Level::R1, Layout::LeftSymmetric),
|
||||
"raid0" | "raid0_meta" => (Level::R0, Layout::LeftSymmetric),
|
||||
"raid4" | "raid5_n" => (Level::R4, Layout::ParityLast),
|
||||
"raid5" | "raid5_ls" => (Level::R5, Layout::LeftSymmetric),
|
||||
"raid5_la" => (Level::R5, Layout::LeftAsymmetric),
|
||||
"raid5_rs" => (Level::R5, Layout::RightSymmetric),
|
||||
"raid5_ra" => (Level::R5, Layout::RightAsymmetric),
|
||||
"raid6" | "raid6_zr" | "raid6_ls_6" => (Level::R6, Layout::LeftSymmetric),
|
||||
"raid6_nr" => (Level::R6, Layout::LeftAsymmetric),
|
||||
"raid6_nc" | "raid6_n_6" => (Level::R6, Layout::ParityLast),
|
||||
"raid10" => (Level::R10, Layout::LeftSymmetric),
|
||||
other => {
|
||||
self.warnings
|
||||
.push(format!("{name}: tipo {other} não suportado"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut p = RaidParams::simple(level, chunk.max(512), layout, members);
|
||||
p.near = 2;
|
||||
p.far = 1;
|
||||
match RaidSource::new(p) {
|
||||
Ok(r) => Map::Raid(Arc::new(r)),
|
||||
Err(e) => {
|
||||
self.warnings.push(format!("{name}: {e}"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.warnings.push(format!(
|
||||
"{name}: tipo de segmento \"{other}\" não suportado"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
segs.push(Seg { start, len, map });
|
||||
}
|
||||
segs.sort_by_key(|s| s.start);
|
||||
let size = segs.iter().map(|s| s.start + s.len).max().unwrap_or(0);
|
||||
let src: Arc<dyn Source> = Arc::new(LvSource { segs, size });
|
||||
self.cache.insert(name.to_string(), Some(src.clone()));
|
||||
Some(src)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
by_vg.entry(p.vg_name.clone()).or_default().push((s, p));
|
||||
}
|
||||
let mut out = vec![];
|
||||
for (vg_name, list) in by_vg {
|
||||
let newest = list.iter().max_by_key(|(_, p)| p.seqno).unwrap();
|
||||
let Some(tree) = parse(&newest.1.text) else {
|
||||
continue;
|
||||
};
|
||||
let Some(vg) = tree.sub(&vg_name) else {
|
||||
continue;
|
||||
};
|
||||
let ext = vg.int("extent_size").unwrap_or(8192) * 512;
|
||||
let mut pvmap = BTreeMap::new();
|
||||
let mut warnings = vec![];
|
||||
if let Some(pvsec) = vg.sub("physical_volumes") {
|
||||
for (pvname, def) in &pvsec.sections {
|
||||
let id = def.str("id").unwrap_or("").to_string();
|
||||
let found = list
|
||||
.iter()
|
||||
.find(|(_, p)| dashed(&p.uuid) == id)
|
||||
.map(|(s, _)| s.clone());
|
||||
if found.is_none() {
|
||||
warnings.push(format!("volume físico {pvname} ({id}) não foi fornecido"));
|
||||
}
|
||||
pvmap.insert(
|
||||
pvname.clone(),
|
||||
(found, def.int("pe_start").unwrap_or(2048) * 512),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(lvsec) = vg.sub("logical_volumes") else {
|
||||
continue;
|
||||
};
|
||||
let mut ctx = Ctx {
|
||||
ext,
|
||||
pvs: pvmap,
|
||||
lvs: lvsec,
|
||||
cache: BTreeMap::new(),
|
||||
warnings,
|
||||
};
|
||||
let mut lvs = vec![];
|
||||
for (name, def) in &lvsec.sections {
|
||||
// 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")))
|
||||
.unwrap_or(false);
|
||||
if hidden {
|
||||
continue;
|
||||
}
|
||||
if let Some(src) = ctx.build(name, 0) {
|
||||
lvs.push((name.clone(), src));
|
||||
}
|
||||
}
|
||||
out.push(VgResult {
|
||||
name: vg_name,
|
||||
lvs,
|
||||
warnings: ctx.warnings,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||