Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f940f261 | ||
|
|
282161d297 | ||
|
|
b9bef85bc7 | ||
|
|
354caaf8f0 | ||
|
|
682ba8b943 |
No files matched your search
@@ -2,6 +2,27 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [0.3.2] - 2026-10-18
|
||||
|
||||
### Corrigido
|
||||
- **Travamento ao clicar em um vídeo no app para Linux.** O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer: ao criar um `<video>` ou `<audio>` o processo da página morria e a janela ficava congelada. Agora o app para Linux nunca cria esses elementos; vídeo e áudio têm o botão "Abrir no player do sistema", que copia o arquivo para uma pasta temporária do usuário e o abre no player padrão (inclusive quando o Guará roda como administrador). No navegador (`guara ui`) e nos demais sistemas o vídeo continua tocando dentro do app, mas só na tela grande, nunca no painel pequeno.
|
||||
|
||||
### Alterado
|
||||
- Clicar na pré-visualização do painel de detalhes abre a visualização grande (imagem, PDF, documento e demais tipos), agora maior (até 98% da largura e 95% da altura da janela).
|
||||
|
||||
## [0.3.1] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
|
||||
|
||||
### Corrigido
|
||||
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
|
||||
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
|
||||
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
|
||||
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
|
||||
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
|
||||
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
|
||||
|
||||
## [0.3.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
|
||||
Generated
+3
-2
@@ -317,7 +317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -329,7 +329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -343,6 +343,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tiny_http",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -34,7 +34,7 @@ Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --relea
|
||||
|
||||
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
||||
|
||||
## Uso (terminal)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
+7
-1
@@ -323,7 +323,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
||||
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
|
||||
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
|
||||
|
||||
dialog.lightbox { width: min(1180px, 96vw); height: min(90vh, 920px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox { width: min(1760px, 98vw); height: min(95vh, 1100px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox[open] { display: grid; }
|
||||
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
|
||||
@@ -454,3 +454,9 @@ dialog.lightbox[open] { display: grid; }
|
||||
.hit .ctx { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; color: var(--text-2); }
|
||||
.hit mark { background: var(--accent-soft); color: var(--accent); padding: 0 2px; border-radius: 3px; font-weight: 600; }
|
||||
@media (max-width: 860px) { .hit { grid-template-columns: 100px 1fr; } .hit span:nth-child(2), .hit span:nth-child(3) { display: none; } }
|
||||
|
||||
.xp-insp #xpPv { cursor: zoom-in; }
|
||||
.pv-card { width: 100%; min-height: 220px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; text-align: center; }
|
||||
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
|
||||
.pv-bigicon { font-size: 52px; line-height: 1; }
|
||||
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
|
||||
+10
-4
@@ -210,7 +210,7 @@ $('#loadSessionBtn').onclick = async () => {
|
||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
openJob(s);
|
||||
} catch (e) { toast(e.message); }
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ início
|
||||
@@ -431,7 +431,7 @@ function renderScan() {
|
||||
</div>`;
|
||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||
$('#progressArea').onclick = onProgressClick;
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast });
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent) });
|
||||
}
|
||||
|
||||
function paintStatus() {
|
||||
@@ -447,7 +447,8 @@ function paintStatus() {
|
||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
|
||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
|
||||
const tools = !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 relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||
if (running || st.status === 'paused') {
|
||||
setHTML($('#progressArea'), `<div class="progress-card slim">
|
||||
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
|
||||
@@ -473,7 +474,12 @@ async function onProgressClick(e) {
|
||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
else if (act === 'csv') {
|
||||
else if (act === 'relink') {
|
||||
const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre <code>${esc(S.status?.target || '')}</code>. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' });
|
||||
if (path) {
|
||||
try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
}
|
||||
} else if (act === 'csv') {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
} else if (act === 'report') {
|
||||
|
||||
+27
-6
@@ -31,8 +31,13 @@ const I = {
|
||||
|
||||
export function createExplorer(deps) {
|
||||
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
|
||||
const inlineMedia = !deps.noInlineMedia;
|
||||
const openExternal = (it) => {
|
||||
toast('Preparando para abrir no player do sistema…');
|
||||
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
|
||||
};
|
||||
const E = {
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' },
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
|
||||
sort: 'date',
|
||||
view: 'grid',
|
||||
tile: 150,
|
||||
@@ -95,6 +100,8 @@ export function createExplorer(deps) {
|
||||
if (f.minConf) q.min_confidence = f.minConf;
|
||||
if (f.gps) q.has_gps = true;
|
||||
if (f.dedupe) q.dedupe = true;
|
||||
if (f.bigOnly) q.min_size = 30 * 1024;
|
||||
if (f.mp1) q.min_mp = 1;
|
||||
if (f.query) q.query = f.query;
|
||||
return q;
|
||||
};
|
||||
@@ -279,7 +286,10 @@ export function createExplorer(deps) {
|
||||
if (img.tagName !== 'IMG' || !img.dataset.th) return;
|
||||
const wrap = img.closest('.xp-thumb, .xp-lth');
|
||||
img.remove();
|
||||
wrap?.insertAdjacentHTML('afterbegin', `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
const offline = E.status && E.status.source === false;
|
||||
wrap?.insertAdjacentHTML('afterbegin', offline
|
||||
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
|
||||
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
}, true);
|
||||
|
||||
// ------------------------------------------------------------ seleção
|
||||
@@ -421,6 +431,8 @@ export function createExplorer(deps) {
|
||||
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
|
||||
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
|
||||
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
|
||||
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
|
||||
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
|
||||
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
|
||||
|
||||
const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
|
||||
@@ -447,6 +459,8 @@ export function createExplorer(deps) {
|
||||
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
|
||||
else if (f === 'gps') F.gps = !F.gps;
|
||||
else if (f === 'dedupe') F.dedupe = !F.dedupe;
|
||||
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
|
||||
else if (f === 'mp1') F.mp1 = !F.mp1;
|
||||
else F[f] = F[f] === v ? null : v;
|
||||
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
|
||||
nav._h = null;
|
||||
@@ -455,7 +469,7 @@ export function createExplorer(deps) {
|
||||
});
|
||||
|
||||
function clearFilters(refresh = true) {
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' };
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
|
||||
$('#xpQ').value = '';
|
||||
if (refresh) changed(); else changed();
|
||||
}
|
||||
@@ -490,6 +504,8 @@ export function createExplorer(deps) {
|
||||
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
|
||||
if (F.gps) add('Com GPS', 'gps');
|
||||
if (F.dedupe) add('Sem duplicadas', 'dedupe');
|
||||
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
|
||||
if (F.mp1) add('Mais de 1 MP', 'mp1');
|
||||
if (F.query) add(`“${esc(F.query)}”`, 'query');
|
||||
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
|
||||
}
|
||||
@@ -497,7 +513,7 @@ export function createExplorer(deps) {
|
||||
const k = e.target.closest('[data-x]')?.dataset.x;
|
||||
if (!k) return;
|
||||
if (k === 'all') return clearFilters();
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' ? false : null;
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
|
||||
if (k === 'year') E.filters.month = null;
|
||||
if (k === 'query') $('#xpQ').value = '';
|
||||
nav._h = null;
|
||||
@@ -559,7 +575,12 @@ export function createExplorer(deps) {
|
||||
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
|
||||
<div class="xp-meta" id="xpMeta"></div>
|
||||
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image' });
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
|
||||
// clicar na miniatura abre o modal grande
|
||||
$('#xpPv').addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button, a, .pv-pdfbar')) return;
|
||||
openLightbox(idxOf(it.id));
|
||||
});
|
||||
const my = ++metaToken;
|
||||
rpc('metadata', { job, id: it.id }).then((m) => {
|
||||
if (my !== metaToken || !$('#xpMeta')) return;
|
||||
@@ -591,7 +612,7 @@ export function createExplorer(deps) {
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
stopLb?.();
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true });
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, onExternal: () => openExternal(it) });
|
||||
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
|
||||
d.querySelector('#lbPrev').onclick = () => go(-1);
|
||||
d.querySelector('#lbNext').onclick = () => go(1);
|
||||
|
||||
+26
-1
@@ -131,8 +131,10 @@ async function image(host, item, url, state, thumb) {
|
||||
if (state.dead) return resolve();
|
||||
host.innerHTML = '';
|
||||
host.appendChild(img);
|
||||
if (!thumb) {
|
||||
host.classList.add('zoomable');
|
||||
img.onclick = () => host.classList.toggle('actual');
|
||||
}
|
||||
if (!thumb) note(host, `${img.naturalWidth} × ${img.naturalHeight} px. Clique para alternar entre ajustar e tamanho real.`);
|
||||
resolve();
|
||||
};
|
||||
@@ -272,12 +274,35 @@ async function zip(host, item, url, state) {
|
||||
|
||||
// ------------------------------------------------------------------ API
|
||||
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false }) {
|
||||
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
|
||||
const ico = kind === 'video' ? '🎞' : '🎵';
|
||||
const canInline = inlineMedia && large;
|
||||
host.innerHTML = `<div class="pv-card"><div class="pv-bigicon" aria-hidden="true">${ico}</div>
|
||||
<b>${esc(item.name)}</b><span class="sub">${esc(KIND_LABEL[kind])} · ${human(item.size)}</span>
|
||||
${inlineMedia
|
||||
? '<button class="btn primary" data-pv="open">Reproduzir em tela grande</button>'
|
||||
: '<button class="btn primary" data-pv="ext">Abrir no player do sistema</button><span class="pv-why">Neste app o ' + (kind === 'video' ? 'vídeo' : 'áudio') + ' abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.</span>'}
|
||||
</div>`;
|
||||
host.onclick = (e) => {
|
||||
const a = e.target.closest('[data-pv]')?.dataset.pv;
|
||||
if (a === 'open') onOpen?.();
|
||||
else if (a === 'ext') onExternal?.();
|
||||
};
|
||||
void canInline;
|
||||
}
|
||||
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null }) {
|
||||
const state = { dead: false, cleanup: [] };
|
||||
host.dataset.url = url;
|
||||
host.classList.remove('zoomable', 'actual');
|
||||
host.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
const kind = kindOf(item);
|
||||
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
|
||||
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
|
||||
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
|
||||
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
|
||||
return () => { state.dead = true; };
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
|
||||
|
||||
@@ -4,7 +4,7 @@ use guara_core::service::Engine;
|
||||
use serde_json::json;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use tiny_http::{Header, Method, Request, Response, Server};
|
||||
|
||||
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||
@@ -52,7 +52,7 @@ fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let host_ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
@@ -61,8 +61,11 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||
return;
|
||||
}
|
||||
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
|
||||
return;
|
||||
};
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let (path, _query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let ct = |v: &str| header("Content-Type", v);
|
||||
match (req.method().clone(), path) {
|
||||
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||
@@ -105,78 +108,6 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/thumb/") => {
|
||||
let tok_ok = query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token));
|
||||
if !tok_ok {
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return;
|
||||
}
|
||||
let mut it = p.trim_start_matches("/thumb/").split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return;
|
||||
};
|
||||
match eng.thumb(job, id, size) {
|
||||
Ok(bytes) => {
|
||||
let resp = Response::from_data(bytes.as_ref().clone())
|
||||
.with_header(ct("image/jpeg"))
|
||||
.with_header(header("Cache-Control", "private, max-age=3600"));
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ =
|
||||
req.respond(Response::from_string("sem miniatura").with_status_code(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/file/") => {
|
||||
let tok_ok = query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token));
|
||||
if !tok_ok {
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return;
|
||||
}
|
||||
let mut it = p.trim_start_matches("/file/").split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return;
|
||||
};
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> =
|
||||
slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp =
|
||||
Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ =
|
||||
req.respond(Response::from_string("indisponível").with_status_code(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||
let ok = req
|
||||
.headers()
|
||||
|
||||
@@ -18,6 +18,7 @@ jpeg-encoder = "0.6"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
thiserror = "2"
|
||||
tiny_http = "0.12"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -180,7 +180,14 @@ pub fn facets(found: &[Found]) -> Value {
|
||||
let mut conf = [0usize; 3];
|
||||
let mut seen_sig = std::collections::HashSet::new();
|
||||
let mut dups = 0usize;
|
||||
let (mut small, mut mp1) = (0usize, 0usize);
|
||||
for f in found {
|
||||
if f.size < 30 * 1024 {
|
||||
small += 1;
|
||||
}
|
||||
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
|
||||
mp1 += 1;
|
||||
}
|
||||
if let Some(g) = f.sig {
|
||||
if !seen_sig.insert((g, f.size)) {
|
||||
dups += 1;
|
||||
@@ -242,6 +249,8 @@ pub fn facets(found: &[Found]) -> Value {
|
||||
"root": root,
|
||||
"gps": gps,
|
||||
"duplicates": dups,
|
||||
"small": small,
|
||||
"mp1": mp1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Abre um arquivo achado no aplicativo padrão do sistema (player de vídeo, leitor
|
||||
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia:
|
||||
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e
|
||||
//! o processo da página morre ao criar um `<video>`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fs::sanitize_name;
|
||||
use crate::types::Found;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pasta temporária própria do usuário (nunca compartilhada).
|
||||
fn temp_dir() -> PathBuf {
|
||||
let uid = crate::owner::invoking_user()
|
||||
.map(|u| u.uid.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: getuid não tem pré-condições.
|
||||
unsafe { libc::getuid() }.to_string()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"user".to_string()
|
||||
}
|
||||
});
|
||||
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
|
||||
}
|
||||
|
||||
/// Remove cópias com mais de um dia.
|
||||
fn cleanup(dir: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let limit = std::time::Duration::from_secs(24 * 3600);
|
||||
for e in rd.flatten() {
|
||||
if let Ok(age) = e
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.and_then(|t| t.elapsed().map_err(std::io::Error::other))
|
||||
{
|
||||
if age > limit {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copia o arquivo achado para uma pasta temporária do usuário e o abre no aplicativo padrão.
|
||||
pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
|
||||
const MAX: u64 = 8 * 1024 * 1024 * 1024;
|
||||
if f.size > MAX {
|
||||
return Err(Error::msg("Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos."));
|
||||
}
|
||||
let dir = temp_dir();
|
||||
crate::owner::create_dir_all_owned(&dir)?;
|
||||
cleanup(&dir);
|
||||
let path = dir.join(format!("{}-{}", f.id, sanitize_name(&f.name)));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
crate::recover::write_file(src, f, &path)?;
|
||||
open_path(&path)
|
||||
.map_err(|e| Error::msg(format!("Não foi possível abrir {}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Abre o caminho com o aplicativo padrão, na sessão do usuário real mesmo se formos root.
|
||||
pub fn open_path(path: &Path) -> std::io::Result<()> {
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut cmd = if let Some(u) = crate::owner::invoking_user() {
|
||||
let mut c = Command::new("runuser");
|
||||
c.args(["-u", &u.name, "--", "env"]);
|
||||
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
|
||||
c.arg(format!(
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
|
||||
u.uid
|
||||
));
|
||||
c.arg("xdg-open").arg(path);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", "start", ""]).arg(path);
|
||||
c
|
||||
};
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Rotas HTTP de mídia (`/file` com Range e `/thumb`) compartilhadas pelo modo `guara ui`.
|
||||
|
||||
use crate::service::Engine;
|
||||
use tiny_http::{Header, Method, Request, Response, StatusCode};
|
||||
|
||||
pub fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// O cabeçalho Host bate com um dos endereços esperados (defesa contra DNS rebinding)?
|
||||
pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
|
||||
req.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()))
|
||||
}
|
||||
|
||||
/// Responde `/file/<job>/<id>` (com Range) e `/thumb/<job>/<id>?s=NNN`.
|
||||
/// Devolve a requisição quando ela não é uma rota de mídia.
|
||||
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let is_file = path.starts_with("/file/");
|
||||
let is_thumb = path.starts_with("/thumb/");
|
||||
if !(is_file || is_thumb) {
|
||||
return Some(req);
|
||||
}
|
||||
if req.method() == &Method::Options {
|
||||
let r = Response::empty(204)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"))
|
||||
.with_header(header("Access-Control-Allow-Headers", "Range"))
|
||||
.with_header(header("Access-Control-Allow-Methods", "GET, OPTIONS"));
|
||||
let _ = req.respond(r);
|
||||
return None;
|
||||
}
|
||||
if req.method() != &Method::Get {
|
||||
let _ = req.respond(Response::from_string("método não permitido").with_status_code(405));
|
||||
return None;
|
||||
}
|
||||
if !query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token))
|
||||
{
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return None;
|
||||
}
|
||||
let mut it = path
|
||||
.trim_start_matches("/file/")
|
||||
.trim_start_matches("/thumb/")
|
||||
.split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return None;
|
||||
};
|
||||
if is_thumb {
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
match eng.thumb(job, id, size) {
|
||||
Ok(bytes) => {
|
||||
let r = Response::from_data(bytes.as_ref().clone())
|
||||
.with_header(header("Content-Type", "image/jpeg"))
|
||||
.with_header(header("Cache-Control", "private, max-age=3600"))
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"));
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("sem miniatura")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> = slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp = Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("indisponível")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub fn create_image(
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
crate::owner::give_to_user(dest);
|
||||
let size = src.size();
|
||||
let mut rep = ImageReport::default();
|
||||
let mut hasher = MultiHasher::new();
|
||||
@@ -81,7 +82,7 @@ pub fn create_image(
|
||||
rep.md5 = h.md5;
|
||||
rep.sha1 = h.sha1;
|
||||
let map = dest.with_extension("map.json");
|
||||
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,13 @@ pub mod carve;
|
||||
pub mod device;
|
||||
pub mod error;
|
||||
pub mod explorer;
|
||||
pub mod external;
|
||||
pub mod fs;
|
||||
pub mod hashing;
|
||||
pub mod http;
|
||||
pub mod image;
|
||||
pub mod meta;
|
||||
pub mod owner;
|
||||
pub mod partition;
|
||||
pub mod recover;
|
||||
pub mod report;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para
|
||||
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que
|
||||
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub home: PathBuf,
|
||||
}
|
||||
|
||||
/// O usuário real por trás de um processo root iniciado por sudo/pkexec.
|
||||
#[cfg(unix)]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
let uid = std::env::var("SUDO_UID")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("PKEXEC_UID").ok())
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.filter(|&u| u != 0);
|
||||
let name = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||
lookup_user(&passwd, uid, name.as_deref())
|
||||
}
|
||||
|
||||
/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd.
|
||||
pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
if f.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let (Ok(fuid), Ok(fgid)) = (f[2].parse::<u32>(), f[3].parse::<u32>()) else {
|
||||
continue;
|
||||
};
|
||||
if uid == Some(fuid) || (uid.is_none() && name == Some(f[0])) {
|
||||
return Some(User {
|
||||
name: f[0].to_string(),
|
||||
uid: fuid,
|
||||
gid: fgid,
|
||||
home: PathBuf::from(f[5]),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root).
|
||||
pub fn user_home() -> PathBuf {
|
||||
if let Some(u) = invoking_user() {
|
||||
return u.home;
|
||||
}
|
||||
PathBuf::from(
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root).
|
||||
pub fn give_to_user(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(u) = invoking_user() {
|
||||
let _ = std::os::unix::fs::lchown(path, Some(u.uid), Some(u.gid));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo.
|
||||
pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
|
||||
let mut missing = vec![];
|
||||
let mut cur = Some(path);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p.exists() {
|
||||
break;
|
||||
}
|
||||
missing.push(p.to_path_buf());
|
||||
cur = p.parent();
|
||||
}
|
||||
std::fs::create_dir_all(path)?;
|
||||
for p in missing.iter().rev() {
|
||||
give_to_user(p);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como `fs::write`, mas o arquivo fica com o dono certo.
|
||||
pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all_owned(parent)?;
|
||||
}
|
||||
std::fs::write(path, bytes)?;
|
||||
give_to_user(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\nlucas:x:1000:1000:Lucas:/home/lucas:/bin/bash\nmaria:x:1001:1002::/home/maria:/bin/zsh\nlinha-quebrada\n";
|
||||
|
||||
#[test]
|
||||
fn acha_o_usuario_por_uid_ou_nome() {
|
||||
let u = lookup_user(PASSWD, Some(1000), None).unwrap();
|
||||
assert_eq!(
|
||||
(u.uid, u.gid, u.home.to_str().unwrap()),
|
||||
(1000, 1000, "/home/lucas")
|
||||
);
|
||||
let m = lookup_user(PASSWD, None, Some("maria")).unwrap();
|
||||
assert_eq!(
|
||||
(m.uid, m.gid, m.home.to_str().unwrap()),
|
||||
(1001, 1002, "/home/maria")
|
||||
);
|
||||
assert!(lookup_user(PASSWD, Some(4242), None).is_none());
|
||||
// o uid manda sobre o nome
|
||||
assert_eq!(
|
||||
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
|
||||
1001
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fora_do_modo_root_nao_muda_dono_e_grava_normalmente() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let f = d.path().join("a/b/sessao.json");
|
||||
write_owned(&f, b"{}").unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"{}");
|
||||
assert!(create_dir_all_owned(&d.path().join("x/y")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub struct RecoverReport {
|
||||
|
||||
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let dest = dest.canonicalize()?;
|
||||
let Some(sp) = source_path else { return Ok(()) };
|
||||
let sp_str = sp.to_string_lossy().to_string();
|
||||
@@ -79,7 +79,7 @@ pub fn recover(
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let mut report = RecoverReport {
|
||||
destination: dest.display().to_string(),
|
||||
..Default::default()
|
||||
@@ -94,7 +94,7 @@ pub fn recover(
|
||||
let rel = target_rel(f);
|
||||
let mut path = dest.join(&rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
if let Err(e) = crate::owner::create_dir_all_owned(parent) {
|
||||
report.failed.push((f.id, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
@@ -144,8 +144,8 @@ pub fn recover(
|
||||
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
||||
"relatorio": &report,
|
||||
});
|
||||
std::fs::write(
|
||||
dest.join("guara-manifesto.json"),
|
||||
crate::owner::write_owned(
|
||||
&dest.join("guara-manifesto.json"),
|
||||
serde_json::to_vec_pretty(&manifest)?,
|
||||
)?;
|
||||
Ok(report)
|
||||
@@ -176,6 +176,7 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?;
|
||||
crate::owner::give_to_user(path);
|
||||
let mut hasher = MultiHasher::new();
|
||||
match &f.data {
|
||||
DataLoc::Resident { bytes } => {
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct Job {
|
||||
opts: ScanOptions,
|
||||
cancel: AtomicBool,
|
||||
stop_requested: AtomicBool,
|
||||
src: Option<Arc<dyn Source>>,
|
||||
src: Mutex<Option<Arc<dyn Source>>>,
|
||||
st: Mutex<State>,
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Job {
|
||||
opts,
|
||||
cancel: AtomicBool::new(false),
|
||||
stop_requested: AtomicBool::new(false),
|
||||
src,
|
||||
src: Mutex::new(src),
|
||||
st: Mutex::new(State {
|
||||
status: Status::Running,
|
||||
progress: Progress::default(),
|
||||
@@ -105,6 +105,9 @@ impl Job {
|
||||
}),
|
||||
})
|
||||
}
|
||||
fn source(&self) -> Option<Arc<dyn Source>> {
|
||||
self.src.lock().unwrap().clone()
|
||||
}
|
||||
fn cancelled(&self) -> bool {
|
||||
self.cancel.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -214,6 +217,9 @@ impl Engine {
|
||||
|
||||
pub fn resume(&self, id: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
if j.source().is_none() {
|
||||
return Err(no_source());
|
||||
}
|
||||
let cp = {
|
||||
let mut s = j.st.lock().unwrap();
|
||||
if s.status != Status::Paused {
|
||||
@@ -254,6 +260,7 @@ impl Engine {
|
||||
"error": s.error,
|
||||
"report": s.report,
|
||||
"elapsed": elapsed,
|
||||
"source": j.source().is_some(),
|
||||
"hits": s.hits.len(),
|
||||
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
|
||||
}))
|
||||
@@ -294,7 +301,7 @@ impl Engine {
|
||||
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
|
||||
return if v.is_empty() { Err(422) } else { Ok(v) };
|
||||
}
|
||||
let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?;
|
||||
let (f, src) = self.open_file(job, id).map_err(status_of)?;
|
||||
if f.category != Category::Image {
|
||||
return Err(415);
|
||||
}
|
||||
@@ -319,10 +326,7 @@ impl Engine {
|
||||
.index
|
||||
.get(&file)
|
||||
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
||||
Ok((
|
||||
s.found[i].clone(),
|
||||
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
|
||||
))
|
||||
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recuperação
|
||||
@@ -335,7 +339,7 @@ impl Engine {
|
||||
dest: &str,
|
||||
) -> Result<String> {
|
||||
let sj = self.job(scan_id)?;
|
||||
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?;
|
||||
let src = sj.source().ok_or_else(no_source)?;
|
||||
let files: Vec<Found> = {
|
||||
let s = sj.st.lock().unwrap();
|
||||
match (&ids, &filter) {
|
||||
@@ -449,7 +453,7 @@ impl Engine {
|
||||
Some(q) => q.select(&s.found),
|
||||
None => s.found.iter().collect(),
|
||||
};
|
||||
std::fs::write(path, crate::report::csv(&items))?;
|
||||
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?;
|
||||
Ok(items.len())
|
||||
}
|
||||
|
||||
@@ -478,7 +482,7 @@ impl Engine {
|
||||
recoveries: &s.recoveries,
|
||||
source_hashes: hashes.as_ref(),
|
||||
});
|
||||
std::fs::write(path, html)?;
|
||||
crate::owner::write_owned(Path::new(path), html)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -606,18 +610,36 @@ impl Engine {
|
||||
recoveries: s.recoveries.clone(),
|
||||
started_unix: s.started_unix,
|
||||
};
|
||||
std::fs::write(path, serde_json::to_vec(&data)?)?;
|
||||
crate::owner::write_owned(Path::new(path), serde_json::to_vec(&data)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::msg(format!(
|
||||
"Sem permissão para ler a sessão {path}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta."
|
||||
))
|
||||
} else {
|
||||
Error::msg(format!("Não foi possível ler a sessão {path}: {e}"))
|
||||
}
|
||||
})?;
|
||||
let data: SessionFile = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
|
||||
))
|
||||
})?;
|
||||
// A sessão abre mesmo sem a origem: dá para navegar, exportar e gerar relatório.
|
||||
// Pré-visualização e recuperação voltam quando a origem for reconectada.
|
||||
let (src, missing) = match open_path(Path::new(&data.target)) {
|
||||
Ok(s) => (Some(s), None),
|
||||
Err(e) => (None, Some(open_error(&data.target, e).to_string())),
|
||||
};
|
||||
let job = self.register(
|
||||
JobKind::Scan,
|
||||
data.target.clone(),
|
||||
data.options.clone(),
|
||||
Some(src),
|
||||
src,
|
||||
);
|
||||
{
|
||||
let mut s = job.st.lock().unwrap();
|
||||
@@ -636,12 +658,45 @@ impl Engine {
|
||||
Status::Paused
|
||||
};
|
||||
}
|
||||
if let Some(m) = missing {
|
||||
job.st.lock().unwrap().warnings.push(format!(
|
||||
"A origem {} não está disponível agora ({m}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
|
||||
data.target
|
||||
));
|
||||
}
|
||||
if data.done {
|
||||
spawn_enrich(job.clone());
|
||||
}
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||
pub fn relink(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
{
|
||||
let s = j.st.lock().unwrap();
|
||||
if let Some(l) = &s.layout {
|
||||
if l.size != 0 && l.size != src.size() {
|
||||
return Err(Error::msg(format!(
|
||||
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
|
||||
src.size(),
|
||||
l.size
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.warnings
|
||||
.retain(|w| !w.starts_with("A origem "));
|
||||
if j.st.lock().unwrap().status == Status::Done {
|
||||
spawn_enrich(j);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RPC
|
||||
|
||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||
@@ -681,6 +736,15 @@ impl Engine {
|
||||
}
|
||||
"status" => self.status(&s("job")?)?,
|
||||
"facets" => self.facets(&s("job")?)?,
|
||||
"open_external" => {
|
||||
let id = p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| Error::msg("id ausente"))?;
|
||||
let (f, src) = self.open_file(&s("job")?, id)?;
|
||||
let path = crate::external::open_found(src.as_ref(), &f)?;
|
||||
json!({ "path": path.to_string_lossy() })
|
||||
}
|
||||
"export_csv" => {
|
||||
let q: Option<Query> = p
|
||||
.get("query")
|
||||
@@ -758,11 +822,13 @@ impl Engine {
|
||||
self.save_session(&s("job")?, &s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_relink" => {
|
||||
self.relink(&s("job")?, &s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||
"defaults" => {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default();
|
||||
let home = crate::owner::user_home().to_string_lossy().to_string();
|
||||
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
|
||||
}
|
||||
"version" => {
|
||||
@@ -793,6 +859,19 @@ struct SessionFile {
|
||||
|
||||
use crate::explorer::cat_key;
|
||||
|
||||
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
|
||||
fn status_of(e: Error) -> u16 {
|
||||
if e.to_string().contains("não está disponível") {
|
||||
503
|
||||
} else {
|
||||
404
|
||||
}
|
||||
}
|
||||
|
||||
fn no_source() -> Error {
|
||||
Error::msg("A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.")
|
||||
}
|
||||
|
||||
fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::msg(format!(
|
||||
@@ -805,7 +884,12 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
|
||||
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
std::thread::spawn(move || {
|
||||
let src = job.src.clone().expect("origem");
|
||||
let Some(src) = job.source() else {
|
||||
let mut s = job.st.lock().unwrap();
|
||||
s.status = Status::Error;
|
||||
s.error = Some(no_source().to_string());
|
||||
return;
|
||||
};
|
||||
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
|
||||
let cancelled = || job.cancelled();
|
||||
let mut sink = JobSink(job.clone());
|
||||
@@ -845,7 +929,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
/// navegar por data, câmera e resolução.
|
||||
fn spawn_enrich(job: Arc<Job>) {
|
||||
std::thread::spawn(move || {
|
||||
let Some(src) = job.src.clone() else { return };
|
||||
let Some(src) = job.source() else { return };
|
||||
let todo: Vec<(u64, Found)> = {
|
||||
let s = job.st.lock().unwrap();
|
||||
s.found
|
||||
@@ -976,6 +1060,10 @@ impl FileSlice {
|
||||
"sandbox; default-src 'none'".into(),
|
||||
),
|
||||
("Access-Control-Allow-Origin", "*".into()),
|
||||
(
|
||||
"Access-Control-Expose-Headers",
|
||||
"Content-Range, Content-Length, Accept-Ranges".into(),
|
||||
),
|
||||
("Content-Disposition", "inline".into()),
|
||||
];
|
||||
if self.partial {
|
||||
@@ -1045,7 +1133,7 @@ impl Engine {
|
||||
id: u64,
|
||||
range: Option<&str>,
|
||||
) -> std::result::Result<FileSlice, u16> {
|
||||
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?;
|
||||
let (found, src) = self.open_file(job, id).map_err(status_of)?;
|
||||
let total = found.size;
|
||||
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
|
||||
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
|
||||
|
||||
@@ -29,8 +29,8 @@ pub fn config_dir() -> PathBuf {
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(home) = invoking_user_home() {
|
||||
return default_dir(home);
|
||||
if let Some(u) = crate::owner::invoking_user() {
|
||||
return default_dir(u.home);
|
||||
}
|
||||
}
|
||||
default_dir(PathBuf::from(
|
||||
@@ -69,25 +69,6 @@ fn default_dir(home: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Se somos root por sudo/pkexec, a home de quem invocou.
|
||||
#[cfg(unix)]
|
||||
fn invoking_user_home() -> Option<PathBuf> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||
let uid = std::env::var("PKEXEC_UID").ok();
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) {
|
||||
return Some(PathBuf::from(f[5]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn file() -> PathBuf {
|
||||
config_dir().join("settings.json")
|
||||
}
|
||||
@@ -100,19 +81,6 @@ pub fn load() -> Settings {
|
||||
}
|
||||
|
||||
pub fn save(s: &Settings) -> Result<()> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = file();
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(s)?)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar.
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
if let Ok(m) = std::fs::metadata(&dir) {
|
||||
let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid()));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,8 +219,10 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
// ---------------------------------------------------------------- PNG
|
||||
|
||||
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let total = src.size().saturating_sub(off);
|
||||
let mut p = 8u64;
|
||||
let mut first = true;
|
||||
let mut saw_idat = false;
|
||||
for _ in 0..200_000 {
|
||||
let c = read_vec(src, off + p, 8).ok()?;
|
||||
if c.len() < 8 {
|
||||
@@ -228,13 +230,20 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
||||
let ty = &c[4..8];
|
||||
if !ty.iter().all(|b| b.is_ascii_alphabetic()) || len > 0x7FFF_FFFF {
|
||||
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do
|
||||
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante.
|
||||
let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
|
||||
&& ty[2].is_ascii_uppercase()
|
||||
&& len <= 128 * 1024 * 1024
|
||||
&& p + 12 + len <= total;
|
||||
if !valid {
|
||||
break;
|
||||
}
|
||||
if first && ty != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
saw_idat |= ty == b"IDAT";
|
||||
p += 12 + len;
|
||||
if p > max {
|
||||
break;
|
||||
@@ -247,11 +256,12 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if first {
|
||||
// Sem IEND: só vale como imagem truncada se já tem dados e é de tamanho razoável.
|
||||
if first || !saw_idat || p > 64 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(Hit {
|
||||
len: p.min(max).min(src.size().saturating_sub(off)),
|
||||
len: p,
|
||||
ext: "png",
|
||||
confidence: Confidence::Low,
|
||||
})
|
||||
|
||||
@@ -302,3 +302,129 @@ fn configuracoes_de_tema_persistem() {
|
||||
.unwrap();
|
||||
assert_eq!(settings::load().theme, "dark");
|
||||
}
|
||||
|
||||
fn wait_done(eng: &service::Engine, job: &str) {
|
||||
for _ in 0..500 {
|
||||
if eng.status(job).unwrap()["status"] != "running" {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("job não terminou");
|
||||
}
|
||||
|
||||
/// Gera uma sessão no formato da v0.1.0 (sem os campos que vieram depois) a partir de uma
|
||||
/// varredura real da imagem, com o alvo informado.
|
||||
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
.unwrap();
|
||||
wait_done(&eng, &job);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
eng.save_session(&job, tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("recoveries");
|
||||
o.remove("started_unix");
|
||||
o.insert("target".into(), alvo.into());
|
||||
for f in o["found"].as_array_mut().unwrap() {
|
||||
let f = f.as_object_mut().unwrap();
|
||||
for k in ["created", "accessed", "taken", "camera", "px", "gps", "sig"] {
|
||||
f.remove(k);
|
||||
}
|
||||
}
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
|
||||
#[test]
|
||||
fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
wait_done(&eng, &job);
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["status"], "done");
|
||||
assert_eq!(st["found"], 7);
|
||||
assert_eq!(st["source"], true);
|
||||
let res = eng.results(&job, &service::Query::default()).unwrap();
|
||||
assert_eq!(res["total"], 7);
|
||||
// recuperar a partir da sessão antiga continua dando o mesmo conteúdo
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let rj = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
Some(service::Query::default()),
|
||||
out.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
let got = hashes_in(out.path());
|
||||
for (h, p) in expected(&dir, "ntfs") {
|
||||
assert!(got.contains_key(&h), "faltou {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
|
||||
#[test]
|
||||
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe");
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], false);
|
||||
assert_eq!(st["found"], 7);
|
||||
assert!(st["warnings"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("não está disponível"));
|
||||
// navegar e exportar funcionam
|
||||
assert_eq!(
|
||||
eng.results(&job, &service::Query::default()).unwrap()["total"],
|
||||
7
|
||||
);
|
||||
let csv = tempfile::NamedTempFile::new().unwrap();
|
||||
assert_eq!(
|
||||
eng.export_csv(&job, csv.path().to_str().unwrap(), None)
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
// pré-visualização, retomada e recuperação avisam com clareza
|
||||
assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
|
||||
assert!(eng
|
||||
.resume(&job)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("não está disponível"));
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let e = eng
|
||||
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("não está disponível"), "{e}");
|
||||
// reconecta
|
||||
eng.relink(&job, img.to_str().unwrap()).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], true);
|
||||
assert!(st["warnings"].as_array().unwrap().is_empty());
|
||||
let rj = eng
|
||||
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
assert_eq!(hashes_in(out.path()).len(), 7);
|
||||
// arquivo que não é sessão
|
||||
std::fs::write(tmp.path(), b"nao e json").unwrap();
|
||||
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
|
||||
assert!(
|
||||
e.to_string().contains("não é uma sessão do Guará válida"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
@@ -473,6 +473,7 @@ fn facetas_filtros_e_duplicadas() {
|
||||
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
|
||||
mk(4, "d.jpg", None, None, None, None),
|
||||
];
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
let f = explorer::facets(&found);
|
||||
assert_eq!(f["total"], 4);
|
||||
assert_eq!(f["years"][0]["year"], 2024);
|
||||
@@ -481,6 +482,8 @@ fn facetas_filtros_e_duplicadas() {
|
||||
assert_eq!(f["undated"], 1);
|
||||
assert_eq!(f["carved"], 1);
|
||||
assert_eq!(f["duplicates"], 1);
|
||||
assert_eq!(f["small"], 4); // todos têm 10 bytes
|
||||
assert_eq!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
|
||||
assert_eq!(f["cameras"][0]["name"], "Canon");
|
||||
let dcim = f["folders"]
|
||||
.as_array()
|
||||
@@ -490,7 +493,6 @@ fn facetas_filtros_e_duplicadas() {
|
||||
.unwrap();
|
||||
assert_eq!(dcim["count"], 2);
|
||||
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
|
||||
@@ -505,3 +507,95 @@ fn facetas_filtros_e_duplicadas() {
|
||||
assert_eq!(items[0].id, 2); // mais recente primeiro
|
||||
assert_eq!(items.last().unwrap().id, 4); // sem data por último
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ carving
|
||||
|
||||
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = (data.len() as u32).to_be_bytes().to_vec();
|
||||
c.extend(ty);
|
||||
c.extend(data);
|
||||
c.extend([0, 0, 0, 0]);
|
||||
c
|
||||
}
|
||||
|
||||
/// Regressão: texto ASCII depois de um PNG truncado vira "tamanho" de ~1,9 GB;
|
||||
/// o carving não pode inventar um PNG gigante.
|
||||
#[test]
|
||||
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
|
||||
let mut d = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
d.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
d.extend(png_chunk(b"IDAT", &vec![7u8; 5000]));
|
||||
let real_end = d.len();
|
||||
d.extend(b"ualiBase64 texto qualquer que segue no disco, como parte de outro arquivo html ");
|
||||
d.resize(4 * 1024 * 1024, 0x41);
|
||||
let src = MemSource(d.clone());
|
||||
let hit =
|
||||
signatures::detect(&src, 0, &d[..64], 8 << 30).expect("PNG truncado ainda é reconhecido");
|
||||
assert_eq!(
|
||||
hit.len as usize, real_end,
|
||||
"deve parar no fim do último bloco válido"
|
||||
);
|
||||
assert_eq!(hit.confidence, Confidence::Low);
|
||||
|
||||
// sem nenhum dado de imagem (só cabeçalho) não é arquivo
|
||||
let mut d2 = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
d2.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
d2.resize(1 << 20, b'e');
|
||||
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none());
|
||||
|
||||
// PNG completo continua com confiança alta e tamanho exato
|
||||
let mut ok = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
ok.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
ok.extend(png_chunk(b"IDAT", &[1; 100]));
|
||||
ok.extend(png_chunk(b"IEND", &[]));
|
||||
let n = ok.len();
|
||||
ok.resize(1 << 20, 0);
|
||||
let h = signatures::detect(&MemSource(ok.clone()), 0, &ok[..64], 8 << 30).unwrap();
|
||||
assert_eq!((h.len as usize, h.confidence), (n, Confidence::High));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ abrir no player do sistema
|
||||
|
||||
/// O vídeo é copiado para uma pasta temporária e entregue ao aplicativo padrão do sistema
|
||||
/// (aqui um `xdg-open`/`open` falso grava o que recebeu).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn abre_no_player_do_sistema() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let bin = tempfile::tempdir().unwrap();
|
||||
let marker = bin.path().join("recebido");
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
"xdg-open"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
let script = bin.path().join(name);
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\ncp \"$1\" '{}'\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let old = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
|
||||
let content: Vec<u8> = (0..5000u32).map(|i| (i * 7) as u8).collect();
|
||||
let mut f = resident("meu video.mp4", content.clone());
|
||||
f.id = 42;
|
||||
let path = external::open_found(&MemSource(vec![]), &f).unwrap();
|
||||
std::env::set_var("PATH", old);
|
||||
assert!(path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("42-"));
|
||||
for _ in 0..100 {
|
||||
if marker.exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
assert_eq!(std::fs::read(&marker).unwrap(), content);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), content);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
Reference in new issue
Block a user