//! Camada de serviço usada pela interface gráfica (Tauri e modo `guara ui`): //! jobs em segundo plano (varredura, recuperação, imagem) e despachante RPC. use crate::device; use crate::error::{Error, Result}; use crate::image; use crate::partition; use crate::recover; use crate::scan::{self, Checkpoint, Outcome, Progress, Sink}; use crate::source::{open_path, Source}; use crate::types::*; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Status { Running, Paused, Done, Stopped, Error, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum JobKind { Scan, Recover, Image, Search, Hash, } struct State { status: Status, progress: Progress, found: Vec, index: HashMap, warnings: Vec, layout: Option, checkpoint: Option, error: Option, report: Option, started: Instant, finished_in: Option, /// Leitura de datas EXIF/dimensões em andamento: (feitos, total). enrich: Option<(u64, u64)>, recoveries: Vec, started_unix: Option, hits: Vec, } pub struct Job { id: String, kind: JobKind, target: String, opts: ScanOptions, cancel: AtomicBool, stop_requested: AtomicBool, src: Mutex>>, /// Caminho atualmente usado para ler a origem (pode mudar se o disco voltar com outro nome). src_path: Mutex, /// Impressão digital do disco (reconhecer o mesmo disco depois de reconectar). fingerprint: Mutex>, /// Arquivo de sessão salvo automaticamente. session_file: Mutex>, st: Mutex, } impl Job { fn new( id: String, kind: JobKind, target: String, opts: ScanOptions, src: Option>, ) -> Arc { Arc::new(Job { id, kind, target: target.clone(), opts, cancel: AtomicBool::new(false), stop_requested: AtomicBool::new(false), src: Mutex::new(src), src_path: Mutex::new(target.clone()), fingerprint: Mutex::new(None), session_file: Mutex::new(None), st: Mutex::new(State { status: Status::Running, progress: Progress::default(), found: vec![], index: HashMap::new(), warnings: vec![], layout: None, checkpoint: None, error: None, report: None, started: Instant::now(), finished_in: None, enrich: None, recoveries: vec![], started_unix: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .ok() .map(|d| d.as_secs() as i64), hits: vec![], }), }) } fn source(&self) -> Option> { self.src.lock().unwrap().clone() } fn cancelled(&self) -> bool { self.cancel.load(Ordering::Relaxed) } } struct JobSink(Arc); impl Sink for JobSink { fn found(&mut self, f: Found) { let mut s = self.0.st.lock().unwrap(); let i = s.found.len(); s.index.insert(f.id, i); s.found.push(f); } fn confirm(&mut self, id: u64, c: Confidence) { let mut s = self.0.st.lock().unwrap(); if let Some(&i) = s.index.get(&id) { s.found[i].confidence = s.found[i].confidence.max(c); } } fn progress(&mut self, p: Progress) { self.0.st.lock().unwrap().progress = p; } fn warning(&mut self, w: String) { let mut s = self.0.st.lock().unwrap(); if !s.warnings.contains(&w) { s.warnings.push(w); } } fn layout(&mut self, l: &DiskLayout) { self.0.st.lock().unwrap().layout = Some(l.clone()); } } pub struct Engine { jobs: Mutex>>, counter: AtomicU64, thumbs: Mutex, gate: crate::explorer::Gate, } impl Default for Engine { fn default() -> Self { Self::new() } } impl Engine { pub fn new() -> Self { Self { jobs: Mutex::new(HashMap::new()), counter: AtomicU64::new(1), thumbs: Mutex::new(crate::explorer::ThumbCache::new()), gate: crate::explorer::Gate::new( std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(2) .min(6), ), } } fn job(&self, id: &str) -> Result> { self.jobs .lock() .unwrap() .get(id) .cloned() .ok_or_else(|| Error::msg(format!("job {id} não existe"))) } fn register( &self, kind: JobKind, target: String, opts: ScanOptions, src: Option>, ) -> Arc { let n = self.counter.fetch_add(1, Ordering::Relaxed); let id = format!("j{n}"); let job = Job::new(id.clone(), kind, target, opts, src); self.jobs.lock().unwrap().insert(id, job.clone()); job } // ---------------------------------------------------------------- varredura pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result { let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let fp = crate::source::fingerprint(src.as_ref()); let job = self.register(JobKind::Scan, path.to_string(), opts, Some(src)); *job.fingerprint.lock().unwrap() = fp; *job.session_file.lock().unwrap() = Some(new_session_path(path)); spawn_scan(job.clone(), None); Ok(job.id.clone()) } pub fn pause(&self, id: &str) -> Result<()> { let j = self.job(id)?; j.cancel.store(true, Ordering::Relaxed); Ok(()) } pub fn stop(&self, id: &str) -> Result<()> { let j = self.job(id)?; j.stop_requested.store(true, Ordering::Relaxed); j.cancel.store(true, Ordering::Relaxed); Ok(()) } pub fn resume(&self, id: &str) -> Result<()> { let j = self.job(id)?; let io_lost = { let s = j.st.lock().unwrap(); if s.status != Status::Paused { return Err(Error::msg("a varredura não está pausada")); } s.checkpoint.as_ref().is_some_and(|c| c.io_lost) }; // Sem origem, ou depois de uma desconexão: reabre o disco (mesmo com outro nome). if j.source().is_none() || io_lost { self.reconnect(&j).map_err(|e| { Error::msg(format!( "A origem desta sessão não está disponível ({e}). Reconecte o disco ou escolha a origem para continuar." )) })?; } let cp = { let mut s = j.st.lock().unwrap(); s.status = Status::Running; s.warnings .retain(|w| !w.starts_with("A leitura do dispositivo")); let mut cp = s.checkpoint.take(); if let Some(c) = cp.as_mut() { c.io_lost = false; } cp }; if j.session_file.lock().unwrap().is_none() { *j.session_file.lock().unwrap() = Some(new_session_path(&j.target)); } j.cancel.store(false, Ordering::Relaxed); j.stop_requested.store(false, Ordering::Relaxed); spawn_scan(j, cp); Ok(()) } pub fn status(&self, id: &str) -> Result { let j = self.job(id)?; let s = j.st.lock().unwrap(); let mut counts: HashMap<&'static str, usize> = HashMap::new(); let mut by_conf = [0usize; 3]; for f in &s.found { *counts.entry(cat_key(f.category)).or_default() += 1; by_conf[f.confidence as usize] += 1; } let elapsed = s .finished_in .unwrap_or_else(|| s.started.elapsed().as_secs_f64()); Ok(json!({ "id": j.id, "kind": j.kind, "target": j.target, "status": s.status, "progress": s.progress, "found": s.found.len(), "categories": counts, "confidence": { "low": by_conf[0], "medium": by_conf[1], "high": by_conf[2] }, "warnings": s.warnings, "layout": s.layout, "error": s.error, "report": s.report, "elapsed": elapsed, "source": j.source().is_some(), "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()), "hits": s.hits.len(), "enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })), })) } pub fn results(&self, id: &str, q: &Query) -> Result { let j = self.job(id)?; let s = j.st.lock().unwrap(); let mut items: Vec<&Found> = q.select(&s.found); crate::explorer::sort_items(&mut items, q.sort.as_deref()); let total = items.len(); let page: Vec = items .into_iter() .skip(q.offset) .take(q.limit.clamp(1, 5000)) .map(crate::explorer::item_json) .collect(); Ok(json!({ "total": total, "items": page })) } /// Contagens para a navegação lateral (tipos, datas, pastas, câmeras). pub fn facets(&self, id: &str) -> Result { let j = self.job(id)?; let s = j.st.lock().unwrap(); Ok(crate::explorer::facets(&s.found)) } /// Esquema de um banco SQLite achado, com o número de linhas de cada tabela. pub fn db_info(&self, id: &str, file: u64) -> Result { let (f, src) = self.open_file(id, file)?; let fs = recover::FoundSource { src: src.as_ref(), found: &f, }; let db = crate::sqlite::Db::open(&fs).map_err(Error::msg)?; let schema = db.schema(); let counts: Vec = schema.tables.iter().map(|t| db.count_rows(t)).collect(); let mut v = serde_json::to_value(&schema)?; for (t, c) in v["tables"].as_array_mut().unwrap().iter_mut().zip(counts) { t["rows"] = json!(c); } Ok(v) } /// Uma página de linhas de uma tabela (ou os registros apagados prováveis dela). pub fn db_rows( &self, id: &str, file: u64, table: &str, offset: usize, limit: usize, deleted: bool, ) -> Result { let (f, src) = self.open_file(id, file)?; let fs = recover::FoundSource { src: src.as_ref(), found: &f, }; let db = crate::sqlite::Db::open(&fs).map_err(Error::msg)?; let page = if deleted { let mut p = db.deleted_rows(table).map_err(Error::msg)?; p.rows = p .rows .into_iter() .skip(offset) .take(limit.clamp(1, 1000)) .collect(); p } else { db.rows(table, offset, limit.clamp(1, 1000)) .map_err(Error::msg)? }; Ok(serde_json::to_value(&page)?) } /// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo. pub fn metadata(&self, id: &str, file: u64) -> Result { let (f, src) = self.open_file(id, file)?; Ok(crate::meta::extract(src.as_ref(), &f)) } /// Miniatura JPEG (cache em memória; decodificações limitadas em paralelo). pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result>, u16> { let size = size.clamp(48, 640); let key = (job.to_string(), id, size); 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(status_of)?; if f.category != Category::Image { return Err(415); } let _permit = self.gate.acquire(); if let Some(v) = self.thumbs.lock().unwrap().get(&key) { return if v.is_empty() { Err(422) } else { Ok(v) }; } let out = Arc::new(crate::thumb::thumbnail(src.as_ref(), &f, size).unwrap_or_default()); self.thumbs.lock().unwrap().put(key, out.clone()); if out.is_empty() { Err(422) } else { Ok(out) } } /// Achado + origem para leitura posicional (streaming de pré-visualização). pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc)> { let j = self.job(id)?; let s = j.st.lock().unwrap(); let i = *s .index .get(&file) .ok_or_else(|| Error::msg("arquivo não encontrado"))?; Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?)) } // ---------------------------------------------------------------- recuperação pub fn start_recover( &self, scan_id: &str, ids: Option>, filter: Option, dest: &str, layout: recover::RecoverLayout, ) -> Result { let sj = self.job(scan_id)?; let src = sj.source().ok_or_else(no_source)?; let files: Vec = { let s = sj.st.lock().unwrap(); match (&ids, &filter) { (Some(ids), _) => ids .iter() .filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone())) .collect(), (None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(), (None, None) => s.found.clone(), } }; if files.is_empty() { return Err(Error::msg("nenhum arquivo selecionado")); } let dest_path = PathBuf::from(dest); let total: u64 = files.iter().map(|f| f.size).sum(); if let Some(free) = recover::free_space(&dest_path) { if total.saturating_add(64 * 1024 * 1024) > free { return Err(Error::msg(format!( "Espaço insuficiente no destino: os arquivos somam {} e há {} livres. Escolha outro disco ou recupere menos arquivos.", human_bytes(total), human_bytes(free) ))); } } recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?; let job = self.register( JobKind::Recover, dest.to_string(), ScanOptions::default(), None, ); let j2 = job.clone(); let parent = sj.clone(); let source_key = sj .fingerprint .lock() .unwrap() .clone() .unwrap_or_else(|| sj.target.clone()); std::thread::spawn(move || { let cancelled = || j2.cancelled(); let mut prog = |done: usize, total: usize, bytes: u64| { let mut s = j2.st.lock().unwrap(); s.progress = Progress { phase: format!("Recuperando ({bytes} bytes)"), done: done as u64, total: total as u64, }; }; let refs: Vec<&Found> = files.iter().collect(); let res = recover::recover_resumable( src.as_ref(), &refs, &dest_path, layout, &source_key, true, &cancelled, &mut prog, ); let mut s = j2.st.lock().unwrap(); match res { Ok(rep) => { s.report = serde_json::to_value(&rep).ok(); parent.st.lock().unwrap().recoveries.push(rep.clone()); if !rep.cancelled && s.started.elapsed().as_secs() >= 8 && crate::settings::load().notify { crate::notify::send( "Guará: recuperação concluída", &format!( "{} arquivo(s) salvos em {}", rep.recovered.len(), rep.destination ), ); } s.status = if rep.cancelled { Status::Stopped } else { Status::Done }; } Err(e) => { s.error = Some(e.to_string()); s.status = Status::Error; } } s.finished_in = Some(s.started.elapsed().as_secs_f64()); }); Ok(job.id.clone()) } // ---------------------------------------------------------------- imagem pub fn start_image(&self, path: &str, dest: &str) -> Result { let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; recover::check_destination( Some(Path::new(path)), Path::new(dest).parent().unwrap_or(Path::new(".")), )?; let job = self.register( JobKind::Image, dest.to_string(), ScanOptions::default(), None, ); let j2 = job.clone(); let dest = PathBuf::from(dest); std::thread::spawn(move || { let cancelled = || j2.cancelled(); let mut prog = |d: u64, t: u64| { j2.st.lock().unwrap().progress = Progress { phase: "Criando imagem do disco".into(), done: d, total: t, }; }; let res = image::create_image(src.as_ref(), &dest, &cancelled, &mut prog); let mut s = j2.st.lock().unwrap(); match res { Ok(rep) => { s.status = if rep.cancelled { Status::Stopped } else { Status::Done }; s.report = serde_json::to_value(&rep).ok(); } Err(e) => { s.error = Some(e.to_string()); s.status = Status::Error; } } s.finished_in = Some(s.started.elapsed().as_secs_f64()); }); Ok(job.id.clone()) } // ---------------------------------------------------------------- exportação e relatório /// CSV dos achados que atendem ao filtro (ou todos). Devolve quantas linhas gravou. pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result { let j = self.job(id)?; let s = j.st.lock().unwrap(); let items: Vec<&Found> = match q { Some(q) => q.select(&s.found), None => s.found.iter().collect(), }; crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?; Ok(items.len()) } /// Relatório HTML de recuperação (identificação do caso, origem, achados, hashes). pub fn export_report( &self, id: &str, path: &str, case: crate::report::CaseInfo, hashes: Option, ) -> Result<()> { let j = self.job(id)?; let s = j.st.lock().unwrap(); let elapsed = s .finished_in .unwrap_or_else(|| s.started.elapsed().as_secs_f64()); let html = crate::report::html(&crate::report::ReportInput { case: &case, target: &j.target, options: &j.opts, layout: s.layout.as_ref(), found: &s.found, warnings: &s.warnings, started_unix: s.started_unix, elapsed, recoveries: &s.recoveries, source_hashes: hashes.as_ref(), }); crate::owner::write_owned(Path::new(path), html)?; Ok(()) } // ---------------------------------------------------------------- busca de texto e hash da origem pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result { if opts.terms.iter().all(|t| t.trim().is_empty()) { return Err(Error::msg("informe pelo menos um termo")); } let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let job = self.register( JobKind::Search, path.to_string(), ScanOptions::default(), Some(src.clone()), ); let j2 = job.clone(); std::thread::spawn(move || { let layout = crate::partition::analyze(src.as_ref(), false); let cancelled = || j2.cancelled(); let mut prog = |d: u64, t: u64| { j2.st.lock().unwrap().progress = Progress { phase: "Procurando texto no disco".into(), done: d, total: t, }; }; let mut on_hit = |h: crate::search::Hit| { let part = layout .partitions .iter() .find(|p| h.offset >= p.start && h.offset < p.start + p.len) .map(|p| p.index + 1); let mut s = j2.st.lock().unwrap(); s.hits.push(json!({ "offset": h.offset, "term": h.term, "encoding": h.encoding, "context": h.context, "partition": part, })); true }; crate::search::search(src.as_ref(), &opts, &cancelled, &mut prog, &mut on_hit); let mut s = j2.st.lock().unwrap(); s.status = if j2.stop_requested.load(Ordering::Relaxed) { Status::Stopped } else { Status::Done }; s.finished_in = Some(s.started.elapsed().as_secs_f64()); }); Ok(job.id.clone()) } pub fn search_results(&self, id: &str, offset: usize, limit: usize) -> Result { let j = self.job(id)?; let s = j.st.lock().unwrap(); Ok(json!({ "total": s.hits.len(), "items": s.hits.iter().skip(offset).take(limit.clamp(1, 2000)).cloned().collect::>(), })) } /// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade). pub fn start_hash(&self, path: &str) -> Result { let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let job = self.register( JobKind::Hash, path.to_string(), ScanOptions::default(), None, ); let j2 = job.clone(); std::thread::spawn(move || { let size = src.size(); let mut h = crate::hashing::MultiHasher::new(); let mut buf = vec![0u8; 4 << 20]; let mut pos = 0u64; let mut err = None; while pos < size && !j2.cancelled() { let want = ((size - pos) as usize).min(buf.len()); match crate::source::read_exact_at(src.as_ref(), pos, &mut buf[..want]) { Ok(()) => { h.update(&buf[..want]); pos += want as u64; } Err(e) => { err = Some(format!("falha de leitura em {pos}: {e}. Para discos com defeito use a imagem forense.")); break; } } j2.st.lock().unwrap().progress = Progress { phase: "Calculando hashes da origem".into(), done: pos, total: size, }; } let mut s = j2.st.lock().unwrap(); if let Some(e) = err { s.error = Some(e); s.status = Status::Error; } else if pos < size { s.status = Status::Stopped; } else { s.report = serde_json::to_value(h.finalize()).ok(); s.status = Status::Done; } s.finished_in = Some(s.started.elapsed().as_secs_f64()); }); Ok(job.id.clone()) } // ---------------------------------------------------------------- sessões pub fn save_session(&self, id: &str, path: &str) -> Result<()> { let j = self.job(id)?; write_session(&j, Path::new(path)) } pub fn load_session(&self, path: &str) -> Result { 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}" )) })?; let job = self.register( JobKind::Scan, data.target.clone(), data.options.clone(), None, ); *job.fingerprint.lock().unwrap() = data.fingerprint.clone(); *job.src_path.lock().unwrap() = data .source_path .clone() .unwrap_or_else(|| data.target.clone()); { let mut s = job.st.lock().unwrap(); for (i, f) in data.found.iter().enumerate() { s.index.insert(f.id, i); } s.found = data.found; s.layout = data.layout; s.warnings = data.warnings; s.recoveries = data.recoveries; s.started_unix = data.started_unix.or(s.started_unix); s.checkpoint = data.checkpoint; s.status = if data.done { Status::Done } else { Status::Paused }; } // A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome); // sem a origem ainda dá para navegar, exportar e gerar relatório. match self.reconnect(&job) { Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!( "O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)." )), Ok(None) => {} Err(e) => job.st.lock().unwrap().warnings.push(format!( "A origem {} não está disponível agora ({e}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.", data.target )), } // continua salvando automaticamente no mesmo arquivo se a varredura for retomada *job.session_file.lock().unwrap() = Some(PathBuf::from(path)); if data.done { spawn_enrich(job.clone()); } Ok(job.id.clone()) } /// Reabre a origem da sessão: tenta o caminho atual e o original e, se o disco voltou com /// outro nome, procura entre os dispositivos conectados o que tem o mesmo conteúdo. /// `Ok(Some(caminho))` quando achou o disco num caminho diferente do esperado. fn reconnect(&self, j: &Arc) -> Result> { let fp = j.fingerprint.lock().unwrap().clone(); let size = j.st.lock() .unwrap() .layout .as_ref() .map(|l| l.size) .unwrap_or(0); let current = j.src_path.lock().unwrap().clone(); let mut tried = vec![current.clone()]; if j.target != current { tried.push(j.target.clone()); } let mut last_err = None; for path in &tried { match open_path(Path::new(path)) { Ok(src) if crate::reconnect::matches(src.as_ref(), size, fp.as_deref()) => { *j.src.lock().unwrap() = Some(src); *j.src_path.lock().unwrap() = path.clone(); return Ok(None); } Ok(_) => last_err = Some(format!("{path} agora é outro disco")), Err(e) => last_err = Some(open_error(path, e).to_string()), } } if let Some((path, src)) = crate::reconnect::find_matching_source(size, fp.as_deref(), &tried) { *j.src.lock().unwrap() = Some(src); *j.src_path.lock().unwrap() = path.clone(); return Ok(Some(path)); } Err(Error::msg( last_err.unwrap_or_else(|| "disco não encontrado".into()), )) } /// Aponta uma sessão carregada para a origem (disco ou imagem) atual. /// Recusa uma origem que não seja o mesmo disco, a menos que `force`. pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> { let j = self.job(id)?; let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let fp = j.fingerprint.lock().unwrap().clone(); let size = j.st.lock() .unwrap() .layout .as_ref() .map(|l| l.size) .unwrap_or(0); if size != 0 && src.size() != size && !force { 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(), size ))); } if !force && fp.is_some() && !crate::reconnect::matches(src.as_ref(), 0, fp.as_deref()) { return Err(Error::msg( "O conteúdo inicial dessa origem é diferente do disco da sessão (outra tabela de partições ou outro disco). Se tem certeza, use a opção de usar assim mesmo.", )); } if fp.is_none() { *j.fingerprint.lock().unwrap() = crate::source::fingerprint(src.as_ref()); } *j.src.lock().unwrap() = Some(src); *j.src_path.lock().unwrap() = path.to_string(); j.st.lock() .unwrap() .warnings .retain(|w| !w.starts_with("A origem ") && !w.starts_with("O disco foi reconhecido")); if j.st.lock().unwrap().status == Status::Done { spawn_enrich(j); } Ok(()) } /// Discos conectados que podem ser a origem desta sessão (mesmo tamanho primeiro). pub fn candidates(&self, id: &str) -> Result { let j = self.job(id)?; let fp = j.fingerprint.lock().unwrap().clone(); let size = j.st.lock() .unwrap() .layout .as_ref() .map(|l| l.size) .unwrap_or(0); let mut out: Vec = vec![]; for d in device::list_devices() { let same_size = size != 0 && d.size == size; let same_content = if same_size && d.readable { open_path(Path::new(&d.path)) .ok() .map(|s| crate::reconnect::matches(s.as_ref(), size, fp.as_deref())) } else { None }; out.push(json!({ "device": d, "same_size": same_size, "same_content": same_content })); } out.sort_by_key(|v| { std::cmp::Reverse(( v["same_content"].as_bool() == Some(true), v["same_size"].as_bool() == Some(true), )) }); Ok(json!(out)) } // ---------------------------------------------------------------- sessões automáticas /// Sessões salvas automaticamente (mais recentes primeiro). pub fn sessions_list(&self) -> Value { let dir = sessions_dir(); let mut items: Vec = vec![]; if let Ok(rd) = std::fs::read_dir(&dir) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); if let Some(base) = name.strip_suffix(".meta.json") { let sess = dir.join(format!("{base}.json")); if !sess.exists() { continue; } if let Ok(mut v) = std::fs::read(e.path()) .map_err(|_| ()) .and_then(|b| serde_json::from_slice::(&b).map_err(|_| ())) { v["path"] = json!(sess.to_string_lossy()); items.push(v); } } } } items.sort_by_key(|v| std::cmp::Reverse(v["saved_at"].as_i64().unwrap_or(0))); items.truncate(30); json!(items) } /// Apaga uma sessão automática (e a miniatura de metadados). pub fn session_delete(&self, path: &str) -> Result<()> { let p = Path::new(path); if p.parent() != Some(sessions_dir().as_path()) { return Err(Error::msg( "só sessões automáticas podem ser apagadas por aqui", )); } let _ = std::fs::remove_file(p.with_extension("meta.json")); std::fs::remove_file(p)?; Ok(()) } // ---------------------------------------------------------------- RPC pub fn rpc(&self, method: &str, p: Value) -> Result { let s = |k: &str| -> Result { p.get(k) .and_then(|v| v.as_str()) .map(String::from) .ok_or_else(|| Error::msg(format!("parâmetro '{k}' ausente"))) }; Ok(match method { "devices" => json!(device::list_devices()), "inspect" => { let path = s("path")?; let src = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?; json!(partition::analyze(src.as_ref(), false)) } "scan_start" => { let opts: ScanOptions = p .get("options") .cloned() .map(serde_json::from_value) .transpose()? .unwrap_or_default(); json!({ "job": self.start_scan(&s("path")?, opts)? }) } "scan_pause" => { self.pause(&s("job")?)?; json!({}) } "scan_resume" => { self.resume(&s("job")?)?; json!({}) } "scan_stop" | "job_stop" => { self.stop(&s("job")?)?; json!({}) } "status" => self.status(&s("job")?)?, "facets" => self.facets(&s("job")?)?, "health" => json!(crate::health::check(&s("path")?)), "open_url" => { let u = s("url")?; // só mapas do OpenStreetMap: evita abrir endereços arbitrários a partir de metadados if !u.starts_with("https://www.openstreetmap.org/") { return Err(Error::msg("endereço não permitido")); } crate::external::open_path(Path::new(&u))?; json!({}) } "db_info" => self.db_info( &s("job")?, p.get("id").and_then(|v| v.as_u64()).unwrap_or(0), )?, "db_rows" => self.db_rows( &s("job")?, p.get("id").and_then(|v| v.as_u64()).unwrap_or(0), &s("table")?, p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize, p.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize, p.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false), )?, "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 = p .get("query") .cloned() .filter(|v| !v.is_null()) .map(serde_json::from_value) .transpose()?; json!({ "rows": self.export_csv(&s("job")?, &s("path")?, q.as_ref())? }) } "export_report" => { let case: crate::report::CaseInfo = p .get("case") .cloned() .filter(|v| !v.is_null()) .map(serde_json::from_value) .transpose()? .unwrap_or_default(); let hashes: Option = p .get("hashes") .cloned() .filter(|v| !v.is_null()) .map(serde_json::from_value) .transpose()?; self.export_report(&s("job")?, &s("path")?, case, hashes)?; json!({}) } "search_start" => { let opts: crate::search::SearchOpts = serde_json::from_value(p.clone())?; json!({ "job": self.start_search(&s("path")?, opts)? }) } "search_results" => self.search_results( &s("job")?, p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize, p.get("limit").and_then(|v| v.as_u64()).unwrap_or(200) as usize, )?, "hash_start" => json!({ "job": self.start_hash(&s("path")?)? }), "metadata" => json!(self.metadata( &s("job")?, p.get("id") .and_then(|v| v.as_u64()) .ok_or_else(|| Error::msg("id ausente"))? )?), "results" => { let q: Query = p .get("query") .cloned() .map(serde_json::from_value) .transpose()? .unwrap_or_default(); self.results(&s("job")?, &q)? } "settings_get" => json!(crate::settings::load()), "settings_set" => { let st: crate::settings::Settings = serde_json::from_value(p.clone())?; crate::settings::save(&st)?; json!({}) } "recover_start" => { let ids: Option> = p .get("ids") .cloned() .filter(|v| !v.is_null()) .map(serde_json::from_value) .transpose()?; let filter: Option = p .get("filter") .cloned() .filter(|v| !v.is_null()) .map(serde_json::from_value) .transpose()?; let layout = p .get("layout") .and_then(|v| v.as_str()) .and_then(recover::RecoverLayout::parse) .unwrap_or_default(); json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? }) } "image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }), "session_save" => { self.save_session(&s("job")?, &s("path")?)?; json!({}) } "session_relink" => { let force = p.get("force").and_then(|v| v.as_bool()).unwrap_or(false); self.relink(&s("job")?, &s("path")?, force)?; json!({}) } "session_reconnect" => { let j = self.job(&s("job")?)?; json!({ "renamed": self.reconnect(&j)? }) } "session_candidates" => self.candidates(&s("job")?)?, "sessions_list" => self.sessions_list(), "session_delete" => { self.session_delete(&s("path")?)?; json!({}) } "session_load" => json!({ "job": self.load_session(&s("path")?)? }), "defaults" => { let home = crate::owner::user_home().to_string_lossy().to_string(); json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() }) } "version" => { json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS }) } other => return Err(Error::msg(format!("método desconhecido: {other}"))), }) } } pub use crate::explorer::Query; #[derive(Serialize, Deserialize)] struct SessionFile { version: u32, target: String, options: ScanOptions, found: Vec, checkpoint: Option, layout: Option, warnings: Vec, done: bool, #[serde(default)] recoveries: Vec, #[serde(default)] started_unix: Option, #[serde(default)] fingerprint: Option, #[serde(default)] source_path: Option, } 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!( "Sem permissão para ler {path}. Discos exigem privilégios de administrador: no Linux/macOS rode com sudo; no Windows, execute como administrador." )) } else { Error::msg(format!("Não foi possível abrir {path}: {e}")) } } fn spawn_scan(job: Arc, cp: Option) { // Salva a sessão sozinho enquanto a varredura roda (e ao terminar/pausar). let saver = job.clone(); std::thread::spawn(move || autosave_loop(saver)); std::thread::spawn(move || { 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 = job.st.lock().unwrap().found.clone(); let cancelled = || job.cancelled(); let mut sink = JobSink(job.clone()); let outcome = scan::run( src.as_ref(), &job.opts, cp, &existing, &cancelled, &mut sink, ); let mut s = job.st.lock().unwrap(); match outcome { Outcome::Completed => { s.status = Status::Done; s.checkpoint = None; if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify { crate::notify::send( "Guará: varredura concluída", &format!( "{} arquivo(s) encontrados em {}.", s.found.len(), job.target ), ); } } Outcome::Interrupted(cp) => { if job.stop_requested.load(Ordering::Relaxed) { s.status = Status::Stopped; } else { s.status = Status::Paused; if cp.io_lost { if crate::settings::load().notify { crate::notify::send("Guará: disco desconectado?", "A varredura foi pausada sem perder nada. Reconecte o disco e continue."); } s.warnings.push("A leitura do dispositivo falhou repetidamente: ele provavelmente foi desconectado. A varredura foi pausada sem perder nada. Reconecte o disco e clique em Continuar.".into()); } s.checkpoint = Some(cp); } } } s.finished_in = Some(s.started.elapsed().as_secs_f64()); let done = s.status == Status::Done; drop(s); if done { spawn_enrich(job); } }); } /// Lê EXIF/dimensões das imagens achadas (em segundo plano) para permitir /// navegar por data, câmera e resolução. fn spawn_enrich(job: Arc) { std::thread::spawn(move || { let Some(src) = job.source() else { return }; let todo: Vec<(u64, Found)> = { let s = job.st.lock().unwrap(); s.found .iter() .filter(|f| { f.sig.is_none() || (f.category == Category::Image && f.px.is_none() && f.taken.is_none()) }) .map(|f| (f.id, f.clone())) .collect() }; if todo.is_empty() { return; } let total = todo.len() as u64; for (i, (id, f)) in todo.into_iter().enumerate() { if job.cancelled() && job.stop_requested.load(Ordering::Relaxed) { break; } let sig = crate::explorer::quick_sig(src.as_ref(), &f); let m = (f.category == Category::Image && f.px.is_none() && f.taken.is_none()) .then(|| crate::meta::extract(src.as_ref(), &f)); let mut s = job.st.lock().unwrap(); if let Some(&k) = s.index.get(&id) { let t = &mut s.found[k]; t.sig = sig; if let Some(m) = m { t.taken = m.taken; t.camera = m.camera.clone(); t.px = m.px.or(t.px); t.gps = m.gps.is_some(); } } s.enrich = Some((i as u64 + 1, total)); } job.st.lock().unwrap().enrich = None; }); } pub fn base64(data: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(data.len().div_ceil(3) * 4); for c in data.chunks(3) { let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; out.push(T[(n >> 18) as usize & 63] as char); out.push(T[(n >> 12) as usize & 63] as char); out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' }); out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' }); } out } /// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar /// script (HTML/SVG/XML) saem como texto puro. pub fn mime_for(ext: &str) -> &'static str { match ext.to_ascii_lowercase().as_str() { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", "bmp" => "image/bmp", "avif" => "image/avif", "ico" => "image/x-icon", "mp4" | "m4v" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm", "mkv" => "video/x-matroska", "mp3" => "audio/mpeg", "wav" => "audio/wav", "ogg" | "opus" => "audio/ogg", "m4a" => "audio/mp4", "aac" => "audio/aac", "flac" => "audio/flac", "pdf" => "application/pdf", _ => "application/octet-stream", } } /// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP /// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri. pub struct FileSlice { found: Found, src: Arc, pub start: u64, /// Inclusivo. pub end: u64, pub total: u64, pub partial: bool, } impl FileSlice { pub fn len(&self) -> u64 { if self.total == 0 { 0 } else { self.end - self.start + 1 } } pub fn is_empty(&self) -> bool { self.len() == 0 } pub fn status(&self) -> u16 { if self.partial { 206 } else { 200 } } pub fn headers(&self) -> Vec<(&'static str, String)> { let mut h = vec![ ("Content-Type", mime_for(&self.found.ext).to_string()), ("Accept-Ranges", "bytes".into()), ("Cache-Control", "no-store".into()), ("X-Content-Type-Options", "nosniff".into()), // Conteúdo recuperado é não confiável: nunca executa script nem carrega nada. ( "Content-Security-Policy", "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 { h.push(( "Content-Range", format!("bytes {}-{}/{}", self.start, self.end, self.total), )); } h } /// Lê o trecho inteiro em memória (limitado a `cap` bytes). pub fn read_all(&self, cap: u64) -> std::io::Result> { let n = self.len().min(cap) as usize; let mut out = vec![0u8; n]; let mut got = 0; while got < n { let r = crate::recover::read_found_at( self.src.as_ref(), &self.found, self.start + got as u64, &mut out[got..], )?; if r == 0 { break; } got += r; } out.truncate(got); Ok(out) } pub fn reader(self) -> FileSliceReader { FileSliceReader { pos: self.start, slice: self, } } } pub struct FileSliceReader { slice: FileSlice, pos: u64, } impl std::io::Read for FileSliceReader { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { if self.pos > self.slice.end || self.slice.total == 0 { return Ok(0); } let left = (self.slice.end - self.pos + 1) as usize; let n = buf.len().min(left).min(1 << 20); let r = crate::recover::read_found_at( self.slice.src.as_ref(), &self.slice.found, self.pos, &mut buf[..n], )?; self.pos += r as u64; Ok(r) } } impl Engine { /// Resolve `/file//` + cabeçalho Range. `Err(status)` em caso de falha. pub fn file_slice( &self, job: &str, id: u64, range: Option<&str>, ) -> std::result::Result { 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=")) { let spec = spec.split(',').next().unwrap_or(""); if let Some((a, b)) = spec.split_once('-') { let parsed = match (a.trim().parse::(), b.trim().parse::()) { (Ok(a), Ok(b)) => Some((a, b.min(total.saturating_sub(1)))), (Ok(a), Err(_)) if b.trim().is_empty() => Some((a, total.saturating_sub(1))), (Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => { Some((total.saturating_sub(n), total.saturating_sub(1))) } _ => None, }; if let Some((a, b)) = parsed { if a >= total || a > b { return Err(416); } start = a; end = b; partial = true; } } } Ok(FileSlice { found, src, start, end, total, partial, }) } } // ---------------------------------------------------------------- sessões automáticas fn sessions_dir() -> PathBuf { crate::settings::config_dir().join("sessoes") } fn new_session_path(target: &str) -> PathBuf { let name: String = Path::new(target) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "disco".into()) .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect(); let t = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); sessions_dir().join(format!("{name}-{t}.json")) } fn snapshot(j: &Job) -> SessionFile { let s = j.st.lock().unwrap(); SessionFile { version: 1, target: j.target.clone(), options: j.opts.clone(), found: s.found.clone(), checkpoint: s.checkpoint.clone(), layout: s.layout.clone(), warnings: s.warnings.clone(), done: s.status == Status::Done, recoveries: s.recoveries.clone(), started_unix: s.started_unix, fingerprint: j.fingerprint.lock().unwrap().clone(), source_path: Some(j.src_path.lock().unwrap().clone()), } } /// Grava a sessão de forma atômica (arquivo temporário + renomear), com o dono certo. fn write_session(j: &Job, path: &Path) -> Result<()> { let data = snapshot(j); let tmp = path.with_extension("json.tmp"); crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?; std::fs::rename(&tmp, path)?; crate::owner::give_to_user(path); // metadados pequenos para listar as sessões recentes sem ler o arquivo inteiro let status = if data.done { "concluída" } else if data.checkpoint.is_some() { "pausada" } else { "incompleta" }; let meta = json!({ "target": data.target, "found": data.found.len(), "status": status, "saved_at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0), "bytes": std::fs::metadata(path).map(|m| m.len()).unwrap_or(0), }); let _ = crate::owner::write_owned( &path.with_extension("meta.json"), serde_json::to_vec(&meta)?, ); Ok(()) } /// Salva de tempos em tempos (o intervalo cresce se a sessão for grande) e no fim. fn autosave_loop(job: Arc) { let mut last = Instant::now(); let mut interval = std::time::Duration::from_secs(45); loop { std::thread::sleep(std::time::Duration::from_millis(500)); let running = job.st.lock().unwrap().status == Status::Running; if running && last.elapsed() < interval { continue; } let Some(path) = job.session_file.lock().unwrap().clone() else { return; }; let t0 = Instant::now(); if write_session(&job, &path).is_err() { return; } // nunca gasta mais de ~10% do tempo salvando interval = (t0.elapsed() * 10).max(std::time::Duration::from_secs(45)); last = Instant::now(); if !running { return; } } } fn human_bytes(n: u64) -> String { const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; let mut v = n as f64; let mut i = 0; while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; } if i == 0 { format!("{n} B") } else { format!("{v:.1} {}", U[i]) } }