Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8139858a90 | ||
|
|
fdf1e20cfb | ||
|
|
13f380291f | ||
|
|
0b9a16e8a0 |
No files matched your search
@@ -4,6 +4,22 @@
|
|||||||
|
|
||||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-11-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Recovery report redesigned. The HTML is now an A4 paginated document (Paged.js embedded, still a single offline file) with a full-bleed deep teal cover where the Guará logo is the hero, a sector-map motif drawn from the findings (one cell per file, colored by confidence), headline numbers, a meta grid and the TLP:AMBER badge, plus a table of contents, running headers and page counters. The PDF gets the same cover (logo embedded as JPEG) and restyled inner pages. All forensic content is unchanged.
|
||||||
|
- Documentation brought up to date (ROADMAP version table, platform matrix, version stamps) and rewritten without em dashes. The interface screenshots were retaken from the current build, and `scripts/screenshots.sh` now regenerates them.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-11-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Update progress bar.** The in-app update shows a real download percentage bar followed by the verify/install/restart phases.
|
||||||
|
- **Delete saved sessions.** Each autosaved session in the sidebar has a delete button, plus a "delete all" button. Settings gained "Delete autosaved sessions after" (never, 7, 30 or 90 days), applied on startup. Sessions open in the app are never removed automatically.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Updating no longer freezes the app.** The update check and install ran on the UI thread; they now run in the background.
|
||||||
|
- **Pause and Stop respond right away.** The signature scan only checked for the request once per 8 MiB block; it now checks continuously, and the UI shows "Pausing…" immediately.
|
||||||
|
|
||||||
## [1.4.0] - 2026-11-03
|
## [1.4.0] - 2026-11-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -13,7 +29,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versio
|
|||||||
## [1.3.5] - 2026-10-31
|
## [1.3.5] - 2026-10-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **One-click desktop update.** The "Update now" button actually downloads, verifies (SHA-256) and installs the new version now, instead of only linking to the Releases page. Works for all three desktop packages: AppImage (atomic self-replace), `.deb` (`dpkg -i`, no new password prompt — the app is already running as root/administrator by the time you click it), and the Windows installer (silent `/S` install). On Linux, the app restarts itself automatically; on Windows, the installer runs silently but the app does not reopen on its own yet — start it again once the install finishes.
|
- **One-click desktop update.** The "Update now" button actually downloads, verifies (SHA-256) and installs the new version now, instead of only linking to the Releases page. Works for all three desktop packages: AppImage (atomic self-replace), `.deb` (`dpkg -i`, no new password prompt, the app is already running as root/administrator by the time you click it), and the Windows installer (silent `/S` install). On Linux, the app restarts itself automatically; on Windows, the installer runs silently but the app does not reopen on its own yet, start it again once the install finishes.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **The repository moved to the Ciberfauna organization** (`ciberfauna/guara`, was `lucasrguerra/guara`). **Important notice for existing installations:** versions up to 1.3.4 have the old location baked into their update check; they will not find this release automatically. Download 1.3.5 once manually from the new Releases page, and future update checks will work through the new location.
|
- **The repository moved to the Ciberfauna organization** (`ciberfauna/guara`, was `lucasrguerra/guara`). **Important notice for existing installations:** versions up to 1.3.4 have the old location baked into their update check; they will not find this release automatically. Download 1.3.5 once manually from the new Releases page, and future update checks will work through the new location.
|
||||||
@@ -21,7 +37,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versio
|
|||||||
## [1.3.4] - 2026-10-31
|
## [1.3.4] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Desktop app: only one window on startup.** The main window used to be visible from launch, so the unprivileged instance's normal window would show, flash while the system asked for the administrator password, and only then get replaced by the elevated relaunch's window a moment later. The window now stays hidden until elevation is resolved — either already elevated, elevation failed (the window then shows the warning), or this is the already-elevated relaunched instance — so only one window ever appears, matching Sabiá.
|
- **Desktop app: only one window on startup.** The main window used to be visible from launch, so the unprivileged instance's normal window would show, flash while the system asked for the administrator password, and only then get replaced by the elevated relaunch's window a moment later. The window now stays hidden until elevation is resolved (either already elevated, elevation failed (the window then shows the warning), or this is the already-elevated relaunched instance) so only one window ever appears, matching Sabiá.
|
||||||
|
|
||||||
## [1.3.3] - 2026-10-30
|
## [1.3.3] - 2026-10-30
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-11-04
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Relatório de recuperação redesenhado. O HTML agora é um documento A4 paginado (Paged.js embutido, continua um único arquivo offline) com capa de página inteira em verde-petróleo escuro onde o logo do Guará é o destaque, um mapa de setores desenhado a partir dos achados (uma célula por arquivo, colorida pela confiança), números principais, grade de metadados e o selo TLP:AMBER, além de sumário, cabeçalhos corridos e numeração de páginas. O PDF ganhou a mesma capa (logo embutido como JPEG) e páginas internas com novo visual. Todo o conteúdo forense continua igual.
|
||||||
|
- Documentação atualizada (tabela de versões do ROADMAP, matriz de plataformas, versões citadas) e reescrita sem travessões. As capturas de tela da interface foram refeitas a partir da versão atual, e o `scripts/screenshots.sh` agora as regera.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-11-03
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- **Barra de progresso da atualização.** A atualização dentro do app mostra uma barra com a porcentagem real do download, seguida das fases de verificação, instalação e reinício.
|
||||||
|
- **Apagar sessões salvas.** Cada sessão salva sozinha na lateral tem um botão de apagar, além de um botão "apagar todas". As configurações ganharam "Apagar sessões salvas sozinhas após" (nunca, 7, 30 ou 90 dias), aplicado ao abrir. Sessões abertas no app nunca são removidas automaticamente.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- **Atualizar não trava mais o app.** A verificação e a instalação rodavam na thread da interface; agora rodam em segundo plano.
|
||||||
|
- **Pausar e Parar respondem na hora.** A varredura por assinatura só checava o pedido a cada bloco de 8 MiB; agora checa continuamente, e a interface mostra "Pausando…" imediatamente.
|
||||||
|
|
||||||
## [1.4.0] - 2026-11-03
|
## [1.4.0] - 2026-11-03
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
@@ -13,7 +29,7 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/);
|
|||||||
## [1.3.5] - 2026-10-31
|
## [1.3.5] - 2026-10-31
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
- **Atualização do app desktop em um clique.** O botão "Atualizar agora" agora baixa, confere (SHA-256) e instala a versão nova de verdade, em vez de só linkar pra página de Releases. Funciona nos três pacotes do desktop: AppImage (substituição atômica), `.deb` (`dpkg -i`, sem pedir senha de novo — o app já roda como root/administrador quando você clica), e o instalador do Windows (instalação silenciosa `/S`). No Linux o app reabre sozinho; no Windows o instalador roda silencioso mas o app ainda não reabre por conta própria — abra de novo depois que a instalação terminar.
|
- **Atualização do app desktop em um clique.** O botão "Atualizar agora" agora baixa, confere (SHA-256) e instala a versão nova de verdade, em vez de só linkar pra página de Releases. Funciona nos três pacotes do desktop: AppImage (substituição atômica), `.deb` (`dpkg -i`, sem pedir senha de novo, o app já roda como root/administrador quando você clica), e o instalador do Windows (instalação silenciosa `/S`). No Linux o app reabre sozinho; no Windows o instalador roda silencioso mas o app ainda não reabre por conta própria, abra de novo depois que a instalação terminar.
|
||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
- **O repositório se mudou pra organização Ciberfauna** (`ciberfauna/guara`, era `lucasrguerra/guara`). **Aviso importante pra instalações existentes:** versões até a 1.3.4 têm o endereço antigo fixo na checagem de atualização e não vão encontrar esta versão sozinhas. Baixe a 1.3.5 manualmente uma vez na nova página de Releases, e as próximas verificações voltam a funcionar pelo novo endereço.
|
- **O repositório se mudou pra organização Ciberfauna** (`ciberfauna/guara`, era `lucasrguerra/guara`). **Aviso importante pra instalações existentes:** versões até a 1.3.4 têm o endereço antigo fixo na checagem de atualização e não vão encontrar esta versão sozinhas. Baixe a 1.3.5 manualmente uma vez na nova página de Releases, e as próximas verificações voltam a funcionar pelo novo endereço.
|
||||||
@@ -21,7 +37,7 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/);
|
|||||||
## [1.3.4] - 2026-10-31
|
## [1.3.4] - 2026-10-31
|
||||||
|
|
||||||
### Corrigido
|
### Corrigido
|
||||||
- **App desktop: só uma janela na abertura.** A janela principal ficava visível desde o lançamento, então a instância sem privilégio mostrava a janela normal, piscava enquanto o sistema pedia a senha de administrador, e só depois era substituída pela janela da instância já elevada. Agora a janela fica escondida até a elevação ser resolvida — já elevada, falhou (a janela aí mostra o aviso), ou é a própria instância já relançada como administrador — então só uma janela aparece, igual ao Sabiá.
|
- **App desktop: só uma janela na abertura.** A janela principal ficava visível desde o lançamento, então a instância sem privilégio mostrava a janela normal, piscava enquanto o sistema pedia a senha de administrador, e só depois era substituída pela janela da instância já elevada. Agora a janela fica escondida até a elevação ser resolvida (já elevada, falhou (a janela aí mostra o aviso), ou é a própria instância já relançada como administrador) então só uma janela aparece, igual ao Sabiá.
|
||||||
|
|
||||||
## [1.3.3] - 2026-10-30
|
## [1.3.3] - 2026-10-30
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Create the reader in `crates/guara-core/src/fs/`, register it in `fs/mod.rs` and
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
The screenshots in `docs/img` are generated: run `scripts/screenshots.sh` to retake them from the current build (synthetic photo image, real scan). Run it again whenever the interface or the version shown on screen changes.
|
||||||
|
|
||||||
## Commits
|
## Commits
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
@@ -47,6 +47,8 @@ Crie o leitor em `crates/guara-core/src/fs/`, registre-o em `fs/mod.rs` e traga
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
As capturas de tela em `docs/img` são geradas: rode `scripts/screenshots.sh` para refazê-las a partir da versão atual (imagem de fotos sintética, varredura de verdade). Rode de novo sempre que a interface ou a versão exibida na tela mudar.
|
||||||
|
|
||||||
## Commits
|
## 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`.
|
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`.
|
||||||
@@ -416,7 +416,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -429,7 +429,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/guara-core", "crates/guara-cli"]
|
members = ["crates/guara-core", "crates/guara-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
|
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ Download the package for your system from **[Releases](../../releases)**:
|
|||||||
|
|
||||||
| | CLI | Graphical app |
|
| | CLI | Graphical app |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Windows | — | `.msi` / `.exe` installer |
|
| Windows | none | `.msi` / `.exe` installer |
|
||||||
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||||
|
|
||||||
The CLI is Linux-only; the desktop app ships for Linux and Windows. Every CLI
|
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
|
package has a `.sha256` next to it. To build from source: `cargo build
|
||||||
--release -p guara-cli` (Rust 1.80+, Linux).
|
--release -p guara-cli` (Rust 1.80+, Linux).
|
||||||
|
|
||||||
**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.
|
**Updates:** the desktop app checks Gitea on startup; when a new version exists, a notice offers the release link. The CLI updates the same way (`guara update`); it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable, no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||||
|
|
||||||
> The binaries are **not signed yet**: Windows SmartScreen will warn you. Check the SHA-256.
|
> The binaries are **not signed yet**: Windows SmartScreen will warn you. Check the SHA-256.
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
|
|||||||
- **Timeline:** creation, modification and access dates from NTFS, FAT and exFAT.
|
- **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.
|
- **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.
|
- **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.
|
- **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name. Saved sessions can be deleted one by one or by age.
|
||||||
- **Duplicate detection** by content signature.
|
- **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).
|
> **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).
|
||||||
@@ -120,7 +120,7 @@ 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 |
|
| **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 |
|
| **Next** | Trusted timestamps (RFC 3161), more tests on Windows |
|
||||||
| **Not planned** | A macOS build — see [Known limits](#known-limits) |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
|||||||
|
|
||||||
| | CLI | App gráfico |
|
| | CLI | App gráfico |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Windows | — | instalador `.msi` / `.exe` |
|
| Windows | nenhum | instalador `.msi` / `.exe` |
|
||||||
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
| 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
|
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
|
CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
|
||||||
guara-cli` (Rust 1.80+, Linux).
|
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.
|
**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.
|
> Os binários ainda **não são assinados**: o Windows SmartScreen vai avisar. Confira o SHA-256.
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
|
|||||||
- **Linha do tempo:** datas de criação, modificação e acesso do NTFS, FAT e exFAT.
|
- **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.
|
- **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.
|
- **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.
|
- **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome. As sessões salvas podem ser apagadas uma a uma ou por idade.
|
||||||
- **Detecção de duplicadas** por assinatura de conteúdo.
|
- **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).
|
> **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).
|
||||||
@@ -120,7 +120,7 @@ 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 |
|
| **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 |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
|
|||||||
|
|
||||||
> Items marked done for macOS reflect what the GitHub CI used to build and test,
|
> 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
|
> 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
|
> tests Linux and Windows. macOS is not built or tested any more, see the
|
||||||
> README's [Known limits](README.md#known-limits).
|
> README's [Known limits](README.md#known-limits).
|
||||||
|
|
||||||
## Principles that do not change
|
## Principles that do not change
|
||||||
@@ -34,10 +34,16 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
|
|||||||
| 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.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.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 |
|
| 1.3.1 | 2026-10-23 | New official logo |
|
||||||
|
| 1.3.2 | 2026-10-30 | Updates come from Gitea |
|
||||||
|
| 1.3.3 | 2026-10-30 | New update mechanism (same model as Sabiá), `guara update` for the CLI |
|
||||||
|
| 1.3.4 | 2026-10-31 | Desktop app opens a single window at startup |
|
||||||
|
| 1.3.5 | 2026-10-31 | One-click desktop update, repository moved to the Ciberfauna organization |
|
||||||
|
| 1.4.0 | 2026-11-03 | Browse by folder, recover whole folders or parts of them |
|
||||||
|
| 1.5.0 | 2026-11-03 | Update progress bar and no more freezing, instant Pause/Stop, delete saved sessions manually or by age |
|
||||||
|
|
||||||
Details of each version in the [CHANGELOG](CHANGELOG.md).
|
Details of each version in the [CHANGELOG](CHANGELOG.md).
|
||||||
|
|
||||||
## Current status (1.3.1) against what is expected from a forensic suite
|
## Current status (1.5.0) against what is expected from a forensic suite
|
||||||
|
|
||||||
### 1. File systems with metadata
|
### 1. File systems with metadata
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
|
|||||||
|
|
||||||
> Itens marcados como feitos para macOS refletem o que o CI do GitHub compilava e
|
> 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;
|
> 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 —
|
> 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.
|
> veja os [Limites conhecidos](README.pt-BR.md#limites-conhecidos) do README.
|
||||||
|
|
||||||
## Princípios que não mudam
|
## Princípios que não mudam
|
||||||
@@ -34,10 +34,16 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
|
|||||||
| 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.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.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 |
|
| 1.3.1 | 2026-10-23 | Nova logo oficial |
|
||||||
|
| 1.3.2 | 2026-10-30 | As atualizações passam a vir do Gitea |
|
||||||
|
| 1.3.3 | 2026-10-30 | Novo mecanismo de atualização (mesmo modelo do Sabiá), `guara update` no CLI |
|
||||||
|
| 1.3.4 | 2026-10-31 | O app desktop abre uma única janela ao iniciar |
|
||||||
|
| 1.3.5 | 2026-10-31 | Atualização do app desktop em um clique, repositório movido para a organização Ciberfauna |
|
||||||
|
| 1.4.0 | 2026-11-03 | Navegação por pastas, recuperação de pastas inteiras ou de partes delas |
|
||||||
|
| 1.5.0 | 2026-11-03 | Barra de progresso da atualização sem travar o app, Pausar/Parar imediatos, apagar sessões salvas à mão ou por idade |
|
||||||
|
|
||||||
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
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
|
## Situação atual (1.5.0) contra o que se espera de uma suíte forense
|
||||||
|
|
||||||
### 1. Sistemas de arquivos com metadados
|
### 1. Sistemas de arquivos com metadados
|
||||||
|
|
||||||
|
|||||||
@@ -1342,7 +1342,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1373,7 +1373,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"guara-core",
|
"guara-core",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
description = "Guará: graphical file recovery interface"
|
description = "Guará: graphical file recovery interface"
|
||||||
version = "1.4.0"
|
version = "1.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
authors = ["Guará contributors"]
|
authors = ["Guará contributors"]
|
||||||
|
|||||||
@@ -22,25 +22,41 @@ async fn rpc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
async fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
let eng = state.0.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let status = eng.update_check().map_err(|e| e.to_string())?;
|
||||||
serde_json::to_value(status).map_err(|e| e.to_string())
|
serde_json::to_value(status).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
|
async fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default());
|
let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default());
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
let exe = std::env::current_exe().unwrap_or_default();
|
let exe = std::env::current_exe().unwrap_or_default();
|
||||||
|
|
||||||
state
|
// Blocking download/install must not run on the UI thread, or the window freezes.
|
||||||
.0
|
let eng = state.0.clone();
|
||||||
.update_apply_desktop(&exe, move |phase| {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _ = app.emit("update-progress", serde_json::json!({ "phase": phase }));
|
eng.update_apply_desktop(&exe, move |phase| {
|
||||||
|
let (name, pct) = match phase.split_once(':') {
|
||||||
|
Some((n, p)) => (n, p.parse::<u8>().ok()),
|
||||||
|
None => (phase, None),
|
||||||
|
};
|
||||||
|
let _ = app.emit(
|
||||||
|
"update-progress",
|
||||||
|
serde_json::json!({ "phase": name, "percent": pct }),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when the process already has sufficient privileges to read raw disks.
|
/// `true` when the process already has sufficient privileges to read raw disks.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Guará",
|
"productName": "Guará",
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
|
|||||||
@@ -542,3 +542,8 @@ dialog.lightbox[open] { display: grid; }
|
|||||||
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.update-card .sub { margin: 0; }
|
.update-card .sub { margin: 0; }
|
||||||
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
|
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
|
||||||
|
.update-card .upd-bar { margin-top: 10px; }
|
||||||
|
|
||||||
|
.list li.has-del { position: relative; }
|
||||||
|
.list li.has-del .del { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); opacity: 0; }
|
||||||
|
.list li.has-del:hover .del, .list li.has-del .del:focus-visible { opacity: 1; }
|
||||||
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ settings
|
// ------------------------------------------------------------------ settings
|
||||||
// (declared before use: `S` is defined further below, so the object lives here)
|
// (declared before use: `S` is defined further below, so the object lives here)
|
||||||
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
|
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true, session_retention_days: 0 };
|
||||||
async function saveSettings(patch) {
|
async function saveSettings(patch) {
|
||||||
Object.assign(SETTINGS, patch);
|
Object.assign(SETTINGS, patch);
|
||||||
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
|
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
|
||||||
@@ -201,11 +201,36 @@ function renderSide() {
|
|||||||
const rl = $('#recentList');
|
const rl = $('#recentList');
|
||||||
rl.dataset.empty = 'Nada salvo ainda';
|
rl.dataset.empty = 'Nada salvo ainda';
|
||||||
rl.innerHTML = (S.recent || []).map((r, i) => `
|
rl.innerHTML = (S.recent || []).map((r, i) => `
|
||||||
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
|
<li class="has-del"><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('');
|
<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><button class="icon-btn small del" data-del="${i}" title="Apagar esta sessão" aria-label="Apagar esta sessão"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg></button></li>`).join('');
|
||||||
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
||||||
|
rl.querySelectorAll('.del').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); deleteRecent(S.recent[+b.dataset.del]); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDlg({ title, text, ok }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const d = $('#dlg');
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text}</p><menu><button class="btn" value="cancel">Cancelar</button><button class="btn danger" value="ok">${esc(ok)}</button></menu></form>`;
|
||||||
|
d.onclose = () => resolve(d.returnValue === 'ok');
|
||||||
|
d.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecent(r) {
|
||||||
|
if (!r) return;
|
||||||
|
const name = (r.target || '').split(/[\\/]/).pop() || 'Sessão';
|
||||||
|
if (!await confirmDlg({ title: 'Apagar sessão salva', text: `A sessão de <code>${esc(name)}</code> será apagada de vez. Os arquivos do disco não são afetados.`, ok: 'Apagar' })) return;
|
||||||
|
try { await rpc('session_delete', { path: r.path }); toast('Sessão apagada'); } catch (e) { toast(e.message); }
|
||||||
|
loadRecent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#clearRecentBtn')?.addEventListener('click', async () => {
|
||||||
|
if (!(S.recent || []).length) { toast('Nada para apagar'); return; }
|
||||||
|
if (!await confirmDlg({ title: 'Apagar todas as sessões salvas', text: 'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.', ok: 'Apagar tudo' })) return;
|
||||||
|
try { const r = await rpc('sessions_prune', { all: true }); toast(`Sessões apagadas: ${r.removed}`); } catch (e) { toast(e.message); }
|
||||||
|
loadRecent();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadRecent() {
|
async function loadRecent() {
|
||||||
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
||||||
renderSide();
|
renderSide();
|
||||||
@@ -590,7 +615,9 @@ function paintStatus() {
|
|||||||
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
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;
|
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||||
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 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>'
|
const stopping = running && st.stopping;
|
||||||
|
const buttons = stopping ? '<button class="btn sm" disabled>Pausando…</button>'
|
||||||
|
: 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>` : '';
|
: 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 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>' : '');
|
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>' : '');
|
||||||
@@ -651,8 +678,8 @@ async function chooseSource() {
|
|||||||
async function onProgressClick(e) {
|
async function onProgressClick(e) {
|
||||||
const act = e.target.closest('[data-act]')?.dataset.act;
|
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||||
if (!act) return;
|
if (!act) return;
|
||||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); tick(); }
|
||||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
else if (act === 'stop') { await rpc('scan_stop', { job: S.job }); tick(); }
|
||||||
else if (act === 'resume') {
|
else if (act === 'resume') {
|
||||||
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||||
catch (err) { toast(err.message); chooseSource(); }
|
catch (err) { toast(err.message); chooseSource(); }
|
||||||
@@ -753,9 +780,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
<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="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="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>
|
<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>
|
||||||
|
<label class="fld"><span>Apagar sessões salvas sozinhas após</span><select class="select" name="retention"><option value="0">Nunca</option><option value="7">7 dias</option><option value="30">30 dias</option><option value="90">90 dias</option></select></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>` : ''}
|
${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>`;
|
<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('[name=lang]').value = SETTINGS.lang;
|
||||||
|
d.querySelector('[name=retention]').value = String([0, 7, 30, 90].includes(SETTINGS.session_retention_days) ? SETTINGS.session_retention_days : 0);
|
||||||
d.querySelector('#updNow')?.addEventListener('click', (e) => {
|
d.querySelector('#updNow')?.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (UPD) applyUpdate();
|
if (UPD) applyUpdate();
|
||||||
@@ -763,11 +792,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
d.onclose = () => {
|
d.onclose = () => {
|
||||||
if (d.returnValue !== 'ok') return;
|
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 };
|
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked, session_retention_days: +d.querySelector('[name=retention]').value };
|
||||||
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
||||||
setLang(patch.lang);
|
setLang(patch.lang);
|
||||||
patch.ui_lang = currentLang();
|
patch.ui_lang = currentLang();
|
||||||
saveSettings(patch);
|
saveSettings(patch).then(() => rpc('sessions_prune', {}).then(loadRecent).catch(() => {}));
|
||||||
EX?.relang?.();
|
EX?.relang?.();
|
||||||
toast('Salvo');
|
toast('Salvo');
|
||||||
};
|
};
|
||||||
@@ -785,7 +814,15 @@ async function applyUpdate() {
|
|||||||
updating = true;
|
updating = true;
|
||||||
const updApply = $('#updApply');
|
const updApply = $('#updApply');
|
||||||
const updNow = $('#updNow');
|
const updNow = $('#updNow');
|
||||||
const setPhase = (txt) => {
|
const setPhase = (txt, pct) => {
|
||||||
|
const card = $('#updateCard');
|
||||||
|
if (card) {
|
||||||
|
let bar = card.querySelector('.upd-bar');
|
||||||
|
if (!bar) { bar = document.createElement('div'); bar.className = 'bar upd-bar'; bar.innerHTML = '<i></i>'; card.appendChild(bar); }
|
||||||
|
bar.classList.toggle('indet', pct == null);
|
||||||
|
bar.firstChild.style.width = pct == null ? '35%' : `${pct}%`;
|
||||||
|
}
|
||||||
|
if (pct != null) txt = `${txt} ${pct}%`;
|
||||||
if (updApply) { updApply.disabled = true; updApply.textContent = txt; }
|
if (updApply) { updApply.disabled = true; updApply.textContent = txt; }
|
||||||
if (updNow) { updNow.disabled = true; updNow.textContent = txt; }
|
if (updNow) { updNow.disabled = true; updNow.textContent = txt; }
|
||||||
};
|
};
|
||||||
@@ -796,7 +833,8 @@ async function applyUpdate() {
|
|||||||
try {
|
try {
|
||||||
unlisten = await tauri.event.listen('update-progress', (event) => {
|
unlisten = await tauri.event.listen('update-progress', (event) => {
|
||||||
const phase = event.payload?.phase;
|
const phase = event.payload?.phase;
|
||||||
if (phase === 'downloading') setPhase('Baixando a atualização…');
|
const pct = event.payload?.percent;
|
||||||
|
if (phase === 'downloading') setPhase('Baixando a atualização…', pct);
|
||||||
else if (phase === 'verifying') setPhase('Verificando integridade…');
|
else if (phase === 'verifying') setPhase('Verificando integridade…');
|
||||||
else if (phase === 'installing') setPhase('Instalando a atualização…');
|
else if (phase === 'installing') setPhase('Instalando a atualização…');
|
||||||
else if (phase === 'restarting') setPhase('Reiniciando…');
|
else if (phase === 'restarting') setPhase('Reiniciando…');
|
||||||
@@ -809,6 +847,7 @@ async function applyUpdate() {
|
|||||||
setPhase('Atualização concluída');
|
setPhase('Atualização concluída');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updating = false;
|
updating = false;
|
||||||
|
$('#updateCard .upd-bar')?.remove();
|
||||||
if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; }
|
if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; }
|
||||||
if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; }
|
if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; }
|
||||||
toast(`Falha na atualização: ${e}`);
|
toast(`Falha na atualização: ${e}`);
|
||||||
@@ -914,6 +953,7 @@ function watchClose() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadDevices();
|
await loadDevices();
|
||||||
|
try { await rpc('sessions_prune', {}); } catch { /* retention is best-effort */ }
|
||||||
await loadRecent();
|
await loadRecent();
|
||||||
renderHome();
|
renderHome();
|
||||||
})();
|
})();
|
||||||
@@ -619,4 +619,20 @@ export const EN = {
|
|||||||
'{n} arquivo': '{n} file',
|
'{n} arquivo': '{n} file',
|
||||||
'{n} arquivos': '{n} files',
|
'{n} arquivos': '{n} files',
|
||||||
'{n} {n} · {n}': '{n} {n} · {n}',
|
'{n} {n} · {n}': '{n} {n} · {n}',
|
||||||
|
'Apagar esta sessão': 'Delete this session',
|
||||||
|
'Apagar todas as sessões salvas sozinhas': 'Delete all autosaved sessions',
|
||||||
|
'Apagar sessão salva': 'Delete saved session',
|
||||||
|
'Apagar': 'Delete',
|
||||||
|
'Apagar tudo': 'Delete all',
|
||||||
|
'Apagar todas as sessões salvas': 'Delete all saved sessions',
|
||||||
|
'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.': 'All autosaved sessions will be permanently deleted. Sessions currently open are kept.',
|
||||||
|
'Sessão apagada': 'Session deleted',
|
||||||
|
'Nada para apagar': 'Nothing to delete',
|
||||||
|
'Apagar sessões salvas sozinhas após': 'Delete autosaved sessions after',
|
||||||
|
'Nunca': 'Never',
|
||||||
|
'7 dias': '7 days',
|
||||||
|
'30 dias': '30 days',
|
||||||
|
'90 dias': '90 days',
|
||||||
|
'será apagada de vez. Os arquivos do disco não são afetados.': 'will be permanently deleted. Files on the disk are not affected.',
|
||||||
|
'Sessões apagadas: {n}': 'Sessions deleted: {n}',
|
||||||
};
|
};
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<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>
|
<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>
|
<div class="side-title">Sessões</div>
|
||||||
<ul id="sessionList" class="list"></ul>
|
<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>
|
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small> <button class="icon-btn small" id="clearRecentBtn" title="Apagar todas as sessões salvas sozinhas" aria-label="Apagar todas as sessões salvas sozinhas"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M5 7h14M10 7V5h4v2m-6 0l1 12h6l1-12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
||||||
<ul id="recentList" class="list"></ul>
|
<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>
|
<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>
|
</nav>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
|
||||||
|
*/
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PagedPolyfill=t()}(this,(function(){"use strict";function e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t,n,r={exports:{}},a={exports:{}};var i,o,s,l,d=function(){},p=d(),c=function(e){return e!==p&&null!==e};function u(){return l||(l=1,s=(n?t:(n=1,t=function(){try{return Object.keys("primitive"),!0}catch(e){return!1}}))()?Object.keys:function(){if(o)return i;o=1;var e=c,t=Object.keys;return i=function(n){return t(e(n)?Object(n):n)}}()),s}var m,h,g=c,f=function(e){if(!g(e))throw new TypeError("Cannot use null or undefined");return e};var y,b,S=function(){var e,t=Object.assign;return"function"==typeof t&&(t(e={foo:"raz"},{bar:"dwa"},{trzy:"trzy"}),e.foo+e.bar+e.trzy==="razdwatrzy")}()?Object.assign:function(){if(h)return m;h=1;var e=u(),t=f,n=Math.max;return m=function(r,a){var i,o,s,l=n(arguments.length,2);for(r=Object(t(r)),s=function(e){try{r[e]=a[e]}catch(e){i||(i=e)}},o=1;o<l;++o)e(a=arguments[o]).forEach(s);if(void 0!==i)throw i;return r},m}(),v=c,x=Array.prototype.forEach,k=Object.create,w="razdwatrzy";var C,T="function"==typeof w.contains&&!0===w.contains("dwa")&&!1===w.contains("foo")?String.prototype.contains:function(){if(b)return y;b=1;var e=String.prototype.indexOf;return y=function(t){return e.call(this,t,arguments[1])>-1},y}(),_=S,z=function(e){var t=k(null);return x.call(arguments,(function(e){v(e)&&function(e,t){var n;for(n in e)t[n]=e[n]}(Object(e),t)})),t},O=function(e){return"function"==typeof e},A=T;C=a.exports=function(e,t){var n,r,a,i,o;return arguments.length<2||"string"!=typeof e?(i=t,t=e,e=null):i=arguments[2],null==e?(n=a=!0,r=!1):(n=A.call(e,"c"),r=A.call(e,"e"),a=A.call(e,"w")),o={value:t,configurable:n,enumerable:r,writable:a},i?_(z(i),o):o},C.gs=function(e,t,n){var r,a,i,o;return"string"!=typeof e?(i=n,n=t,t=e,e=null):i=arguments[3],null==t?t=void 0:O(t)?null==n?n=void 0:O(n)||(i=n,n=void 0):(i=t,t=n=void 0),null==e?(r=!0,a=!1):(r=A.call(e,"c"),a=A.call(e,"e")),o={get:t,set:n,configurable:r,enumerable:a},i?_(z(i),o):o};var E=a.exports,j=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e};!function(e,t){var n,r,a,i,o,s,l,d=E,p=j,c=Function.prototype.apply,u=Function.prototype.call,m=Object.create,h=Object.defineProperty,g=Object.defineProperties,f=Object.prototype.hasOwnProperty,y={configurable:!0,enumerable:!1,writable:!0};n=function(e,t){var n;return p(t),f.call(this,"__ee__")?n=this.__ee__:(n=y.value=m(null),h(this,"__ee__",y),y.value=null),n[e]?"object"==typeof n[e]?n[e].push(t):n[e]=[n[e],t]:n[e]=t,this},r=function(e,t){var r,i;return p(t),i=this,n.call(this,e,r=function(){a.call(i,e,r),c.call(t,this,arguments)}),r.__eeOnceListener__=t,this},a=function(e,t){var n,r,a,i;if(p(t),!f.call(this,"__ee__"))return this;if(!(n=this.__ee__)[e])return this;if("object"==typeof(r=n[e]))for(i=0;a=r[i];++i)a!==t&&a.__eeOnceListener__!==t||(2===r.length?n[e]=r[i?0:1]:r.splice(i,1));else r!==t&&r.__eeOnceListener__!==t||delete n[e];return this},i=function(e){var t,n,r,a,i;if(f.call(this,"__ee__")&&(a=this.__ee__[e]))if("object"==typeof a){for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];for(a=a.slice(),t=0;r=a[t];++t)c.call(r,this,i)}else switch(arguments.length){case 1:u.call(a,this);break;case 2:u.call(a,this,arguments[1]);break;case 3:u.call(a,this,arguments[1],arguments[2]);break;default:for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];c.call(a,this,i)}},o={on:n,once:r,off:a,emit:i},s={on:d(n),once:d(r),off:d(a),emit:d(i)},l=g({},s),e.exports=t=function(e){return null==e?m(l):g(Object(e),s)},t.methods=o}(r,r.exports);var W=r.exports,L=e(W);class B{constructor(e){this.context=e||this,this.hooks=[]}register(){for(var e=0;e<arguments.length;++e)if("function"==typeof arguments[e])this.hooks.push(arguments[e]);else for(var t=0;t<arguments[e].length;++t)this.hooks.push(arguments[e][t])}trigger(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);a&&"function"==typeof a.then?n.push(a):n.push(new Promise(((e,t)=>{e(a)})))})),Promise.all(n)}triggerSync(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);n.push(a)})),n}list(){return this.hooks}clear(){return this.hooks=[]}}function P(e){if(!e)return;let t;if(void 0!==e.getBoundingClientRect)t=e.getBoundingClientRect();else{let n=document.createRange();n.selectNode(e),t=n.getBoundingClientRect()}return t}function q(e){if(!e)return;let t;if(void 0!==e.getClientRects)t=e.getClientRects();else{let n=document.createRange();n.selectNode(e),t=n.getClientRects()}return t}function M(){var e=(new Date).getTime();return"undefined"!=typeof performance&&"function"==typeof performance.now&&(e+=performance.now()),"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replaceLine truncated
|
||||||
@@ -64,7 +64,19 @@ pub fn carve_range(
|
|||||||
let scan_len = got.min(CHUNK).min((end - pos) as usize);
|
let scan_len = got.min(CHUNK).min((end - pos) as usize);
|
||||||
ev.chunk(pos, &buf[..scan_len]);
|
ev.chunk(pos, &buf[..scan_len]);
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
|
let mut since_check = 0u32;
|
||||||
while i < scan_len {
|
while i < scan_len {
|
||||||
|
// Pause/stop must take effect within a fraction of a second even on slow media,
|
||||||
|
// not only at 8 MiB chunk boundaries. The chunk is reprocessed on resume (hits at
|
||||||
|
// offsets already found are skipped by the caller).
|
||||||
|
since_check += 1;
|
||||||
|
if since_check >= 2048 {
|
||||||
|
since_check = 0;
|
||||||
|
if cancelled() {
|
||||||
|
*next = pos;
|
||||||
|
return CarveEnd::Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
let abs = pos + i as u64;
|
let abs = pos + i as u64;
|
||||||
if abs < skip_until {
|
if abs < skip_until {
|
||||||
i += SECTOR as usize;
|
i += SECTOR as usize;
|
||||||
@@ -78,6 +90,10 @@ pub fn carve_range(
|
|||||||
let allowed = cfg.categories.is_empty()
|
let allowed = cfg.categories.is_empty()
|
||||||
|| cfg.categories.contains(&category_for_ext(hit.ext));
|
|| cfg.categories.contains(&category_for_ext(hit.ext));
|
||||||
if hit.len >= MIN_LEN && allowed {
|
if hit.len >= MIN_LEN && allowed {
|
||||||
|
if cancelled() {
|
||||||
|
*next = pos;
|
||||||
|
return CarveEnd::Cancelled;
|
||||||
|
}
|
||||||
let action = ev.hit(abs, &hit);
|
let action = ev.hit(abs, &hit);
|
||||||
match action {
|
match action {
|
||||||
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
|
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use std::io::Write;
|
|||||||
const W: f64 = 595.28;
|
const W: f64 = 595.28;
|
||||||
const H: f64 = 841.89;
|
const H: f64 = 841.89;
|
||||||
const MARGIN: f64 = 40.0;
|
const MARGIN: f64 = 40.0;
|
||||||
|
/// Top of the text area on inner pages (leaves room for the running header).
|
||||||
|
const TOP: f64 = H - 60.0;
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Font {
|
pub enum Font {
|
||||||
@@ -179,12 +181,22 @@ pub struct Pdf {
|
|||||||
pages: Vec<String>,
|
pages: Vec<String>,
|
||||||
cur: String,
|
cur: String,
|
||||||
y: f64,
|
y: f64,
|
||||||
|
/// Running header (left text, right TLP label) drawn on every inner page.
|
||||||
|
header: Option<(String, String)>,
|
||||||
|
/// Leading full-bleed pages that get no running header/footer.
|
||||||
|
covers: usize,
|
||||||
|
/// Baseline JPEG (`/DCTDecode`): bytes, pixel width, pixel height.
|
||||||
|
image: Option<(&'static [u8], u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rgb = (f64, f64, f64);
|
type Rgb = (f64, f64, f64);
|
||||||
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
|
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
|
||||||
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
|
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
|
||||||
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
|
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
|
||||||
|
pub const DEEP: Rgb = (0.027, 0.212, 0.200);
|
||||||
|
pub const AMBER: Rgb = (0.70, 0.42, 0.0);
|
||||||
|
const ZEBRA: Rgb = (0.953, 0.976, 0.973);
|
||||||
|
const HAIR: Rgb = (0.86, 0.91, 0.90);
|
||||||
|
|
||||||
impl Pdf {
|
impl Pdf {
|
||||||
pub fn new(title: &str, footer: &str) -> Self {
|
pub fn new(title: &str, footer: &str) -> Self {
|
||||||
@@ -193,7 +205,10 @@ impl Pdf {
|
|||||||
footer: footer.into(),
|
footer: footer.into(),
|
||||||
pages: vec![],
|
pages: vec![],
|
||||||
cur: String::new(),
|
cur: String::new(),
|
||||||
y: H - MARGIN,
|
y: TOP,
|
||||||
|
header: None,
|
||||||
|
covers: 0,
|
||||||
|
image: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +217,10 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new_page(&mut self) {
|
fn new_page(&mut self) {
|
||||||
if !self.cur.is_empty() || self.y != H - MARGIN {
|
if !self.cur.is_empty() || self.y != TOP {
|
||||||
self.pages.push(std::mem::take(&mut self.cur));
|
self.pages.push(std::mem::take(&mut self.cur));
|
||||||
}
|
}
|
||||||
self.y = H - MARGIN;
|
self.y = TOP;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure(&mut self, h: f64) {
|
fn ensure(&mut self, h: f64) {
|
||||||
@@ -214,7 +229,7 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
|
pub fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,20 +243,80 @@ impl Pdf {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
|
pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
|
||||||
self.cur.push_str(&format!(
|
self.cur.push_str(&format!(
|
||||||
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
|
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
|
||||||
c.0, c.1, c.2
|
c.0, c.1, c.2
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
|
pub fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
|
||||||
self.cur.push_str(&format!(
|
self.cur.push_str(&format!(
|
||||||
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
|
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
|
||||||
c.0, c.1, c.2
|
c.0, c.1, c.2
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Page size in points (A4).
|
||||||
|
pub const SIZE: (f64, f64) = (W, H);
|
||||||
|
|
||||||
|
/// Sets the running header shown on inner pages: `left` in muted caps, `tlp` in amber.
|
||||||
|
pub fn running_header(&mut self, left: &str, tlp: &str) {
|
||||||
|
self.header = Some((left.into(), tlp.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the single JPEG image (drawn with [`Pdf::image`]); must be baseline DCT.
|
||||||
|
pub fn set_image(&mut self, jpeg: &'static [u8], w: u32, h: u32) {
|
||||||
|
self.image = Some((jpeg, w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the registered image at `(x, y)` (bottom-left) scaled to `w` x `h` points.
|
||||||
|
pub fn image(&mut self, x: f64, y: f64, w: f64, h: f64) {
|
||||||
|
self.cur
|
||||||
|
.push_str(&format!("q {w:.2} 0 0 {h:.2} {x:.2} {y:.2} cm /Im1 Do Q\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the current page as a full-bleed cover (no running header or footer).
|
||||||
|
/// Must be called before any inner content is laid out.
|
||||||
|
pub fn end_cover(&mut self) {
|
||||||
|
self.pages.push(std::mem::take(&mut self.cur));
|
||||||
|
self.covers = self.pages.len();
|
||||||
|
self.y = TOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a fresh page unless the current one is still empty.
|
||||||
|
pub fn page_break(&mut self) {
|
||||||
|
if !self.cur.is_empty() {
|
||||||
|
self.new_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row of KPI tiles: (label, value, detail). Value colored by `accent`.
|
||||||
|
pub fn kpis(&mut self, items: &[(&str, String, String, Rgb)]) {
|
||||||
|
let gap = 8.0;
|
||||||
|
let n = items.len().max(1) as f64;
|
||||||
|
let w = (Self::content_width() - gap * (n - 1.0)) / n;
|
||||||
|
let h = 58.0;
|
||||||
|
self.ensure(h + 8.0);
|
||||||
|
let top = self.y;
|
||||||
|
for (i, (l, v, d, c)) in items.iter().enumerate() {
|
||||||
|
let x = MARGIN + i as f64 * (w + gap);
|
||||||
|
self.rect(x, top - h, w, h, ZEBRA);
|
||||||
|
self.rect(x, top - 2.5, w, 2.5, *c);
|
||||||
|
self.put(
|
||||||
|
x + 8.0,
|
||||||
|
top - 15.0,
|
||||||
|
&l.to_uppercase(),
|
||||||
|
6.5,
|
||||||
|
Font::Mono,
|
||||||
|
GRAY,
|
||||||
|
);
|
||||||
|
self.put(x + 8.0, top - 38.0, v, 20.0, Font::Bold, *c);
|
||||||
|
self.put(x + 8.0, top - 51.0, d, 7.5, Font::Regular, GRAY);
|
||||||
|
}
|
||||||
|
self.y -= h + 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn space(&mut self, h: f64) {
|
pub fn space(&mut self, h: f64) {
|
||||||
self.y -= h;
|
self.y -= h;
|
||||||
}
|
}
|
||||||
@@ -255,10 +330,13 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn heading(&mut self, s: &str) {
|
pub fn heading(&mut self, s: &str) {
|
||||||
self.ensure(50.0);
|
self.ensure(70.0);
|
||||||
self.y -= 14.0;
|
if self.y < TOP {
|
||||||
self.put(MARGIN, self.y - 11.0, s, 12.0, Font::Bold, TEAL);
|
self.y -= 16.0;
|
||||||
self.y -= 20.0;
|
}
|
||||||
|
self.rect(MARGIN, self.y - 3.0, 22.0, 3.0, TEAL);
|
||||||
|
self.put(MARGIN, self.y - 21.0, s, 15.0, Font::Bold, DEEP);
|
||||||
|
self.y -= 32.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
|
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
|
||||||
@@ -326,13 +404,7 @@ impl Pdf {
|
|||||||
let h = size * 1.4 + 2.0 * pad;
|
let h = size * 1.4 + 2.0 * pad;
|
||||||
p.ensure(h + 2.0 * size * 1.4);
|
p.ensure(h + 2.0 * size * 1.4);
|
||||||
let top = p.y;
|
let top = p.y;
|
||||||
p.rect(
|
p.rect(MARGIN, top - h, Self::content_width(), h, DEEP);
|
||||||
MARGIN,
|
|
||||||
top - h,
|
|
||||||
Self::content_width(),
|
|
||||||
h,
|
|
||||||
(0.906, 0.953, 0.949),
|
|
||||||
);
|
|
||||||
let mut x = MARGIN;
|
let mut x = MARGIN;
|
||||||
for c in cols {
|
for c in cols {
|
||||||
let tx = if c.right {
|
let tx = if c.right {
|
||||||
@@ -346,14 +418,14 @@ impl Pdf {
|
|||||||
c.title,
|
c.title,
|
||||||
size,
|
size,
|
||||||
Font::Bold,
|
Font::Bold,
|
||||||
(0.043, 0.31, 0.29),
|
(1.0, 1.0, 1.0),
|
||||||
);
|
);
|
||||||
x += c.width;
|
x += c.width;
|
||||||
}
|
}
|
||||||
p.y -= h;
|
p.y -= h;
|
||||||
};
|
};
|
||||||
header(self);
|
header(self);
|
||||||
for row in rows {
|
for (ri, row) in rows.iter().enumerate() {
|
||||||
let cells: Vec<Vec<String>> = cols
|
let cells: Vec<Vec<String>> = cols
|
||||||
.iter()
|
.iter()
|
||||||
.zip(row)
|
.zip(row)
|
||||||
@@ -366,6 +438,9 @@ impl Pdf {
|
|||||||
header(self);
|
header(self);
|
||||||
}
|
}
|
||||||
let top = self.y;
|
let top = self.y;
|
||||||
|
if ri % 2 == 1 {
|
||||||
|
self.rect(MARGIN, top - h, Self::content_width(), h, ZEBRA);
|
||||||
|
}
|
||||||
let mut x = MARGIN;
|
let mut x = MARGIN;
|
||||||
for (c, lines) in cols.iter().zip(&cells) {
|
for (c, lines) in cols.iter().zip(&cells) {
|
||||||
for (i, l) in lines.iter().enumerate() {
|
for (i, l) in lines.iter().enumerate() {
|
||||||
@@ -385,10 +460,10 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
x += c.width;
|
x += c.width;
|
||||||
}
|
}
|
||||||
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
|
self.line(MARGIN, W - MARGIN, top - h, HAIR, 0.4);
|
||||||
self.y -= h;
|
self.y -= h;
|
||||||
}
|
}
|
||||||
self.y -= 6.0;
|
self.y -= 8.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalizes and serializes the PDF document. `date` is the UTC creation timestamp (`YYYYMMDDHHMMSS`).
|
/// Finalizes and serializes the PDF document. `date` is the UTC creation timestamp (`YYYYMMDDHHMMSS`).
|
||||||
@@ -397,16 +472,51 @@ impl Pdf {
|
|||||||
self.pages.push(std::mem::take(&mut self.cur));
|
self.pages.push(std::mem::take(&mut self.cur));
|
||||||
}
|
}
|
||||||
let total = self.pages.len();
|
let total = self.pages.len();
|
||||||
for (i, p) in self.pages.iter_mut().enumerate() {
|
for (i, p) in self.pages.iter_mut().enumerate().skip(self.covers) {
|
||||||
let label = format!("{} · página {} de {total}", self.footer, i + 1);
|
let mut extra = String::new();
|
||||||
let x = W - MARGIN - text_width(&label, 8.0, Font::Regular);
|
let mut put = |x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb| {
|
||||||
p.push_str(&format!(
|
extra.push_str(&format!(
|
||||||
"BT /F1 8 Tf {:.3} {:.3} {:.3} rg {x:.2} 24 Td ({}) Tj ET\n",
|
"BT /{} {size:.1} Tf {:.3} {:.3} {:.3} rg {x:.2} {y:.2} Td ({}) Tj ET\n",
|
||||||
GRAY.0,
|
f.res(),
|
||||||
GRAY.1,
|
c.0,
|
||||||
GRAY.2,
|
c.1,
|
||||||
escape(&label)
|
c.2,
|
||||||
|
escape(s)
|
||||||
));
|
));
|
||||||
|
};
|
||||||
|
if let Some((left, tlp)) = &self.header {
|
||||||
|
put(MARGIN + 20.0, H - 34.0, left, 7.0, Font::Mono, GRAY);
|
||||||
|
let tw = text_width(tlp, 7.5, Font::Bold);
|
||||||
|
put(W - MARGIN - tw, H - 34.0, tlp, 7.5, Font::Bold, AMBER);
|
||||||
|
}
|
||||||
|
put(MARGIN, 24.0, &self.footer, 7.5, Font::Mono, GRAY);
|
||||||
|
let label = format!("página {} de {total}", i + 1);
|
||||||
|
let x = W - MARGIN - text_width(&label, 8.0, Font::Bold);
|
||||||
|
put(x, 24.0, &label, 8.0, Font::Bold, DEEP);
|
||||||
|
if self.header.is_some() {
|
||||||
|
if self.image.is_some() {
|
||||||
|
extra.push_str(&format!(
|
||||||
|
"q 15 0 0 15 {MARGIN:.2} {:.2} cm /Im1 Do Q\n",
|
||||||
|
H - 38.5
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extra.push_str(&format!(
|
||||||
|
"{:.3} {:.3} {:.3} RG 0.4 w {MARGIN:.2} {y:.2} m {:.2} {y:.2} l S\n",
|
||||||
|
HAIR.0,
|
||||||
|
HAIR.1,
|
||||||
|
HAIR.2,
|
||||||
|
W - MARGIN,
|
||||||
|
y = H - 44.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extra.push_str(&format!(
|
||||||
|
"{:.3} {:.3} {:.3} RG 0.4 w {MARGIN:.2} 36 m {:.2} 36 l S\n",
|
||||||
|
HAIR.0,
|
||||||
|
HAIR.1,
|
||||||
|
HAIR.2,
|
||||||
|
W - MARGIN
|
||||||
|
));
|
||||||
|
p.push_str(&extra);
|
||||||
}
|
}
|
||||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||||
let mut offsets: Vec<usize> = vec![];
|
let mut offsets: Vec<usize> = vec![];
|
||||||
@@ -447,11 +557,18 @@ impl Pdf {
|
|||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
|
// optional image XObject goes right after the last (page, content) pair
|
||||||
|
let img_obj = 7 + 2 * total;
|
||||||
|
let xobj = if self.image.is_some() {
|
||||||
|
format!(" /XObject << /Im1 {img_obj} 0 R >>")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
for (i, p) in self.pages.iter().enumerate() {
|
for (i, p) in self.pages.iter().enumerate() {
|
||||||
obj(
|
obj(
|
||||||
&mut out,
|
&mut out,
|
||||||
format!(
|
format!(
|
||||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents {} 0 R >>",
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >>{xobj} >> /Contents {} 0 R >>",
|
||||||
8 + 2 * i
|
8 + 2 * i
|
||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
@@ -465,6 +582,16 @@ impl Pdf {
|
|||||||
body.extend_from_slice(b"\nendstream");
|
body.extend_from_slice(b"\nendstream");
|
||||||
obj(&mut out, &body);
|
obj(&mut out, &body);
|
||||||
}
|
}
|
||||||
|
if let Some((jpeg, w, h)) = self.image {
|
||||||
|
let mut body = format!(
|
||||||
|
"<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n",
|
||||||
|
jpeg.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
body.extend_from_slice(jpeg);
|
||||||
|
body.extend_from_slice(b"\nendstream");
|
||||||
|
obj(&mut out, &body);
|
||||||
|
}
|
||||||
let xref = out.len();
|
let xref = out.len();
|
||||||
out.extend_from_slice(
|
out.extend_from_slice(
|
||||||
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
|
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
|
||||||
|
|||||||
@@ -112,24 +112,7 @@ const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz012
|
|||||||
|
|
||||||
/// Encodes the embedded logo as a `data:image/png;base64,...` URI.
|
/// Encodes the embedded logo as a `data:image/png;base64,...` URI.
|
||||||
fn logo_data_uri() -> String {
|
fn logo_data_uri() -> String {
|
||||||
let mut out = String::with_capacity(LOGO_PNG.len().div_ceil(3) * 4 + 22);
|
format!("data:image/png;base64,{}", b64(LOGO_PNG))
|
||||||
out.push_str("data:image/png;base64,");
|
|
||||||
for chunk in LOGO_PNG.chunks(3) {
|
|
||||||
let b0 = chunk[0];
|
|
||||||
let b1 = chunk.get(1).copied();
|
|
||||||
let b2 = chunk.get(2).copied();
|
|
||||||
out.push(B64[(b0 >> 2) as usize] as char);
|
|
||||||
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
|
|
||||||
out.push(match b1 {
|
|
||||||
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
|
|
||||||
None => '=',
|
|
||||||
});
|
|
||||||
out.push(match b2 {
|
|
||||||
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
|
|
||||||
None => '=',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn esc(s: &str) -> String {
|
fn esc(s: &str) -> String {
|
||||||
@@ -156,45 +139,356 @@ fn human(n: u64) -> String {
|
|||||||
|
|
||||||
const MAX_ROWS: usize = 20_000;
|
const MAX_ROWS: usize = 20_000;
|
||||||
|
|
||||||
pub fn html(r: &ReportInput) -> String {
|
/// Paged.js (MIT, v0.4.3), inlined so the HTML report paginates to A4 with
|
||||||
let now = std::time::SystemTime::now()
|
/// running headers and a table of contents while staying a single offline file.
|
||||||
|
const PAGED_JS: &str = include_str!("../assets/paged.polyfill.min.js");
|
||||||
|
/// 96 px logo for the running header of inner pages (the full logo is used on the cover).
|
||||||
|
const LOGO_MARK_PNG: &[u8] = include_bytes!("../assets/logo-mark.png");
|
||||||
|
/// Logo composited on the PDF cover background with the orange glow baked in
|
||||||
|
/// (baseline JPEG, embedded as-is through `/DCTDecode`). Regenerate with PIL if
|
||||||
|
/// the logo or the cover color changes.
|
||||||
|
const LOGO_COVER_JPG: &[u8] = include_bytes!("../assets/logo-cover.jpg");
|
||||||
|
const LOGO_COVER_PX: u32 = 900;
|
||||||
|
|
||||||
|
/// Traffic Light Protocol label printed on every report (Ciberfauna default).
|
||||||
|
pub const TLP: &str = "TLP:AMBER";
|
||||||
|
|
||||||
|
const MAP_COLS: usize = 30;
|
||||||
|
const MAP_ROWS: usize = 20;
|
||||||
|
|
||||||
|
fn b64(data: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let b0 = chunk[0];
|
||||||
|
let b1 = chunk.get(1).copied();
|
||||||
|
let b2 = chunk.get(2).copied();
|
||||||
|
out.push(B64[(b0 >> 2) as usize] as char);
|
||||||
|
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
|
||||||
|
out.push(match b1 {
|
||||||
|
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
|
||||||
|
None => '=',
|
||||||
|
});
|
||||||
|
out.push(match b2 {
|
||||||
|
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
|
||||||
|
None => '=',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregates shared by the HTML and PDF renderers.
|
||||||
|
struct Summary {
|
||||||
|
by_cat: std::collections::BTreeMap<&'static str, usize>,
|
||||||
|
/// Counts by confidence: `[low, medium, high]`.
|
||||||
|
conf: [usize; 3],
|
||||||
|
bytes: u64,
|
||||||
|
recovered: usize,
|
||||||
|
recovered_bytes: u64,
|
||||||
|
/// Disk "sector map": files in disk order, one cell each (or a bucket of
|
||||||
|
/// files), holding the best confidence (0 = empty, 1 low, 2 medium, 3 high).
|
||||||
|
cells: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize(r: &ReportInput) -> Summary {
|
||||||
|
let mut by_cat = std::collections::BTreeMap::new();
|
||||||
|
let mut conf = [0usize; 3];
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
let n = MAP_COLS * MAP_ROWS;
|
||||||
|
let mut cells = vec![0u8; n];
|
||||||
|
// one cell per file in disk order (bucketed when there are more files than
|
||||||
|
// cells), spread over the whole map like a defragmenter view
|
||||||
|
let mut order: Vec<(u64, u64, u8)> = Vec::with_capacity(r.found.len());
|
||||||
|
for f in r.found {
|
||||||
|
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
|
||||||
|
conf[f.confidence as usize] += 1;
|
||||||
|
bytes += f.size;
|
||||||
|
order.push((
|
||||||
|
f.first_offset().unwrap_or(u64::MAX),
|
||||||
|
f.id,
|
||||||
|
f.confidence as u8 + 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
order.sort_unstable();
|
||||||
|
let count = order.len().max(1);
|
||||||
|
for (rank, (_, _, c)) in order.iter().enumerate() {
|
||||||
|
let idx = (rank * n / count).min(n - 1);
|
||||||
|
cells[idx] = cells[idx].max(*c);
|
||||||
|
}
|
||||||
|
Summary {
|
||||||
|
by_cat,
|
||||||
|
conf,
|
||||||
|
bytes,
|
||||||
|
recovered: r.recoveries.iter().map(|x| x.recovered.len()).sum(),
|
||||||
|
recovered_bytes: r.recoveries.iter().map(|x| x.total_bytes).sum(),
|
||||||
|
cells,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0)
|
||||||
let mut o = String::new();
|
}
|
||||||
o.push_str(
|
|
||||||
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
|
|
||||||
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
|
|
||||||
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
|
|
||||||
.brand{display:flex;align-items:center;gap:10px}.brand img{width:30px;height:30px;object-fit:contain}
|
|
||||||
.badges{display:flex;gap:6px;margin-top:8px}.badge{font:700 11px ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;padding:2px 8px;border-radius:3px;border:1px solid #0f766e;color:#0f766e;background:#e7f3f2}
|
|
||||||
.badge-tlp{color:#8a5a00;border-color:#8a5a00;background:#fff0cf}
|
|
||||||
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
|
|
||||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
||||||
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
|
|
||||||
td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;word-break:break-all}
|
|
||||||
.kv td:first-child{width:230px;color:#5b6f6d}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;font-weight:600}
|
|
||||||
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
|
|
||||||
.note{background:#fff7e0;border-left:4px solid #e0a100;padding:10px 14px;border-radius:6px;margin:8px 0}
|
|
||||||
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
|
|
||||||
</style></head><body><main>"#,
|
|
||||||
);
|
|
||||||
o.push_str("<header><div class=\"brand\"><img src=\"");
|
|
||||||
o.push_str(&logo_data_uri());
|
|
||||||
o.push_str("\" alt=\"\"><h1>Relatório de recuperação de arquivos</h1></div><div class=\"badges\"><span class=\"badge badge-tlp\">TLP:AMBER</span><span class=\"badge\">Uso autorizado</span></div><div class=\"mut\">Gerado pelo Guará ");
|
|
||||||
o.push_str(env!("CARGO_PKG_VERSION"));
|
|
||||||
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
|
|
||||||
|
|
||||||
o.push_str("<h2>Identificação do caso</h2><table class=\"kv\">");
|
fn mode_label(o: &ScanOptions) -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
if o.mode == ScanMode::Deep {
|
||||||
|
"profunda (metadados + assinatura)"
|
||||||
|
} else {
|
||||||
|
"rápida (metadados)"
|
||||||
|
},
|
||||||
|
if o.include_allocated {
|
||||||
|
", incluindo áreas em uso"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partition_obs(p: &PartInfo) -> String {
|
||||||
|
[
|
||||||
|
p.lost.then_some("encontrada por varredura".to_string()),
|
||||||
|
p.encrypted
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| format!("cifrada ({e}), não examinada")),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSING: &str = "Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.";
|
||||||
|
|
||||||
|
const CSS: &str = r#"
|
||||||
|
:root{color-scheme:light;--deep:#073633;--teal:#0f766e;--aqua:#2dd4bf;--ink:#10201f;--muted:#5b6f6d;--faint:#8aa09d;--line:#dce8e6;--mist:#f1f7f6;--amber:#b36b00;--orange:#f97316;
|
||||||
|
--body:Inter,system-ui,"Segoe UI",Roboto,sans-serif;--mono:ui-monospace,"JetBrains Mono","IBM Plex Mono",Menlo,Consolas,monospace}
|
||||||
|
*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||||
|
body{margin:0;font:10px/1.5 var(--body);color:var(--ink);background:#fff}
|
||||||
|
@page{size:A4;margin:24mm 16mm 20mm;
|
||||||
|
@top-left{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22'%3E%3Cimage href='data:image/png;base64,{{MARK}}' width='22' height='22'/%3E%3C/svg%3E");vertical-align:bottom;padding-bottom:4mm;width:25%}
|
||||||
|
@top-center{content:"Guará · relatório de recuperação";font:500 8px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint);vertical-align:bottom;padding-bottom:5mm;width:50%;text-align:center;white-space:nowrap}
|
||||||
|
@top-right{content:"\25CF {{TLP}}";width:25%;text-align:right;white-space:nowrap;font:700 7.5px var(--mono);letter-spacing:.12em;color:var(--amber);vertical-align:bottom;padding-bottom:5mm}
|
||||||
|
@bottom-left{content:"{{FOOT}}";font:400 7.5px var(--mono);color:var(--faint);vertical-align:top;padding-top:5mm;width:75%}
|
||||||
|
@bottom-right{content:counter(page) " / " counter(pages);font:600 8px var(--mono);color:var(--ink);vertical-align:top;padding-top:5mm}
|
||||||
|
}
|
||||||
|
@page cover{margin:0;@top-left{content:none}@top-center{content:none}@top-right{content:none}@bottom-left{content:none}@bottom-right{content:none}}
|
||||||
|
.cover{page:cover;break-after:page;width:210mm;height:297mm;position:relative;overflow:hidden;color:#fff;
|
||||||
|
background:radial-gradient(120mm 120mm at 150mm 110mm,rgba(249,115,22,.16),transparent 70%),linear-gradient(165deg,#0b2b28 0%,#061a18 55%,#03100f 100%)}
|
||||||
|
.cover .map{position:absolute;left:0;bottom:0;width:210mm;height:140mm;opacity:.9}
|
||||||
|
.cover .map svg{display:block;width:100%;height:100%}
|
||||||
|
.cover .glow{position:absolute;right:-40mm;top:30mm;width:180mm;height:180mm;border-radius:50%;background:radial-gradient(circle,rgba(251,146,60,.42) 0%,rgba(249,115,22,.16) 38%,transparent 68%)}
|
||||||
|
.cover .hero{position:absolute;right:-24mm;top:42mm;width:150mm;height:150mm;object-fit:contain;filter:drop-shadow(0 6mm 14mm rgba(0,0,0,.45))}
|
||||||
|
.cover .top{position:relative;padding:16mm 18mm 0;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.cover .word{font:700 11px var(--mono);letter-spacing:.42em;color:#fff}
|
||||||
|
.cover .chips{display:flex;gap:6px}
|
||||||
|
.chip{font:700 8.5px var(--mono);letter-spacing:.16em;padding:3px 8px;border-radius:2px;border:1px solid currentColor;text-transform:uppercase}
|
||||||
|
.cover .tlp{color:#ffc000}.cover .auth{color:#9fd8d0}
|
||||||
|
.cover .eyebrow{position:relative;font:500 8.5px var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--aqua);margin:44mm 18mm 0;max-width:100mm}
|
||||||
|
.cover h1{position:relative;font:800 44px/1.03 var(--body);letter-spacing:-.025em;margin:5mm 18mm 0;max-width:98mm;color:#fff;text-shadow:0 2px 12px rgba(0,0,0,.4)}
|
||||||
|
.cover .sub{position:relative;font:400 12.5px/1.55 var(--body);color:#c3dcd8;margin:6mm 18mm 0;max-width:88mm}
|
||||||
|
.cover .strip{position:absolute;left:18mm;right:18mm;bottom:52mm;display:grid;grid-template-columns:repeat(4,1fr);gap:4mm}
|
||||||
|
.cover .strip div{border-top:2px solid var(--aqua);padding-top:2.5mm;background:linear-gradient(rgba(3,16,15,.72),rgba(3,16,15,.35));padding:2.5mm 3mm 3mm}
|
||||||
|
.cover .strip div.o{border-top-color:var(--orange)}
|
||||||
|
.cover .strip b{display:block;font:800 24px/1.1 var(--body);letter-spacing:-.02em;color:#fff}
|
||||||
|
.cover .strip span{font:500 7px/1.3 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:#9fc4bf}
|
||||||
|
.cover .cap{position:absolute;left:18mm;bottom:40mm;font:400 7px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:#7fa9a3;margin:0}
|
||||||
|
.cover .cap i{display:inline-block;width:7px;height:7px;margin:0 4px 0 10px;vertical-align:-1px;border-radius:1px}
|
||||||
|
.cover .meta{position:absolute;left:18mm;right:18mm;bottom:13mm;display:grid;grid-template-columns:1.1fr 1.6fr 1.1fr .9fr;gap:6mm;padding-top:4mm;border-top:1px solid rgba(159,216,208,.35);margin:0}
|
||||||
|
.cover .meta dt{font:500 7px var(--mono);letter-spacing:.16em;text-transform:uppercase;color:#7fa9a3;margin-bottom:2px}
|
||||||
|
.cover .meta dd{margin:0;font:500 9.5px/1.45 var(--body);color:#fff;word-break:break-all}
|
||||||
|
.toc{break-after:page}
|
||||||
|
.toc ol{list-style:none;margin:6mm 0 0;padding:0}
|
||||||
|
.toc a{display:flex;align-items:baseline;gap:8px;color:var(--ink);text-decoration:none;padding:7px 0;border-bottom:1px solid var(--line);font:500 11.5px var(--body)}
|
||||||
|
.toc a .k{font:600 9px var(--mono);color:var(--teal);width:22px}
|
||||||
|
.toc a::after{content:target-counter(attr(href),page);font:600 10px var(--mono);color:var(--teal);margin-left:auto}
|
||||||
|
section{margin:0 0 9mm}
|
||||||
|
h2{font:700 19px/1.15 var(--body);letter-spacing:-.01em;color:var(--deep);margin:0 0 3.5mm;break-after:avoid}
|
||||||
|
h2::before{content:"";display:block;width:26px;height:4px;background:var(--teal);margin-bottom:3mm;border-radius:2px}
|
||||||
|
h3{font:600 11px var(--body);color:var(--deep);margin:4mm 0 2mm;break-after:avoid}
|
||||||
|
p{margin:0 0 2.5mm}.mut{color:var(--muted)}
|
||||||
|
.lead{font-size:11px;color:var(--muted);max-width:150mm;margin-bottom:4mm}
|
||||||
|
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:3.5mm;margin:0 0 5mm}
|
||||||
|
.kpi{border-top:3px solid var(--teal);background:var(--mist);padding:3mm 3.5mm 3.5mm;break-inside:avoid}
|
||||||
|
.kpi .l{font:500 7px/1.3 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
||||||
|
.kpi .v{font:800 24px/1.1 var(--body);letter-spacing:-.02em;margin:1.5mm 0 1mm;color:var(--deep)}
|
||||||
|
.kpi .d{font-size:8.5px;color:var(--muted)}
|
||||||
|
.kpi.hi{border-top-color:#16a34a}.kpi.or{border-top-color:var(--orange)}
|
||||||
|
.bar{display:flex;height:9px;border-radius:2px;overflow:hidden;background:var(--line);margin:1mm 0 2mm}
|
||||||
|
.bar i{display:block;height:100%}
|
||||||
|
.legend{display:flex;gap:12px;font:500 8px var(--mono);color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
|
||||||
|
.legend i{display:inline-block;width:8px;height:8px;border-radius:1px;margin-right:4px;vertical-align:-1px}
|
||||||
|
.c-alta{background:#16a34a}.c-media{background:#d97706}.c-baixa{background:#dc2626}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:0 0 3mm}
|
||||||
|
th,td{padding:4.5px 7px;text-align:left;vertical-align:top;font-size:8.8px;border-bottom:1px solid var(--line)}
|
||||||
|
thead th{background:var(--deep);color:#fff;font:600 7.5px var(--mono);letter-spacing:.08em;text-transform:uppercase}
|
||||||
|
tbody tr:nth-child(even) td{background:var(--mist)}
|
||||||
|
tr{break-inside:avoid}
|
||||||
|
td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||||
|
.mono{font-family:var(--mono);font-size:8px;word-break:break-all}
|
||||||
|
.kv td:first-child{width:46mm;color:var(--muted);font:500 7.5px var(--mono);letter-spacing:.1em;text-transform:uppercase;padding-top:6px}
|
||||||
|
.kv td{font-size:9.5px}
|
||||||
|
.hashes div{white-space:nowrap}.hashes b{display:inline-block;width:46px;font:600 7px var(--mono);color:var(--muted);letter-spacing:.06em}
|
||||||
|
.pill{display:inline-block;padding:0 7px;border-radius:99px;font-size:8px;font-weight:700;white-space:nowrap}
|
||||||
|
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
|
||||||
|
.note{border-left:3px solid #d97706;background:#fff8e8;padding:3mm 4mm;margin:0 0 3mm;break-inside:avoid}
|
||||||
|
.note b{display:block;font:600 7px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:#8a5a00;margin-bottom:1mm}
|
||||||
|
.note.info{border-left-color:var(--teal);background:var(--mist)}.note.info b{color:var(--teal)}
|
||||||
|
.note.bad{border-left-color:#dc2626;background:#fdecec}.note.bad b{color:#a32121}
|
||||||
|
@media screen{body{background:#4a5b59}.pagedjs_pages{padding:8mm 0}.pagedjs_page{background:#fff;margin:0 auto 8mm;box-shadow:0 4px 24px rgba(0,0,0,.35)}}
|
||||||
|
@media print{body{background:#fff}}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn html(r: &ReportInput) -> String {
|
||||||
|
let now = now_unix();
|
||||||
|
let s = summarize(r);
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let nf = |v: &str| {
|
||||||
|
if v.is_empty() {
|
||||||
|
"não informado".to_string()
|
||||||
|
} else {
|
||||||
|
esc(v)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let case_label = if r.case.case_id.is_empty() {
|
||||||
|
"sem número".to_string()
|
||||||
|
} else {
|
||||||
|
r.case.case_id.clone()
|
||||||
|
};
|
||||||
|
let foot = format!(
|
||||||
|
"Guará {version} · caso {} · gerado em {}",
|
||||||
|
case_label,
|
||||||
|
fmt_unix(now)
|
||||||
|
)
|
||||||
|
.replace(['"', '\\', '\n', '\r'], " ");
|
||||||
|
let mut o = String::with_capacity(PAGED_JS.len() + LOGO_PNG.len() * 2 + 64 * 1024);
|
||||||
|
o.push_str("<!doctype html><html lang=\"pt-BR\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Guará: relatório de recuperação</title><style>");
|
||||||
|
o.push_str(
|
||||||
|
&CSS.replace("{{MARK}}", &b64(LOGO_MARK_PNG))
|
||||||
|
.replace("{{TLP}}", TLP)
|
||||||
|
.replace("{{FOOT}}", &foot),
|
||||||
|
);
|
||||||
|
o.push_str("</style></head><body>");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- cover
|
||||||
|
let total = r.found.len();
|
||||||
|
o.push_str("<section class=\"cover\"><div class=\"map\">");
|
||||||
|
o.push_str(§or_map_svg(&s.cells));
|
||||||
|
o.push_str("</div><div class=\"glow\"></div><img class=\"hero\" alt=\"Guará\" src=\"");
|
||||||
|
o.push_str(&logo_data_uri());
|
||||||
|
o.push_str("\"><div class=\"top\"><div class=\"word\">GUARÁ</div><div class=\"chips\"><span class=\"chip auth\">Uso autorizado</span><span class=\"chip tlp\">");
|
||||||
|
o.push_str(TLP);
|
||||||
|
o.push_str("</span></div></div><div class=\"eyebrow\">Ciberfauna · Recuperação forense de arquivos</div><h1>Relatório de recuperação de arquivos</h1>");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<p class=\"sub\">Exame somente leitura de <b>{}</b>: o que foi encontrado, o que foi recuperado e os hashes que provam a integridade de cada arquivo.</p>",
|
||||||
|
esc(short_target(r.target))
|
||||||
|
));
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<div class=\"strip\"><div><b>{total}</b><span>arquivos encontrados</span></div><div><b>{}</b><span>confiança alta</span></div><div class=\"o\"><b>{}</b><span>recuperados com hash</span></div><div class=\"o\"><b>{}</b><span>volume encontrado</span></div></div>",
|
||||||
|
s.conf[2],
|
||||||
|
s.recovered,
|
||||||
|
human(s.bytes)
|
||||||
|
));
|
||||||
|
o.push_str("<p class=\"cap\">Mapa da origem: arquivos em ordem de posição no disco<i style=\"background:#2dd4bf\"></i>alta<i style=\"background:#f59e0b\"></i>média<i style=\"background:#f87171\"></i>baixa</p>");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<dl class=\"meta\"><div><dt>Emissão</dt><dd>{}</dd></div><div><dt>Origem</dt><dd>{}</dd></div><div><dt>Caso · Perito</dt><dd>{} · {}</dd></div><div><dt>Classificação</dt><dd>{TLP}</dd></div></dl></section>",
|
||||||
|
fmt_unix(now),
|
||||||
|
esc(short_target(r.target)),
|
||||||
|
esc(&case_label),
|
||||||
|
nf(&r.case.examiner)
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- TOC
|
||||||
|
let mut toc: Vec<(&str, &str)> = vec![
|
||||||
|
("resumo", "Resumo dos achados"),
|
||||||
|
("caso", "Identificação do caso"),
|
||||||
|
("origem", "Origem examinada"),
|
||||||
|
];
|
||||||
|
if r.audit.is_some() {
|
||||||
|
toc.push(("auditoria", "Trilha de auditoria"));
|
||||||
|
}
|
||||||
|
if r.layout.is_some() {
|
||||||
|
toc.push(("particoes", "Partições"));
|
||||||
|
}
|
||||||
|
if !r.recoveries.is_empty() {
|
||||||
|
toc.push(("recuperados", "Arquivos recuperados (integridade)"));
|
||||||
|
}
|
||||||
|
toc.push(("relacao", "Relação de arquivos encontrados"));
|
||||||
|
o.push_str("<section class=\"toc\"><h2>Sumário</h2><ol>");
|
||||||
|
for (i, (id, t)) in toc.iter().enumerate() {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<li><a href=\"#{id}\"><span class=\"k\">{:02}</span>{t}</a></li>",
|
||||||
|
i + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str(&format!(
|
||||||
|
"</ol><p class=\"mut\" style=\"margin-top:8mm\">Gerado pelo Guará {version} em {}. {CLOSING}</p></section>",
|
||||||
|
fmt_unix(now)
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- summary
|
||||||
|
let pct = |n: usize| {
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
n as f64 * 100.0 / total as f64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
o.push_str("<section id=\"resumo\"><h2>Resumo dos achados</h2>");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<p class=\"lead\">Arquivos encontrados: <b>{total}</b> ({}). A confiança indica a chance de o conteúdo estar íntegro.</p>",
|
||||||
|
human(s.bytes)
|
||||||
|
));
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<div class=\"kpis\"><div class=\"kpi\"><div class=\"l\">Encontrados</div><div class=\"v\">{total}</div><div class=\"d\">{}</div></div><div class=\"kpi hi\"><div class=\"l\">Confiança alta</div><div class=\"v\">{}</div><div class=\"d\">{:.0}% do total</div></div><div class=\"kpi or\"><div class=\"l\">Recuperados</div><div class=\"v\">{}</div><div class=\"d\">{} com hash</div></div><div class=\"kpi\"><div class=\"l\">Duração</div><div class=\"v\">{:.1} s</div><div class=\"d\">varredura {}</div></div></div>",
|
||||||
|
human(s.bytes),
|
||||||
|
s.conf[2],
|
||||||
|
pct(s.conf[2]),
|
||||||
|
s.recovered,
|
||||||
|
human(s.recovered_bytes),
|
||||||
|
r.elapsed,
|
||||||
|
if r.options.mode == ScanMode::Deep { "profunda" } else { "rápida" }
|
||||||
|
));
|
||||||
|
o.push_str("<h3>Confiança</h3><div class=\"bar\">");
|
||||||
|
for (n, c) in [
|
||||||
|
(s.conf[2], "c-alta"),
|
||||||
|
(s.conf[1], "c-media"),
|
||||||
|
(s.conf[0], "c-baixa"),
|
||||||
|
] {
|
||||||
|
if n > 0 {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<i class=\"{c}\" style=\"width:{:.2}%\"></i>",
|
||||||
|
pct(n)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.push_str(&format!(
|
||||||
|
"</div><div class=\"legend\"><span><i class=\"c-alta\"></i>alta {}</span><span><i class=\"c-media\"></i>média {}</span><span><i class=\"c-baixa\"></i>baixa {}</span></div>",
|
||||||
|
s.conf[2], s.conf[1], s.conf[0]
|
||||||
|
));
|
||||||
|
o.push_str("<h3>Por tipo</h3><table><thead><tr><th>Tipo</th><th style=\"text-align:right\">Arquivos</th><th style=\"text-align:right\">Proporção</th></tr></thead><tbody>");
|
||||||
|
for (k, v) in &s.by_cat {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>{k}</td><td class=\"n\">{v}</td><td class=\"n\">{:.1}%</td></tr>",
|
||||||
|
pct(*v)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</tbody></table>");
|
||||||
|
for w in r.warnings {
|
||||||
|
o.push_str(&format!("<div class=\"note\"><b>Aviso</b>{}</div>", esc(w)));
|
||||||
|
}
|
||||||
|
o.push_str("</section>");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- case
|
||||||
|
o.push_str("<section id=\"caso\"><h2>Identificação do caso</h2><table class=\"kv\"><tbody>");
|
||||||
for (k, v) in [
|
for (k, v) in [
|
||||||
("Número do caso", &r.case.case_id),
|
("Número do caso", &r.case.case_id),
|
||||||
("Responsável pelo exame", &r.case.examiner),
|
("Responsável pelo exame", &r.case.examiner),
|
||||||
("Organização", &r.case.organization),
|
("Organização", &r.case.organization),
|
||||||
] {
|
] {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!("<tr><td>{k}</td><td>{}</td></tr>", nf(v)));
|
||||||
"<tr><td>{k}</td><td>{}</td></tr>",
|
|
||||||
esc(if v.is_empty() { "não informado" } else { v })
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if !r.case.notes.is_empty() {
|
if !r.case.notes.is_empty() {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
@@ -202,9 +496,12 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
esc(&r.case.notes).replace('\n', "<br>")
|
esc(&r.case.notes).replace('\n', "<br>")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str("</table>");
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Classificação</td><td><span class=\"pill media\">{TLP}</span> uso autorizado; compartilhe só com quem precisa conhecer o caso</td></tr></tbody></table></section>"
|
||||||
|
));
|
||||||
|
|
||||||
o.push_str("<h2>Origem examinada</h2><table class=\"kv\">");
|
// ---------------------------------------------------------------- source
|
||||||
|
o.push_str("<section id=\"origem\"><h2>Origem examinada</h2><table class=\"kv\"><tbody>");
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
|
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
|
||||||
esc(r.target)
|
esc(r.target)
|
||||||
@@ -214,7 +511,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
}
|
}
|
||||||
if let Some(h) = r.source_hashes {
|
if let Some(h) = r.source_hashes {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>Hash da origem</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
"<tr><td>Hash da origem</td><td class=\"mono hashes\"><div><b>MD5</b>{}</div><div><b>SHA-1</b>{}</div><div><b>SHA-256</b>{}</div></td></tr>",
|
||||||
esc(&h.md5),
|
esc(&h.md5),
|
||||||
esc(&h.sha1),
|
esc(&h.sha1),
|
||||||
esc(&h.sha256)
|
esc(&h.sha256)
|
||||||
@@ -229,34 +526,26 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}{}</td></tr><tr><td>Garantia de leitura</td><td>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</td></tr></table>",
|
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}</td></tr></tbody></table><div class=\"note info\"><b>Garantia de leitura</b>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</div></section>",
|
||||||
r.elapsed,
|
r.elapsed,
|
||||||
if r.options.mode == ScanMode::Deep { "profunda (metadados + assinatura)" } else { "rápida (metadados)" },
|
mode_label(r.options)
|
||||||
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- audit
|
||||||
if let Some(a) = &r.audit {
|
if let Some(a) = &r.audit {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<h2>Trilha de auditoria</h2><table class=\"kv\"><tr><td>Entradas</td><td>{}</td></tr><tr><td>Encadeamento</td><td>{}</td></tr><tr><td>Hash da última entrada</td><td class=\"mono\">{}</td></tr></table>",
|
"<section id=\"auditoria\"><h2>Trilha de auditoria</h2><table class=\"kv\"><tbody><tr><td>Entradas</td><td>{}</td></tr><tr><td>Encadeamento</td><td>{}</td></tr><tr><td>Hash da última entrada</td><td class=\"mono\">{}</td></tr></tbody></table>",
|
||||||
a.entries,
|
a.entries,
|
||||||
if a.ok { "íntegro" } else { "QUEBRADO: confira com guara audit --verify" },
|
if a.ok { "<span class=\"pill alta\">íntegro</span>" } else { "<span class=\"pill baixa\">QUEBRADO: confira com guara audit --verify</span>" },
|
||||||
esc(&a.head)
|
esc(&a.head)
|
||||||
));
|
));
|
||||||
|
o.push_str("<div class=\"note info\"><b>Cadeia de custódia</b>Arquive o hash da última entrada fora desta máquina: ele permite provar depois que a trilha não foi alterada.</div></section>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- partitions
|
||||||
if let Some(l) = r.layout {
|
if let Some(l) = r.layout {
|
||||||
o.push_str("<h2>Partições</h2><table><tr><th>#</th><th>Sistema</th><th>Início (bytes)</th><th>Tamanho</th><th>Rótulo</th><th>Observação</th></tr>");
|
o.push_str("<section id=\"particoes\"><h2>Partições</h2><table><thead><tr><th>#</th><th>Sistema</th><th style=\"text-align:right\">Início (bytes)</th><th style=\"text-align:right\">Tamanho</th><th>Rótulo</th><th>Observação</th></tr></thead><tbody>");
|
||||||
for p in &l.partitions {
|
for p in &l.partitions {
|
||||||
let obs = [
|
|
||||||
p.lost.then_some("encontrada por varredura".to_string()),
|
|
||||||
p.encrypted
|
|
||||||
.as_ref()
|
|
||||||
.map(|e| format!("cifrada ({e}), não examinada")),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("; ");
|
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
|
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
|
||||||
p.index + 1,
|
p.index + 1,
|
||||||
@@ -264,43 +553,18 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
p.start,
|
p.start,
|
||||||
human(p.len),
|
human(p.len),
|
||||||
esc(p.label.as_deref().unwrap_or("")),
|
esc(p.label.as_deref().unwrap_or("")),
|
||||||
esc(&obs)
|
esc(&partition_obs(p))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for g in &l.gaps {
|
for g in &l.gaps {
|
||||||
o.push_str(&format!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
|
o.push_str(&format!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
|
||||||
}
|
}
|
||||||
o.push_str("</table>");
|
o.push_str("</tbody></table></section>");
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
let mut by_cat = std::collections::BTreeMap::new();
|
|
||||||
let mut conf = [0usize; 3];
|
|
||||||
let mut bytes = 0u64;
|
|
||||||
for f in r.found {
|
|
||||||
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
|
|
||||||
conf[f.confidence as usize] += 1;
|
|
||||||
bytes += f.size;
|
|
||||||
}
|
|
||||||
o.push_str(&format!("<h2>Resumo dos achados</h2><table class=\"kv\"><tr><td>Arquivos encontrados</td><td><b>{}</b> ({})</td></tr>", r.found.len(), human(bytes)));
|
|
||||||
o.push_str(&format!(
|
|
||||||
"<tr><td>Confiança</td><td><span class=\"pill alta\">alta {}</span> <span class=\"pill media\">média {}</span> <span class=\"pill baixa\">baixa {}</span></td></tr>",
|
|
||||||
conf[2], conf[1], conf[0]
|
|
||||||
));
|
|
||||||
o.push_str(&format!(
|
|
||||||
"<tr><td>Por tipo</td><td>{}</td></tr></table>",
|
|
||||||
by_cat
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k}: {v}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" · ")
|
|
||||||
));
|
|
||||||
for w in r.warnings {
|
|
||||||
o.push_str(&format!("<div class=\"note\">{}</div>", esc(w)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- recoveries
|
||||||
if !r.recoveries.is_empty() {
|
if !r.recoveries.is_empty() {
|
||||||
o.push_str("<h2>Arquivos recuperados (integridade)</h2>");
|
o.push_str("<section id=\"recuperados\"><h2>Arquivos recuperados (integridade)</h2>");
|
||||||
for rec in r.recoveries {
|
for rec in r.recoveries {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
|
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
|
||||||
@@ -313,10 +577,10 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
format!(", {} falha(s)", rec.failed.len())
|
format!(", {} falha(s)", rec.failed.len())
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
o.push_str("<table><tr><th>Arquivo</th><th>Tamanho</th><th>Hashes</th></tr>");
|
o.push_str("<table><thead><tr><th>Arquivo</th><th style=\"text-align:right\">Tamanho</th><th>Hashes</th></tr></thead><tbody>");
|
||||||
for f in rec.recovered.iter().take(MAX_ROWS) {
|
for f in rec.recovered.iter().take(MAX_ROWS) {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono hashes\"><div><b>MD5</b>{}</div><div><b>SHA-1</b>{}</div><div><b>SHA-256</b>{}</div></td></tr>",
|
||||||
esc(&f.path),
|
esc(&f.path),
|
||||||
human(f.size),
|
human(f.size),
|
||||||
f.md5,
|
f.md5,
|
||||||
@@ -324,15 +588,17 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
f.sha256
|
f.sha256
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str("</table>");
|
o.push_str("</tbody></table>");
|
||||||
}
|
}
|
||||||
|
o.push_str("</section>");
|
||||||
}
|
}
|
||||||
|
|
||||||
o.push_str("<h2>Relação de arquivos encontrados</h2>");
|
// ---------------------------------------------------------------- findings
|
||||||
|
o.push_str("<section id=\"relacao\"><h2>Relação de arquivos encontrados</h2>");
|
||||||
if r.found.len() > MAX_ROWS {
|
if r.found.len() > MAX_ROWS {
|
||||||
o.push_str(&format!("<div class=\"note\">Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
|
o.push_str(&format!("<div class=\"note\"><b>Lista truncada</b>Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
|
||||||
}
|
}
|
||||||
o.push_str("<table><tr><th>Nome</th><th>Pasta</th><th>Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th>Posição no disco</th></tr>");
|
o.push_str("<table><thead><tr><th>Nome</th><th>Pasta</th><th style=\"text-align:right\">Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th style=\"text-align:right\">Posição no disco</th></tr></thead><tbody>");
|
||||||
for f in r.found.iter().take(MAX_ROWS) {
|
for f in r.found.iter().take(MAX_ROWS) {
|
||||||
let (cl, cc) = match f.confidence {
|
let (cl, cc) = match f.confidence {
|
||||||
Confidence::High => ("alta", "alta"),
|
Confidence::High => ("alta", "alta"),
|
||||||
@@ -350,27 +616,134 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
f.first_offset().map(|o| o.to_string()).unwrap_or_default()
|
f.first_offset().map(|o| o.to_string()).unwrap_or_default()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
|
o.push_str(&format!(
|
||||||
|
"</tbody></table><div class=\"note info\"><b>Leitura</b>{CLOSING}</div></section>"
|
||||||
|
));
|
||||||
|
o.push_str("<script>");
|
||||||
|
o.push_str(PAGED_JS);
|
||||||
|
o.push_str("</script></body></html>");
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last path component of the target (for the cover subtitle).
|
||||||
|
fn short_target(t: &str) -> &str {
|
||||||
|
t.rsplit(['/', '\\']).find(|p| !p.is_empty()).unwrap_or(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover motif: a defrag-style map where each lit cell is a found file (in disk
|
||||||
|
/// order), colored by confidence. It shows as a band between the subtitle and the
|
||||||
|
/// headline strip and fades out before the numbers and the meta grid.
|
||||||
|
fn sector_map_svg(cells: &[u8]) -> String {
|
||||||
|
let mut o = format!(
|
||||||
|
"<svg viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"fade\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\".3\" stop-color=\"#fff\" stop-opacity=\"1\"/><stop offset=\".42\" stop-color=\"#fff\" stop-opacity=\"1\"/><stop offset=\".55\" stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\" stop-opacity=\"0\"/></linearGradient><mask id=\"m\"><rect width=\"100%\" height=\"100%\" fill=\"url(#fade)\"/></mask></defs><g mask=\"url(#m)\">",
|
||||||
|
MAP_COLS * 7,
|
||||||
|
MAP_ROWS * 7
|
||||||
|
);
|
||||||
|
for (i, c) in cells.iter().enumerate() {
|
||||||
|
// the map shows as a band above the headline strip and fades out before the
|
||||||
|
// numbers and the meta grid, so the white text stays legible
|
||||||
|
let (x, y) = ((i % MAP_COLS) * 7, (MAP_ROWS - 1 - i / MAP_COLS) * 7);
|
||||||
|
let fill = match c {
|
||||||
|
3 => "#2dd4bf\" fill-opacity=\".9",
|
||||||
|
2 => "#f59e0b\" fill-opacity=\".9",
|
||||||
|
1 => "#f87171\" fill-opacity=\".9",
|
||||||
|
_ => "#2dd4bf\" fill-opacity=\".07",
|
||||||
|
};
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<rect x=\"{x}.5\" y=\"{y}.5\" width=\"6\" height=\"6\" rx=\".6\" fill=\"{fill}\"/>"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</g></svg>");
|
||||||
o
|
o
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an A4 PDF recovery report containing the same sections as the HTML report.
|
/// Generates an A4 PDF recovery report containing the same sections as the HTML report.
|
||||||
pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
||||||
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
|
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY, TEAL};
|
||||||
let now = std::time::SystemTime::now()
|
let now = now_unix();
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs() as i64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let stamp = crate::audit::utc_iso(now);
|
let stamp = crate::audit::utc_iso(now);
|
||||||
let version = env!("CARGO_PKG_VERSION");
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let s = summarize(r);
|
||||||
|
let case_label = if r.case.case_id.is_empty() {
|
||||||
|
"sem número".to_string()
|
||||||
|
} else {
|
||||||
|
r.case.case_id.clone()
|
||||||
|
};
|
||||||
let mut d = Pdf::new(
|
let mut d = Pdf::new(
|
||||||
"Guará: relatório de recuperação",
|
"Guará: relatório de recuperação",
|
||||||
&format!("Guará {version}"),
|
&format!(
|
||||||
);
|
"Guará {version} · caso {case_label} · gerado em {}",
|
||||||
d.title(
|
fmt_unix(now)
|
||||||
"Relatório de recuperação de arquivos",
|
),
|
||||||
&format!("Gerado pelo Guará {version} em {}", fmt_unix(now)),
|
|
||||||
);
|
);
|
||||||
|
d.set_image(LOGO_COVER_JPG, LOGO_COVER_PX, LOGO_COVER_PX);
|
||||||
|
pdf_cover(&mut d, r, &s, &case_label, now);
|
||||||
|
d.running_header("GUARÁ · RELATÓRIO DE RECUPERAÇÃO", &format!("• {TLP}"));
|
||||||
|
|
||||||
|
let total = r.found.len();
|
||||||
|
let pct = |n: usize| {
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
n as f64 * 100.0 / total as f64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d.heading("Resumo dos achados");
|
||||||
|
d.kpis(&[
|
||||||
|
("Encontrados", total.to_string(), human(s.bytes), TEAL),
|
||||||
|
(
|
||||||
|
"Confiança alta",
|
||||||
|
s.conf[2].to_string(),
|
||||||
|
format!("{:.0}% do total", pct(s.conf[2])),
|
||||||
|
(0.086, 0.639, 0.290),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Recuperados",
|
||||||
|
s.recovered.to_string(),
|
||||||
|
format!("{} com hash", human(s.recovered_bytes)),
|
||||||
|
(0.976, 0.451, 0.086),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Duração",
|
||||||
|
format!("{:.1} s", r.elapsed),
|
||||||
|
format!(
|
||||||
|
"varredura {}",
|
||||||
|
if r.options.mode == ScanMode::Deep {
|
||||||
|
"profunda"
|
||||||
|
} else {
|
||||||
|
"rápida"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
TEAL,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
d.kv(&[
|
||||||
|
(
|
||||||
|
"Arquivos encontrados",
|
||||||
|
&format!("{} ({})", total, human(s.bytes)),
|
||||||
|
Font::Bold,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Confiança",
|
||||||
|
&format!(
|
||||||
|
"alta {} · média {} · baixa {}",
|
||||||
|
s.conf[2], s.conf[1], s.conf[0]
|
||||||
|
),
|
||||||
|
Font::Regular,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Por tipo",
|
||||||
|
&s.by_cat
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}: {v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · "),
|
||||||
|
Font::Regular,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for w in r.warnings {
|
||||||
|
d.note(w);
|
||||||
|
}
|
||||||
|
|
||||||
let nf = |v: &str| {
|
let nf = |v: &str| {
|
||||||
if v.is_empty() {
|
if v.is_empty() {
|
||||||
@@ -392,6 +765,11 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
if !r.case.notes.is_empty() {
|
if !r.case.notes.is_empty() {
|
||||||
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
|
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
|
||||||
}
|
}
|
||||||
|
rows.push((
|
||||||
|
"Classificação",
|
||||||
|
format!("{TLP} (uso autorizado)"),
|
||||||
|
Font::Bold,
|
||||||
|
));
|
||||||
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
|
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
|
||||||
d.kv(&rows);
|
d.kv(&rows);
|
||||||
|
|
||||||
@@ -426,23 +804,7 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
format!("{:.1} s", r.elapsed),
|
format!("{:.1} s", r.elapsed),
|
||||||
Font::Regular,
|
Font::Regular,
|
||||||
));
|
));
|
||||||
rows.push((
|
rows.push(("Modo", mode_label(r.options), Font::Regular));
|
||||||
"Modo",
|
|
||||||
format!(
|
|
||||||
"{}{}",
|
|
||||||
if r.options.mode == ScanMode::Deep {
|
|
||||||
"profunda (metadados + assinatura)"
|
|
||||||
} else {
|
|
||||||
"rápida (metadados)"
|
|
||||||
},
|
|
||||||
if r.options.include_allocated {
|
|
||||||
", incluindo áreas em uso"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Font::Regular,
|
|
||||||
));
|
|
||||||
rows.push((
|
rows.push((
|
||||||
"Garantia de leitura",
|
"Garantia de leitura",
|
||||||
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
|
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
|
||||||
@@ -542,43 +904,6 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
d.table(&cols, &rows);
|
d.table(&cols, &rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut by_cat = std::collections::BTreeMap::new();
|
|
||||||
let mut conf = [0usize; 3];
|
|
||||||
let mut bytes = 0u64;
|
|
||||||
for f in r.found {
|
|
||||||
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
|
|
||||||
conf[f.confidence as usize] += 1;
|
|
||||||
bytes += f.size;
|
|
||||||
}
|
|
||||||
d.heading("Resumo dos achados");
|
|
||||||
d.kv(&[
|
|
||||||
(
|
|
||||||
"Arquivos encontrados",
|
|
||||||
&format!("{} ({})", r.found.len(), human(bytes)),
|
|
||||||
Font::Bold,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Confiança",
|
|
||||||
&format!(
|
|
||||||
"alta {} · média {} · baixa {}",
|
|
||||||
conf[2], conf[1], conf[0]
|
|
||||||
),
|
|
||||||
Font::Regular,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Por tipo",
|
|
||||||
&by_cat
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k}: {v}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" · "),
|
|
||||||
Font::Regular,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
for w in r.warnings {
|
|
||||||
d.note(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !r.recoveries.is_empty() {
|
if !r.recoveries.is_empty() {
|
||||||
d.heading("Arquivos recuperados (integridade)");
|
d.heading("Arquivos recuperados (integridade)");
|
||||||
for rec in r.recoveries {
|
for rec in r.recoveries {
|
||||||
@@ -703,11 +1028,169 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
d.table(&cols, &rows);
|
d.table(&cols, &rows);
|
||||||
d.paragraph(
|
d.paragraph(CLOSING, 8.0, Font::Regular, GRAY);
|
||||||
"Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.",
|
|
||||||
8.0,
|
|
||||||
Font::Regular,
|
|
||||||
GRAY,
|
|
||||||
);
|
|
||||||
d.finish(&stamp.replace(['-', ':', 'T', 'Z'], ""))
|
d.finish(&stamp.replace(['-', ':', 'T', 'Z'], ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full-bleed PDF cover: deep teal page, glowing logo bleeding off the right
|
||||||
|
/// edge, sector-map motif, headline strip and meta grid (mirrors the HTML cover).
|
||||||
|
fn pdf_cover(d: &mut crate::pdf::Pdf, r: &ReportInput, s: &Summary, case: &str, now: i64) {
|
||||||
|
use crate::pdf::{Font, Pdf};
|
||||||
|
let (w, h) = Pdf::SIZE;
|
||||||
|
let mm = 72.0 / 25.4;
|
||||||
|
// background must equal the JPEG's flat background so the logo blends in
|
||||||
|
let bg = (7.0 / 255.0, 26.0 / 255.0, 25.0 / 255.0);
|
||||||
|
let mix = |c: (f64, f64, f64), a: f64| {
|
||||||
|
(
|
||||||
|
bg.0 + (c.0 - bg.0) * a,
|
||||||
|
bg.1 + (c.1 - bg.1) * a,
|
||||||
|
bg.2 + (c.2 - bg.2) * a,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
d.rect(0.0, 0.0, w, h, bg);
|
||||||
|
// sector map: bottom 140 mm, fading toward the top
|
||||||
|
let cell = w / MAP_COLS as f64;
|
||||||
|
for (i, c) in s.cells.iter().enumerate() {
|
||||||
|
let (col, row) = (i % MAP_COLS, i / MAP_COLS);
|
||||||
|
// a band above the headline strip, gone behind the text. Unlike the HTML, it
|
||||||
|
// starts below the logo: the cover JPEG is opaque and would cut the map with a
|
||||||
|
// hard edge
|
||||||
|
let t = 1.0 - (row as f64 + 0.5) / MAP_ROWS as f64;
|
||||||
|
let fade = if t < 0.345 {
|
||||||
|
0.0
|
||||||
|
} else if t < 0.45 {
|
||||||
|
1.0
|
||||||
|
} else if t < 0.55 {
|
||||||
|
((0.55 - t) / 0.1).sqrt()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let (color, a) = match c {
|
||||||
|
3 => ((0.176, 0.831, 0.749), 0.85),
|
||||||
|
2 => ((0.961, 0.620, 0.043), 0.85),
|
||||||
|
1 => ((0.973, 0.443, 0.443), 0.85),
|
||||||
|
_ => ((0.176, 0.831, 0.749), 0.08),
|
||||||
|
};
|
||||||
|
let y = row as f64 * cell;
|
||||||
|
d.rect(
|
||||||
|
col as f64 * cell + 0.6,
|
||||||
|
y + 0.6,
|
||||||
|
cell - 1.2,
|
||||||
|
cell - 1.2,
|
||||||
|
mix(color, a * fade * fade),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// hero logo, bleeding off the right edge
|
||||||
|
let lw = 165.0 * mm;
|
||||||
|
d.image(w - lw + 26.0 * mm, h - 40.0 * mm - lw, lw, lw);
|
||||||
|
let white = (1.0, 1.0, 1.0);
|
||||||
|
let aqua = (0.176, 0.831, 0.749);
|
||||||
|
let soft = (0.62, 0.77, 0.75);
|
||||||
|
let x0 = 18.0 * mm;
|
||||||
|
// top bar: wordmark + chips
|
||||||
|
d.put(x0, h - 20.0 * mm, "G U A R Á", 11.0, Font::Bold, white);
|
||||||
|
let tlp_w = crate::pdf::text_width(TLP, 8.0, Font::Mono) + 12.0;
|
||||||
|
let tx = w - x0 - tlp_w;
|
||||||
|
let chip = |d: &mut Pdf, x: f64, wd: f64, label: &str, c: (f64, f64, f64)| {
|
||||||
|
let y = h - 21.5 * mm;
|
||||||
|
d.rect(x, y, wd, 0.8, c);
|
||||||
|
d.rect(x, y + 14.0, wd, 0.8, c);
|
||||||
|
d.rect(x, y, 0.8, 14.8, c);
|
||||||
|
d.rect(x + wd - 0.8, y, 0.8, 14.8, c);
|
||||||
|
d.put(x + 6.0, y + 4.5, label, 8.0, Font::Mono, c);
|
||||||
|
};
|
||||||
|
chip(d, tx, tlp_w, TLP, (1.0, 0.753, 0.0));
|
||||||
|
let auth = "USO AUTORIZADO";
|
||||||
|
let aw = crate::pdf::text_width(auth, 8.0, Font::Mono) + 12.0;
|
||||||
|
chip(d, tx - aw - 6.0, aw, auth, (0.62, 0.85, 0.82));
|
||||||
|
// eyebrow, title, subtitle
|
||||||
|
d.put(
|
||||||
|
x0,
|
||||||
|
h - 72.0 * mm,
|
||||||
|
"CIBERFAUNA · RECUPERAÇÃO FORENSE DE ARQUIVOS",
|
||||||
|
8.0,
|
||||||
|
Font::Mono,
|
||||||
|
aqua,
|
||||||
|
);
|
||||||
|
for (i, l) in ["Relatório de", "recuperação", "de arquivos"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
d.put(
|
||||||
|
x0,
|
||||||
|
h - 88.0 * mm - i as f64 * 38.0,
|
||||||
|
l,
|
||||||
|
36.0,
|
||||||
|
Font::Bold,
|
||||||
|
white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sub = format!(
|
||||||
|
"Exame somente leitura de {}: o que foi encontrado, o que foi recuperado e os hashes que provam a integridade de cada arquivo.",
|
||||||
|
short_target(r.target)
|
||||||
|
);
|
||||||
|
for (i, l) in crate::pdf::wrap(&sub, 11.0, Font::Regular, 88.0 * mm)
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
d.put(
|
||||||
|
x0,
|
||||||
|
h - 136.0 * mm - i as f64 * 16.0,
|
||||||
|
l,
|
||||||
|
11.0,
|
||||||
|
Font::Regular,
|
||||||
|
soft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// headline strip
|
||||||
|
let sw = (w - 2.0 * x0 - 3.0 * 4.0 * mm) / 4.0;
|
||||||
|
let sy = 52.0 * mm;
|
||||||
|
let orange = (0.976, 0.451, 0.086);
|
||||||
|
for (i, (v, l, c)) in [
|
||||||
|
(r.found.len().to_string(), "ARQUIVOS ENCONTRADOS", aqua),
|
||||||
|
(s.conf[2].to_string(), "CONFIANÇA ALTA", aqua),
|
||||||
|
(s.recovered.to_string(), "RECUPERADOS COM HASH", orange),
|
||||||
|
(human(s.bytes), "VOLUME ENCONTRADO", orange),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let x = x0 + i as f64 * (sw + 4.0 * mm);
|
||||||
|
d.rect(x, sy, sw, 50.0, (0.012, 0.063, 0.059));
|
||||||
|
d.rect(x, sy + 48.5, sw, 1.5, *c);
|
||||||
|
d.put(x + 8.0, sy + 22.0, v, 20.0, Font::Bold, white);
|
||||||
|
d.put(x + 8.0, sy + 9.0, l, 6.5, Font::Mono, soft);
|
||||||
|
}
|
||||||
|
// meta grid over a hairline
|
||||||
|
let my = 16.0 * mm;
|
||||||
|
d.rect(x0, my + 28.0, w - 2.0 * x0, 0.6, (0.33, 0.48, 0.46));
|
||||||
|
let cols = [
|
||||||
|
("EMISSÃO", fmt_unix(now), 0.0),
|
||||||
|
("ORIGEM", short_target(r.target).to_string(), 0.22),
|
||||||
|
(
|
||||||
|
"CASO · PERITO",
|
||||||
|
format!(
|
||||||
|
"{case} · {}",
|
||||||
|
if r.case.examiner.is_empty() {
|
||||||
|
"não informado"
|
||||||
|
} else {
|
||||||
|
&r.case.examiner
|
||||||
|
}
|
||||||
|
),
|
||||||
|
0.55,
|
||||||
|
),
|
||||||
|
("CLASSIFICAÇÃO", TLP.to_string(), 0.82),
|
||||||
|
];
|
||||||
|
let span = w - 2.0 * x0;
|
||||||
|
for (i, (k, v, f)) in cols.iter().enumerate() {
|
||||||
|
let x = x0 + span * f;
|
||||||
|
let next = cols.get(i + 1).map(|c| c.2).unwrap_or(1.0);
|
||||||
|
let cw = span * (next - f) - 10.0;
|
||||||
|
d.put(x, my + 16.0, k, 6.5, Font::Mono, (0.50, 0.66, 0.64));
|
||||||
|
let line = crate::pdf::wrap(v, 8.5, Font::Regular, cw);
|
||||||
|
for (j, l) in line.iter().take(2).enumerate() {
|
||||||
|
d.put(x, my + 4.0 - j as f64 * 11.0, l, 8.5, Font::Regular, white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.end_cover();
|
||||||
|
}
|
||||||
@@ -235,7 +235,12 @@ pub fn run(
|
|||||||
return Outcome::Interrupted(cp);
|
return Outcome::Interrupted(cp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handler.rebuild_pending();
|
if !handler.rebuild_pending(cancelled) {
|
||||||
|
// Paused/stopped during reassembly: the truncated candidates were already reported,
|
||||||
|
// and on resume (all ranges done) the scan finishes without redoing them.
|
||||||
|
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
return Outcome::Interrupted(cp);
|
||||||
|
}
|
||||||
Outcome::Completed
|
Outcome::Completed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +311,13 @@ impl Handler<'_> {
|
|||||||
self.sink.replace(f);
|
self.sink.replace(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_pending(&mut self) {
|
/// Reassembles queued fragmented files. Returns `false` if cancelled part-way.
|
||||||
|
fn rebuild_pending(&mut self, cancelled: &(dyn Fn() -> bool + Sync)) -> bool {
|
||||||
let pend = std::mem::take(&mut self.pending);
|
let pend = std::mem::take(&mut self.pending);
|
||||||
for p in pend {
|
for p in pend {
|
||||||
|
if cancelled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let off = p.found.first_offset().unwrap_or(0);
|
let off = p.found.first_offset().unwrap_or(0);
|
||||||
if p.mp4 {
|
if p.mp4 {
|
||||||
self.rebuild_mp4(p, off);
|
self.rebuild_mp4(p, off);
|
||||||
@@ -339,6 +348,7 @@ impl Handler<'_> {
|
|||||||
self.sink.replace(f);
|
self.sink.replace(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ impl Engine {
|
|||||||
"error": s.error,
|
"error": s.error,
|
||||||
"report": s.report,
|
"report": s.report,
|
||||||
"elapsed": elapsed,
|
"elapsed": elapsed,
|
||||||
|
"stopping": s.status == Status::Running && j.cancelled(),
|
||||||
"source": j.source().is_some(),
|
"source": j.source().is_some(),
|
||||||
"source_path": j.src_path.lock().unwrap().clone(),
|
"source_path": j.src_path.lock().unwrap().clone(),
|
||||||
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
|
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||||
@@ -1098,11 +1099,78 @@ impl Engine {
|
|||||||
"só sessões automáticas podem ser apagadas por aqui",
|
"só sessões automáticas podem ser apagadas por aqui",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let running = self.jobs.lock().unwrap().values().any(|j| {
|
||||||
|
j.session_file.lock().unwrap().as_deref() == Some(p)
|
||||||
|
&& j.st.lock().unwrap().status == Status::Running
|
||||||
|
});
|
||||||
|
if running {
|
||||||
|
return Err(Error::msg(
|
||||||
|
"esta varredura ainda está em andamento; pause ou pare antes de apagar a sessão",
|
||||||
|
));
|
||||||
|
}
|
||||||
let _ = std::fs::remove_file(p.with_extension("meta.json"));
|
let _ = std::fs::remove_file(p.with_extension("meta.json"));
|
||||||
std::fs::remove_file(p)?;
|
std::fs::remove_file(p)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `true` if an open job (scanning, paused, or loaded) still autosaves to this file.
|
||||||
|
fn session_in_use(&self, p: &Path) -> bool {
|
||||||
|
self.jobs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.any(|j| j.session_file.lock().unwrap().as_deref() == Some(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes autosaved sessions older than `days` (`None` = the configured retention;
|
||||||
|
/// `Some(0)` = all of them). Sessions held by open jobs are always kept.
|
||||||
|
/// Returns how many were removed.
|
||||||
|
pub fn sessions_prune(&self, days: Option<u32>) -> usize {
|
||||||
|
let days = match days {
|
||||||
|
Some(d) => d,
|
||||||
|
None => match crate::settings::load().session_retention_days {
|
||||||
|
0 => return 0, // retention disabled: keep everything
|
||||||
|
d => d,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let dir = sessions_dir();
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cutoff = now - days as i64 * 86_400;
|
||||||
|
let mut removed = 0;
|
||||||
|
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let path = e.path();
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
if !name.ends_with(".json") || name.ends_with(".meta.json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let saved_at = std::fs::read(path.with_extension("meta.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||||
|
.and_then(|v| v["saved_at"].as_i64())
|
||||||
|
.or_else(|| {
|
||||||
|
e.metadata()
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
})
|
||||||
|
.unwrap_or(now);
|
||||||
|
if saved_at <= cutoff && !self.session_in_use(&path) {
|
||||||
|
let _ = std::fs::remove_file(path.with_extension("meta.json"));
|
||||||
|
if std::fs::remove_file(&path).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- RPC
|
// ---------------------------------------------------------------- RPC
|
||||||
|
|
||||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||||
@@ -1342,6 +1410,10 @@ impl Engine {
|
|||||||
self.session_delete(&s("path")?)?;
|
self.session_delete(&s("path")?)?;
|
||||||
json!({})
|
json!({})
|
||||||
}
|
}
|
||||||
|
"sessions_prune" => {
|
||||||
|
let all = p.get("all").and_then(|v| v.as_bool()) == Some(true);
|
||||||
|
json!({ "removed": self.sessions_prune(if all { Some(0) } else { None }) })
|
||||||
|
}
|
||||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||||
"defaults" => {
|
"defaults" => {
|
||||||
let home = crate::owner::user_home().to_string_lossy().to_string();
|
let home = crate::owner::user_home().to_string_lossy().to_string();
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ pub struct Settings {
|
|||||||
/// without sending any telemetry).
|
/// without sending any telemetry).
|
||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
pub updates: bool,
|
pub updates: bool,
|
||||||
|
/// Autosaved sessions older than this many days are deleted on startup (`0` = keep forever).
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_retention_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auto() -> String {
|
fn auto() -> String {
|
||||||
@@ -46,6 +49,7 @@ impl Default for Settings {
|
|||||||
notify: true,
|
notify: true,
|
||||||
maps: false,
|
maps: false,
|
||||||
updates: true,
|
updates: true,
|
||||||
|
session_retention_days: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ struct Release {
|
|||||||
struct Asset {
|
struct Asset {
|
||||||
name: String,
|
name: String,
|
||||||
browser_download_url: String,
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -52,13 +54,25 @@ fn curl_text(url: &str) -> Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn curl_download(url: &str, dest: &Path) -> Result<()> {
|
/// Downloads `url` to `dest`, polling the file size to report `percent` (0-100) through
|
||||||
let status = Command::new("curl")
|
/// `on_pct` while curl runs (no stderr parsing; `total` comes from the release asset size).
|
||||||
|
fn curl_download(url: &str, dest: &Path, total: u64, on_pct: &dyn Fn(u8)) -> Result<()> {
|
||||||
|
let mut child = Command::new("curl")
|
||||||
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
||||||
.arg(dest)
|
.arg(dest)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
.status()
|
.spawn()
|
||||||
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
||||||
|
let status = loop {
|
||||||
|
if let Some(st) = child.try_wait().map_err(|e| Error::msg(e.to_string()))? {
|
||||||
|
break st;
|
||||||
|
}
|
||||||
|
let got = fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
|
||||||
|
if let Some(pct) = got.saturating_mul(100).checked_div(total) {
|
||||||
|
on_pct(pct.min(100) as u8);
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
};
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(Error::msg(format!("curl {url}: download falhou")));
|
return Err(Error::msg(format!("curl {url}: download falhou")));
|
||||||
}
|
}
|
||||||
@@ -145,7 +159,7 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
|||||||
let tmp_dir = std::env::temp_dir().join("guara-update");
|
let tmp_dir = std::env::temp_dir().join("guara-update");
|
||||||
fs::create_dir_all(&tmp_dir)?;
|
fs::create_dir_all(&tmp_dir)?;
|
||||||
let archive_path = tmp_dir.join(&asset.name);
|
let archive_path = tmp_dir.join(&asset.name);
|
||||||
curl_download(&asset.browser_download_url, &archive_path)?;
|
curl_download(&asset.browser_download_url, &archive_path, 0, &|_| {})?;
|
||||||
|
|
||||||
if let Some(checksum_asset) = checksum_asset {
|
if let Some(checksum_asset) = checksum_asset {
|
||||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||||
@@ -350,7 +364,12 @@ where
|
|||||||
let download_path = tmp_dir.join(&asset.name);
|
let download_path = tmp_dir.join(&asset.name);
|
||||||
|
|
||||||
on_progress("downloading");
|
on_progress("downloading");
|
||||||
curl_download(&asset.browser_download_url, &download_path)?;
|
curl_download(
|
||||||
|
&asset.browser_download_url,
|
||||||
|
&download_path,
|
||||||
|
asset.size,
|
||||||
|
&|pct| on_progress(&format!("downloading:{pct}")),
|
||||||
|
)?;
|
||||||
|
|
||||||
if let Some(checksum_asset) = checksum_asset {
|
if let Some(checksum_asset) = checksum_asset {
|
||||||
on_progress("verifying");
|
on_progress("verifying");
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
|
|||||||
notify: false,
|
notify: false,
|
||||||
maps: true,
|
maps: true,
|
||||||
updates: false,
|
updates: false,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let s = settings::load();
|
let s = settings::load();
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ fn streams(pdf: &[u8]) -> Vec<String> {
|
|||||||
while let Some(s) = find(pdf, b">>\nstream\n", at) {
|
while let Some(s) = find(pdf, b">>\nstream\n", at) {
|
||||||
let start = s + 10;
|
let start = s + 10;
|
||||||
let end = find(pdf, b"\nendstream", start).unwrap();
|
let end = find(pdf, b"\nendstream", start).unwrap();
|
||||||
|
// the cover logo is a raw JPEG (`/DCTDecode`), not a content stream
|
||||||
|
let dict_start = pdf[..s].windows(2).rposition(|w| w == b"<<").unwrap();
|
||||||
|
if find(&pdf[..s], b"/DCTDecode", dict_start).is_some() {
|
||||||
|
at = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
flate2::read::ZlibDecoder::new(&pdf[start..end])
|
flate2::read::ZlibDecoder::new(&pdf[start..end])
|
||||||
.read_to_string(&mut text)
|
.read_to_string(&mut text)
|
||||||
@@ -146,7 +152,11 @@ fn long_lists_paginate_with_numbered_footers() {
|
|||||||
assert!(pages.len() >= 5, "{} páginas", pages.len());
|
assert!(pages.len() >= 5, "{} páginas", pages.len());
|
||||||
let last = pages.len();
|
let last = pages.len();
|
||||||
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
|
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
|
||||||
assert!(pages[0].contains(&format!("p\\341gina 1 de {last}")));
|
// page 1 is the full-bleed cover (no footer); numbering continues on page 2
|
||||||
|
assert!(!pages[0].contains("p\\341gina 1 de"));
|
||||||
|
assert!(pages[1].contains(&format!("p\\341gina 2 de {last}")));
|
||||||
|
assert!(pages[0].contains("/Im1 Do"), "logo da capa ausente");
|
||||||
|
assert!(pages[1].contains("TLP:AMBER"));
|
||||||
// the table header row is repeated at the top of each continuation page
|
// the table header row is repeated at the top of each continuation page
|
||||||
assert!(pages[2].contains("Modificado \\(UTC\\)"));
|
assert!(pages[2].contains("Modificado \\(UTC\\)"));
|
||||||
// every table row appears exactly once across all pages
|
// every table row appears exactly once across all pages
|
||||||
|
|||||||
@@ -148,9 +148,9 @@ Searches in UTF-8 and UTF-16 (`--no-utf16` turns it off), including free space a
|
|||||||
guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
|
guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
|
||||||
```
|
```
|
||||||
|
|
||||||
Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML.
|
Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML. Both open with a full-page cover (logo, headline numbers, TLP:AMBER classification); the HTML is a self-contained A4 document that paginates in the browser and prints as is.
|
||||||
|
|
||||||
## `update` — updating
|
## `update`: updating
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
guara update # check for a new version
|
guara update # check for a new version
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 693 KiB After Width: | Height: | Size: 614 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 706 KiB After Width: | Height: | Size: 626 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 66 KiB |
@@ -37,9 +37,13 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), **
|
|||||||
|
|
||||||
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
|
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
|
||||||
|
|
||||||
|
## Saved sessions
|
||||||
|
|
||||||
|
Sessions are autosaved and listed under **Recent** in the sidebar. Hover one and click the ✕ to delete it, or use the trash button in the section title to delete them all; sessions open in the app are kept. **Settings > Delete autosaved sessions after** removes old ones on startup (never by default; 7, 30 or 90 days).
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself.
|
When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. Clicking **Update now** shows a progress bar with the download percentage, then the verify, install and restart steps; the app stays responsive meanwhile. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself.
|
||||||
|
|
||||||
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
||||||
|
|
||||||
@@ -47,4 +51,4 @@ Error messages that come from the core, and the terminal output, are still in Po
|
|||||||
|
|
||||||
## About these screenshots
|
## About these screenshots
|
||||||
|
|
||||||
They show the real interface, not a mock-up, running on a synthetic test image: 72 generated JPEGs with fake EXIF (cameras, dates, GPS) that were deleted from a FAT32 image by `scripts/make-photo-fixture.py` and found by a normal `guara scan`. No real data appears. The device name in the sidebar was replaced with a generic one.
|
They show the real interface, not a mock-up, running on a synthetic test image: 72 generated JPEGs with fake EXIF (cameras, dates, GPS) that were deleted from a FAT32 image by `scripts/make-photo-fixture.py` and found by a normal `guara scan`. No real data appears. The device name in the sidebar was replaced with a generic one. They are regenerated by `scripts/screenshots.sh` (see [CONTRIBUTING.md](../CONTRIBUTING.md)).
|
||||||
@@ -148,9 +148,9 @@ Procura em UTF-8 e UTF-16 (`--no-utf16` desliga), inclusive no espaço livre e f
|
|||||||
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
|
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
|
||||||
```
|
```
|
||||||
|
|
||||||
Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML.
|
Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML. Os dois abrem com uma capa de página inteira (logo, números principais, classificação TLP:AMBER); o HTML é um documento A4 autocontido que pagina no navegador e imprime como está.
|
||||||
|
|
||||||
## `update` — atualizações
|
## `update`: atualizações
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
guara update # verifica se há versão nova
|
guara update # verifica se há versão nova
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Clar
|
|||||||
|
|
||||||
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
|
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
|
||||||
|
|
||||||
|
## Sessões salvas
|
||||||
|
|
||||||
|
As sessões são salvas sozinhas e listadas em **Recentes** na barra lateral. Passe o mouse sobre uma e clique no ✕ para apagá-la, ou use o botão de lixeira no título da seção para apagar todas; sessões abertas no app são mantidas. **Configurações > Apagar sessões salvas sozinhas após** remove as antigas ao abrir (nunca, por padrão; 7, 30 ou 90 dias).
|
||||||
|
|
||||||
## Atualizações
|
## Atualizações
|
||||||
|
|
||||||
Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho.
|
Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. Ao clicar em **Atualizar agora**, aparece uma barra de progresso com a porcentagem do download e depois as etapas de verificação, instalação e reinício; o app continua responsivo. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho.
|
||||||
|
|
||||||
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
||||||
|
|
||||||
@@ -47,4 +51,4 @@ As mensagens de erro que vêm do núcleo e a saída do terminal continuam em por
|
|||||||
|
|
||||||
## Sobre estas telas
|
## Sobre estas telas
|
||||||
|
|
||||||
Mostram a interface de verdade, não uma maquete, rodando sobre uma imagem de teste sintética: 72 JPEGs gerados com EXIF falso (câmeras, datas, GPS), apagados de uma imagem FAT32 por `scripts/make-photo-fixture.py` e encontrados por um `guara scan` normal. Nenhum dado real aparece. O nome do dispositivo na barra lateral foi trocado por um genérico.
|
Mostram a interface de verdade, não uma maquete, rodando sobre uma imagem de teste sintética: 72 JPEGs gerados com EXIF falso (câmeras, datas, GPS), apagados de uma imagem FAT32 por `scripts/make-photo-fixture.py` e encontrados por um `guara scan` normal. Nenhum dado real aparece. O nome do dispositivo na barra lateral foi trocado por um genérico. Elas são regeradas por `scripts/screenshots.sh` (veja [CONTRIBUTING.pt-BR.md](../../CONTRIBUTING.pt-BR.md)).
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](../support.md) · **Português**
|
[English](../support.md) · **Português**
|
||||||
|
|
||||||
Matriz do que existe hoje (versão 1.3.2). Para o que vem a seguir, veja o [ROADMAP](../../ROADMAP.pt-BR.md).
|
Matriz do que existe hoje (versão 1.5.0). Para o que vem a seguir, veja o [ROADMAP](../../ROADMAP.pt-BR.md).
|
||||||
|
|
||||||
## Tabelas de partição
|
## Tabelas de partição
|
||||||
|
|
||||||
@@ -99,8 +99,8 @@ A pré-visualização é lida direto do disco, sem gravar nada: imagem, vídeo,
|
|||||||
| | CLI | Interface | Testes com imagens reais |
|
| | CLI | Interface | Testes com imagens reais |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Linux | ✅ | ✅ AppImage e deb | ✅ |
|
| Linux | ✅ | ✅ AppImage e deb | ✅ |
|
||||||
| Windows | ✅ | ✅ msi e exe | só compilação e testes sintéticos |
|
| Windows | ❌ sem binário na release | ✅ instalador exe | só compilação e testes sintéticos |
|
||||||
| macOS | ✅ | ✅ dmg | só compilação e testes sintéticos |
|
| macOS | ❌ não compilado nem testado | ❌ sem build | ❌ |
|
||||||
|
|
||||||
## O que o Guará não faz
|
## O que o Guará não faz
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](support.md) · [Português](pt-BR/suporte.md)
|
[English](support.md) · [Português](pt-BR/suporte.md)
|
||||||
|
|
||||||
Matrix of what exists today (version 1.3.2). For what comes next, see the [ROADMAP](../ROADMAP.md).
|
Matrix of what exists today (version 1.5.0). For what comes next, see the [ROADMAP](../ROADMAP.md).
|
||||||
|
|
||||||
## Partition tables
|
## Partition tables
|
||||||
|
|
||||||
@@ -99,8 +99,8 @@ The preview is read straight from the disk, without writing anything: image, vid
|
|||||||
| | CLI | Interface | Tests with real images |
|
| | CLI | Interface | Tests with real images |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Linux | ✅ | ✅ AppImage and deb | ✅ |
|
| Linux | ✅ | ✅ AppImage and deb | ✅ |
|
||||||
| Windows | ✅ | ✅ msi and exe | build and synthetic tests only |
|
| Windows | ❌ no release binary | ✅ exe installer | build and synthetic tests only |
|
||||||
| macOS | ✅ | ✅ dmg | build and synthetic tests only |
|
| macOS | ❌ not built or tested | ❌ no build | ❌ |
|
||||||
|
|
||||||
## What Guará does not do
|
## What Guará does not do
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regera as capturas de tela de docs/img a partir da interface real do Guará.
|
||||||
|
#
|
||||||
|
# Dados: uma imagem FAT32 sintética com 72 fotos JPEG apagadas (EXIF falso de câmera, data e
|
||||||
|
# GPS), gerada por scripts/make-photo-fixture.py e encontrada por uma varredura de verdade. A
|
||||||
|
# lista de dispositivos do computador é substituída por um nome genérico. Nenhum dado real.
|
||||||
|
#
|
||||||
|
# scripts/screenshots.sh # grava em docs/img
|
||||||
|
# OUT=/tmp/img scripts/screenshots.sh # grava em outro diretório
|
||||||
|
#
|
||||||
|
# Gera explorer-{dark,light}-{pt,en}, folders-light-{pt,en} e settings-language-{pt,en}.
|
||||||
|
# Variáveis: OUT, GUARA_BIN (padrão target/release/guara; sempre recompilado se não for dado,
|
||||||
|
# porque a interface vai embutida no binário), CHROME, PUPPETEER_CORE (padrões: Chrome do cache
|
||||||
|
# do puppeteer e puppeteer-core em ~/.claude/tools/node_modules).
|
||||||
|
# Requer: python3, ImageMagick (convert), mkfs.vfat e mtools (mmd, mcopy, mdel), node e Chrome.
|
||||||
|
# Usa /tmp/test-photos.img (a imagem aparece com esse caminho na barra lateral das capturas).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
out=${OUT:-$root/docs/img}
|
||||||
|
image=/tmp/test-photos.img
|
||||||
|
|
||||||
|
if [ -z "${GUARA_BIN:-}" ]; then
|
||||||
|
(cd "$root" && cargo build --release -p guara-cli)
|
||||||
|
GUARA_BIN=$root/target/release/guara
|
||||||
|
fi
|
||||||
|
mkdir -p "$out"
|
||||||
|
python3 "$root/scripts/make-photo-fixture.py" "$image" 72
|
||||||
|
trap 'rm -f "$image"' EXIT
|
||||||
|
|
||||||
|
node "$root/scripts/screenshots/shots.mjs" "$GUARA_BIN" "$image" "$out"
|
||||||
|
echo "capturas gravadas em $out"
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Captura a interface do Guará (explorer, vista de pastas e configurações) nos temas
|
||||||
|
// claro/escuro e nos idiomas pt/en. Chamado por scripts/screenshots.sh:
|
||||||
|
// node shots.mjs <binário guara> <imagem de teste> <dir-saída>
|
||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const [bin, image, out] = process.argv.slice(2);
|
||||||
|
const core = process.env.PUPPETEER_CORE || `${homedir()}/.claude/tools/node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js`;
|
||||||
|
const { default: puppeteer } = await import(core);
|
||||||
|
const chrome = process.env.CHROME || execFileSync("bash", ["-c", `ls -1d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux64/chrome | sort -V | tail -1`], { encoding: "utf8" }).trim();
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Dispositivo fictício: a lista real do computador não deve aparecer nas capturas.
|
||||||
|
const FAKE_DEVICES = [{ path: "/dev/nvme0n1", name: "SSD interno", kind: "NVMe", size: 954 * 1024 ** 3, readable: false, mounted_at: [], removable: false }];
|
||||||
|
const LABEL = "Pendrive 64 GB";
|
||||||
|
|
||||||
|
const variants = [
|
||||||
|
{ theme: "dark", lang: "pt" }, { theme: "dark", lang: "en" },
|
||||||
|
{ theme: "light", lang: "pt" }, { theme: "light", lang: "en" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sobe `guara ui` com config isolada (sessões e ajustes próprios) e devolve a URL com o token.
|
||||||
|
async function startServer(theme, lang) {
|
||||||
|
const cfg = mkdtempSync(join(tmpdir(), "guara-shots-"));
|
||||||
|
mkdirSync(cfg, { recursive: true });
|
||||||
|
writeFileSync(join(cfg, "settings.json"), JSON.stringify({ theme, lang, notify: true, maps: false, updates: false }));
|
||||||
|
const child = spawn(bin, ["ui", "--no-open", "--port", "0"], { env: { ...process.env, GUARA_CONFIG_DIR: cfg }, stdio: ["ignore", "ignore", "pipe"] });
|
||||||
|
const url = await new Promise((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
child.stderr.on("data", (d) => { buf += d; const m = buf.match(/http:\/\/\S+/); if (m) resolve(m[0]); });
|
||||||
|
child.on("exit", () => reject(new Error("guara ui saiu antes de subir")));
|
||||||
|
setTimeout(() => reject(new Error("timeout subindo guara ui")), 20000);
|
||||||
|
});
|
||||||
|
return { url, stop: () => { child.kill(); rmSync(cfg, { recursive: true, force: true }); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newPage(browser, url, w = 1600, h = 940) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1 });
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.url().includes("/rpc/devices")) {
|
||||||
|
req.respond({ status: 200, contentType: "application/json", body: JSON.stringify(FAKE_DEVICES) });
|
||||||
|
} else req.continue();
|
||||||
|
});
|
||||||
|
await page.goto(url, { waitUntil: "networkidle0" });
|
||||||
|
await sleep(1200);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abre a imagem de teste, faz a varredura profunda e espera terminar.
|
||||||
|
async function scanImage(page) {
|
||||||
|
await page.click("#openImageBtn");
|
||||||
|
await sleep(400);
|
||||||
|
await page.type("#dlg input[name=v]", image);
|
||||||
|
await page.click("#dlg button[value=ok]");
|
||||||
|
await sleep(1200);
|
||||||
|
await page.click("#goDeep");
|
||||||
|
await page.waitForFunction(() => document.querySelector(".xp-tile, .xp-lrow"), { timeout: 120000 });
|
||||||
|
await page.waitForFunction(() => !document.querySelector("#progressArea .progress-card"), { timeout: 120000 });
|
||||||
|
await sleep(2500); // miniaturas e autosave da sessão
|
||||||
|
}
|
||||||
|
|
||||||
|
const relabel = (page) => page.evaluate((t) => {
|
||||||
|
const sub = document.querySelector("#scanSub"); if (sub) sub.innerHTML = `<span class="mono">${t}</span>`;
|
||||||
|
}, LABEL);
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({ executablePath: chrome, args: ["--no-sandbox"] });
|
||||||
|
try {
|
||||||
|
for (const { theme, lang } of variants) {
|
||||||
|
const srv = await startServer(theme, lang);
|
||||||
|
try {
|
||||||
|
// 1ª rodada: só para deixar uma sessão salva em "Recentes" (a 2ª rodada cria a outra).
|
||||||
|
let page = await newPage(browser, srv.url);
|
||||||
|
await scanImage(page);
|
||||||
|
await page.close();
|
||||||
|
|
||||||
|
page = await newPage(browser, srv.url);
|
||||||
|
await page.evaluate(() => { try { localStorage.setItem("guara-view", "grid"); } catch {} });
|
||||||
|
await page.reload({ waitUntil: "networkidle0" });
|
||||||
|
await sleep(1000);
|
||||||
|
await scanImage(page);
|
||||||
|
await relabel(page);
|
||||||
|
|
||||||
|
// Explorador em grade, com a primeira foto marcada.
|
||||||
|
const chk = await page.$(".xp-tile .xp-chk");
|
||||||
|
if (chk) await chk.click(); else await (await page.$(".xp-tile")).click();
|
||||||
|
await sleep(800);
|
||||||
|
await page.screenshot({ path: `${out}/explorer-${theme}-${lang}.png` });
|
||||||
|
console.log("ok", `explorer-${theme}-${lang}`);
|
||||||
|
|
||||||
|
if (theme === "light") {
|
||||||
|
// Vista de pastas: pasta DCIM marcada e selecionada.
|
||||||
|
await page.click('button[data-view="folders"]');
|
||||||
|
await sleep(1000);
|
||||||
|
const dcim = await page.$('.xp-lrow.xp-dir[data-dir="0"]');
|
||||||
|
await (await dcim.$(".xp-chk")).click();
|
||||||
|
await sleep(400);
|
||||||
|
await dcim.click({ offset: { x: 200, y: 20 } });
|
||||||
|
await sleep(800);
|
||||||
|
await relabel(page);
|
||||||
|
await page.screenshot({ path: `${out}/folders-${theme}-${lang}.png` });
|
||||||
|
console.log("ok", `folders-${theme}-${lang}`);
|
||||||
|
await page.click('button[data-view="grid"]');
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme === "light") {
|
||||||
|
// Diálogo de configurações, recortado com a moldura borrada ao redor.
|
||||||
|
await page.click("#settingsBtn");
|
||||||
|
await sleep(900);
|
||||||
|
const box = await (await page.$("#dlg")).boundingBox();
|
||||||
|
const pad = 24;
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${out}/settings-language-${lang}.png`,
|
||||||
|
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.width + 2 * pad, height: box.height + 2 * pad },
|
||||||
|
});
|
||||||
|
console.log("ok", `settings-language-${lang}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||