Versão 0.5.0: bancos SQLite, saúde do disco, notificações, retomada, mapa GPS, inglês

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 20:01:31 -03:00
1 parent 4bb2397e3b
commit bbcb42ae6e
27 files changed
+2681 -41

No files matched your search

+273
View File
@@ -0,0 +1,273 @@
//! Saúde do disco antes de varrer: lê o SMART (via `smartctl`, quando instalado) e
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma
//! imagem primeiro: cada leitura extra pode piorar o estado dele.
use serde::Serialize;
use serde_json::Value;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Serialize, Default)]
pub struct Health {
/// Foi possível avaliar? (imagens de disco e dispositivos sem SMART ficam sem avaliação)
pub available: bool,
/// "ok", "warning", "failing" ou "unknown".
pub status: String,
pub summary: String,
pub details: Vec<(String, String)>,
pub advice: Vec<String>,
}
impl Health {
fn unknown(msg: &str) -> Self {
Health {
available: false,
status: "unknown".into(),
summary: msg.into(),
..Default::default()
}
}
}
const IMAGE_ADVICE: &str = "Crie a imagem do disco primeiro (botão \"Criar imagem\" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.";
/// Avalia o dispositivo `path`. Para arquivos comuns (imagens) devolve "sem avaliação".
pub fn check(path: &str) -> Health {
let p = std::path::Path::new(path);
#[cfg(unix)]
let is_dev = {
use std::os::unix::fs::FileTypeExt;
std::fs::metadata(p)
.map(|m| m.file_type().is_block_device() || m.file_type().is_char_device())
.unwrap_or(false)
};
#[cfg(not(unix))]
let is_dev = path.starts_with(r"\\.\");
if !is_dev {
return Health::unknown("Imagem de disco: não há SMART para avaliar.");
}
let mut h = match run_smartctl(path) {
Some(v) => parse_smartctl(&v),
None => Health::unknown("O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco."),
};
kernel_errors(path, &mut h);
h
}
fn smartctl_name(path: &str) -> String {
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
if let Some(n) = path
.strip_prefix(r"\\.\PhysicalDrive")
.and_then(|n| n.parse::<u8>().ok())
{
return format!("/dev/sd{}", (b'a' + n.min(25)) as char);
}
// macOS: /dev/rdiskN -> /dev/diskN
if let Some(rest) = path.strip_prefix("/dev/rdisk") {
return format!("/dev/disk{rest}");
}
path.to_string()
}
fn run_smartctl(path: &str) -> Option<Value> {
let dev = smartctl_name(path);
let out = Command::new("smartctl")
.args(["--json=o", "-H", "-A", "-i", "-l", "error", &dev])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
// smartctl usa bits do código de saída para avisos; o JSON vale mesmo assim
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
// sem permissão ou dispositivo não suportado
if v.pointer("/smartctl/messages")
.and_then(|m| m.as_array())
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error"))
&& v.get("smart_status").is_none()
&& v.get("ata_smart_attributes").is_none()
&& v.get("nvme_smart_health_information_log").is_none()
{
return None;
}
Some(v)
}
fn attr(v: &Value, id: u64) -> Option<i64> {
v.pointer("/ata_smart_attributes/table")?
.as_array()?
.iter()
.find(|a| a["id"] == id)?
.pointer("/raw/value")?
.as_i64()
}
/// Interpreta a saída JSON do `smartctl` (testável sem disco).
pub fn parse_smartctl(v: &Value) -> Health {
let mut h = Health {
available: true,
status: "ok".into(),
..Default::default()
};
let mut bad = 0u32; // 2 = falhando, 1 = atenção
let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
bad = bad.max(sev);
h.details.push((k.to_string(), val));
};
if let Some(m) = v["model_name"].as_str() {
h.details.push(("Modelo".into(), m.to_string()));
}
if let Some(s) = v["serial_number"].as_str() {
h.details.push(("Nº de série".into(), s.to_string()));
}
if let Some(hrs) = v.pointer("/power_on_time/hours").and_then(|x| x.as_i64()) {
h.details.push(("Horas ligado".into(), hrs.to_string()));
}
if let Some(t) = v.pointer("/temperature/current").and_then(|x| x.as_i64()) {
if t >= 60 {
note(&mut h, 1, "Temperatura", format!("{t} °C (alta)"));
} else {
h.details.push(("Temperatura".into(), format!("{t} °C")));
}
}
if v.pointer("/smart_status/passed") == Some(&Value::Bool(false)) {
note(
&mut h,
2,
"Autoavaliação SMART",
"REPROVADO: o próprio disco prevê falha".into(),
);
} else if v.pointer("/smart_status/passed") == Some(&Value::Bool(true)) {
h.details
.push(("Autoavaliação SMART".into(), "aprovado".into()));
}
// ATA (HD e SSD SATA)
if let Some(n) = attr(v, 5) {
if n > 0 {
note(
&mut h,
if n >= 50 { 2 } else { 1 },
"Setores realocados",
n.to_string(),
);
}
}
if let Some(n) = attr(v, 197) {
if n > 0 {
note(&mut h, 2, "Setores pendentes de realocação", n.to_string());
}
}
if let Some(n) = attr(v, 198) {
if n > 0 {
note(&mut h, 2, "Setores incorrigíveis offline", n.to_string());
}
}
if let Some(n) = attr(v, 187) {
if n > 0 {
note(&mut h, 1, "Erros incorrigíveis reportados", n.to_string());
}
}
if let Some(n) = attr(v, 199) {
if n > 0 {
note(
&mut h,
1,
"Erros de CRC na interface (cabo ou porta)",
n.to_string(),
);
}
}
if let Some(n) = v
.pointer("/ata_smart_error_log/summary/count")
.and_then(|x| x.as_i64())
{
if n > 0 {
note(
&mut h,
1,
"Erros registrados no log do disco",
n.to_string(),
);
}
}
// NVMe
if let Some(n) = v.get("nvme_smart_health_information_log") {
if n["critical_warning"].as_i64().unwrap_or(0) != 0 {
note(
&mut h,
2,
"Aviso crítico NVMe",
n["critical_warning"].to_string(),
);
}
if let Some(u) = n["percentage_used"].as_i64() {
if u >= 100 {
note(&mut h, 2, "Vida útil usada", format!("{u}%"));
} else if u >= 90 {
note(&mut h, 1, "Vida útil usada", format!("{u}%"));
} else {
h.details.push(("Vida útil usada".into(), format!("{u}%")));
}
}
if let Some(m) = n["media_errors"].as_i64() {
if m > 0 {
note(&mut h, 2, "Erros de mídia", m.to_string());
}
}
if let (Some(s), Some(t)) = (
n["available_spare"].as_i64(),
n["available_spare_threshold"].as_i64(),
) {
if s < t {
note(
&mut h,
2,
"Reserva de blocos abaixo do limite",
format!("{s}% (limite {t}%)"),
);
}
}
}
match bad {
2 => {
h.status = "failing".into();
h.summary = "Este disco mostra sinais sérios de defeito.".into();
h.advice.push(IMAGE_ADVICE.into());
}
1 => {
h.status = "warning".into();
h.summary = "Este disco mostra sinais de desgaste ou erros.".into();
h.advice.push(IMAGE_ADVICE.into());
}
_ => h.summary = "Nenhum sinal de defeito encontrado pelo SMART.".into(),
}
h
}
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
fn kernel_errors(path: &str, h: &mut Health) {
#[cfg(target_os = "linux")]
{
let name = path.trim_start_matches("/dev/");
let base = name.trim_end_matches(|c: char| c.is_ascii_digit());
for n in [name, base] {
if let Ok(s) = std::fs::read_to_string(format!("/sys/block/{n}/device/ioerr_cnt")) {
if let Ok(c) = u64::from_str_radix(s.trim().trim_start_matches("0x"), 16) {
if c > 0 {
h.details.push((
"Erros de leitura/gravação vistos pelo sistema".into(),
c.to_string(),
));
if h.status == "ok" || h.status == "unknown" {
h.available = true;
h.status = "warning".into();
h.summary = "O sistema já registrou erros de E/S neste disco.".into();
h.advice.push(IMAGE_ADVICE.into());
}
}
return;
}
}
}
}
#[cfg(not(target_os = "linux"))]
let _ = (path, h);
}
+3
View File
@@ -10,9 +10,11 @@ pub mod explorer;
pub mod external;
pub mod fs;
pub mod hashing;
pub mod health;
pub mod http;
pub mod image;
pub mod meta;
pub mod notify;
pub mod owner;
pub mod partition;
pub mod reconnect;
@@ -24,6 +26,7 @@ pub mod service;
pub mod settings;
pub mod signatures;
pub mod source;
pub mod sqlite;
pub mod thumb;
pub mod types;
+74
View File
@@ -0,0 +1,74 @@
//! Notificações do sistema (varredura concluída, disco desconectado...), usando os
//! comandos nativos de cada sistema, sem dependências extras.
use std::process::{Command, Stdio};
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
pub fn send(title: &str, body: &str) {
let (title, body) = (title.to_string(), body.to_string());
std::thread::spawn(move || {
if let Some(mut c) = command(&title, &body) {
if let Ok(mut child) = c
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
let _ = child.wait();
}
}
});
}
#[cfg(target_os = "linux")]
fn command(title: &str, body: &str) -> Option<Command> {
// Como root, notifica na sessão do usuário real.
let mut c = 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("notify-send");
c
} else {
Command::new("notify-send")
};
c.args(["-a", "Guará", "-i", "drive-harddisk", title, body]);
Some(c)
}
#[cfg(target_os = "macos")]
fn command(title: &str, body: &str) -> Option<Command> {
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
let mut c = Command::new("osascript");
c.args([
"-e",
&format!(
"display notification \"{}\" with title \"{}\"",
esc(body),
esc(title)
),
]);
Some(c)
}
#[cfg(windows)]
fn command(title: &str, body: &str) -> Option<Command> {
let esc = |s: &str| s.replace('\'', "''");
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(6000, '{}', '{}', [System.Windows.Forms.ToolTipIcon]::None); Start-Sleep -Seconds 7; $n.Dispose()",
esc(title),
esc(body)
);
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script]);
Some(c)
}
+114 -2
View File
@@ -33,6 +33,9 @@ pub struct RecoverReport {
pub failed: Vec<(u64, String)>,
pub total_bytes: u64,
pub cancelled: bool,
/// Arquivos já recuperados e conferidos numa execução anterior (pulados ao retomar).
#[serde(default)]
pub skipped: usize,
}
/// Recusa destinos que ficam no mesmo dispositivo da origem.
@@ -124,18 +127,98 @@ pub fn recover_with(
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
recover_resumable(src, files, dest, layout, "", false, cancelled, progress)
}
/// Uma linha do diário de recuperação (`guara-diario.jsonl`): cada arquivo gravado e conferido.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct JournalEntry {
source: String,
id: u64,
name: String,
file: RecoveredFile,
}
pub const JOURNAL: &str = "guara-diario.jsonl";
fn sha256_of_file(p: &Path) -> Option<String> {
use std::io::Read;
let mut f = std::fs::File::open(p).ok()?;
let mut h = MultiHasher::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = f.read(&mut buf).ok()?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Some(h.finalize().sha256)
}
/// Como `recover_with`, mas mantém um diário e, com `resume`, pula o que já foi
/// recuperado (e conferido por SHA-256) numa execução interrompida.
/// `source_key` identifica a origem para que o diário de outro disco não seja reaproveitado.
#[allow(clippy::too_many_arguments)]
pub fn recover_resumable(
src: &dyn Source,
files: &[&Found],
dest: &Path,
layout: RecoverLayout,
source_key: &str,
resume: bool,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
use std::io::Write;
crate::owner::create_dir_all_owned(dest)?;
let mut report = RecoverReport {
destination: dest.display().to_string(),
..Default::default()
};
let mut used: HashSet<PathBuf> = HashSet::new();
let journal_path = dest.join(JOURNAL);
let mut prior: std::collections::HashMap<u64, JournalEntry> = std::collections::HashMap::new();
if resume {
if let Ok(text) = std::fs::read_to_string(&journal_path) {
for line in text.lines() {
if let Ok(e) = serde_json::from_str::<JournalEntry>(line) {
if e.source == source_key {
prior.insert(e.id, e);
}
}
}
}
}
let mut journal = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&journal_path)
.ok();
crate::owner::give_to_user(&journal_path);
for (i, f) in files.iter().enumerate() {
if cancelled() {
report.cancelled = true;
break;
}
progress(i, files.len(), report.total_bytes);
// retomada: já está lá e confere?
if let Some(e) = prior.get(&f.id) {
let p = dest.join(&e.file.path);
if e.name == f.name && e.file.size == f.size && p.is_file() {
if std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0) == f.size
&& sha256_of_file(&p).as_deref() == Some(e.file.sha256.as_str())
{
used.insert(p);
report.skipped += 1;
report.total_bytes += f.size;
report.recovered.push(e.file.clone());
continue;
}
// incompleto ou alterado: refaz (é um arquivo nosso)
let _ = std::fs::remove_file(&p);
}
}
let rel = target_rel(f, layout);
let mut path = dest.join(&rel);
if let Some(parent) = path.parent() {
@@ -162,7 +245,7 @@ pub fn recover_with(
match write_file(src, f, &path) {
Ok(h) => {
report.total_bytes += f.size;
report.recovered.push(RecoveredFile {
let rec = RecoveredFile {
id: f.id,
path: path
.strip_prefix(dest)
@@ -176,7 +259,21 @@ pub fn recover_with(
confidence: f.confidence,
origin: f.origin,
partial: f.confidence == Confidence::Low,
});
};
if let Some(j) = journal.as_mut() {
let line = JournalEntry {
source: source_key.to_string(),
id: f.id,
name: f.name.clone(),
file: rec.clone(),
};
if let Ok(mut l) = serde_json::to_string(&line) {
l.push('\n');
let _ = j.write_all(l.as_bytes());
let _ = j.flush();
}
}
report.recovered.push(rec);
}
Err(e) => report.failed.push((f.id, e.to_string())),
}
@@ -347,3 +444,18 @@ pub fn read_found_at(
}
}
}
/// Um arquivo achado visto como origem de leitura posicional (para leitores de formato).
pub struct FoundSource<'a> {
pub src: &'a dyn Source,
pub found: &'a Found,
}
impl Source for FoundSource<'_> {
fn size(&self) -> u64 {
self.found.size
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<usize> {
read_found_at(self.src, self.found, offset, buf)
}
}
+106 -1
View File
@@ -320,6 +320,55 @@ impl Engine {
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<Value> {
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<u64> = 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<Value> {
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<crate::meta::Meta> {
let (f, src) = self.open_file(id, file)?;
@@ -407,6 +456,12 @@ impl Engine {
);
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| {
@@ -418,11 +473,13 @@ impl Engine {
};
};
let refs: Vec<&Found> = files.iter().collect();
let res = recover::recover_with(
let res = recover::recover_resumable(
src.as_ref(),
&refs,
&dest_path,
layout,
&source_key,
true,
&cancelled,
&mut prog,
);
@@ -431,6 +488,19 @@ impl Engine {
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 {
@@ -907,6 +977,28 @@ impl Engine {
}
"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")
@@ -1100,6 +1192,16 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
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) {
@@ -1107,6 +1209,9 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
} 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);
+22 -1
View File
@@ -8,13 +8,34 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// "auto", "light" ou "dark".
#[serde(default = "auto")]
pub theme: String,
/// Idioma da interface: "auto", "pt" ou "en".
#[serde(default = "auto")]
pub lang: String,
/// Notificação do sistema quando uma tarefa longa termina.
#[serde(default = "yes")]
pub notify: bool,
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
#[serde(default)]
pub maps: bool,
}
fn auto() -> String {
"auto".into()
}
fn yes() -> bool {
true
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: "auto".into(),
theme: auto(),
lang: auto(),
notify: true,
maps: false,
}
}
}
+900
View File
@@ -0,0 +1,900 @@
//! Leitor de bancos SQLite feito para arquivos recuperados.
//!
//! Lê as páginas B-tree diretamente (sem a biblioteca do SQLite), então funciona
//! em bancos danificados ou truncados, para os quais a biblioteca oficial desiste.
//! Também procura **registros apagados** nas áreas livres das páginas, onde o
//! SQLite deixa o conteúdo antigo até que outro dado o sobrescreva.
use crate::source::Source;
use serde::Serialize;
use std::collections::HashSet;
const MAGIC: &[u8; 16] = b"SQLite format 3\0";
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "t", content = "v", rename_all = "lowercase")]
pub enum Cell {
Null,
Int(i64),
Real(f64),
Text(String),
/// Tamanho e os primeiros bytes (hex) para pré-visualizar; `mime` se reconhecido.
Blob {
len: usize,
head: String,
mime: Option<&'static str>,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct DbColumn {
pub name: String,
pub decl_type: String,
pub pk: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbTable {
pub name: String,
pub rootpage: u32,
pub sql: String,
pub columns: Vec<DbColumn>,
pub without_rowid: bool,
pub virtual_table: bool,
/// Coluna `INTEGER PRIMARY KEY` (alias do rowid), se houver.
#[serde(skip)]
pub rowid_alias: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbSchema {
pub page_size: u32,
pub pages: u32,
pub file_pages: u64,
pub encoding: String,
pub tables: Vec<DbTable>,
pub indexes: Vec<String>,
pub views: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbRow {
pub rowid: Option<i64>,
pub cells: Vec<Cell>,
pub deleted: bool,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct RowsPage {
pub columns: Vec<String>,
pub rows: Vec<DbRow>,
pub total: u64,
pub warnings: Vec<String>,
}
pub struct Db<'a> {
src: &'a dyn Source,
ps: usize,
usable: usize,
pages: u32,
enc: u32,
}
type Res<T> = Result<T, String>;
// ------------------------------------------------------------------ utilidades
fn varint(b: &[u8]) -> Option<(i64, usize)> {
let mut v: u64 = 0;
for i in 0..9 {
let c = *b.get(i)?;
if i == 8 {
return Some((((v << 8) | c as u64) as i64, 9));
}
v = (v << 7) | (c & 0x7F) as u64;
if c & 0x80 == 0 {
return Some((v as i64, i + 1));
}
}
None
}
fn be16(b: &[u8], o: usize) -> Option<usize> {
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]) as usize)
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*b.get(o)?,
*b.get(o + 1)?,
*b.get(o + 2)?,
*b.get(o + 3)?,
]))
}
fn serial_len(t: i64) -> Option<usize> {
Some(match t {
0 | 8 | 9 => 0,
1 => 1,
2 => 2,
3 => 3,
4 => 4,
5 => 6,
6 | 7 => 8,
10 | 11 => return None,
n if n >= 12 => ((n - 12) / 2) as usize,
_ => return None,
})
}
fn blob_mime(b: &[u8]) -> Option<&'static str> {
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if b.starts_with(&[0x89, b'P', b'N', b'G']) {
Some("image/png")
} else if b.starts_with(b"GIF8") {
Some("image/gif")
} else if b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
fn decode_text(b: &[u8], enc: u32) -> String {
match enc {
2 => String::from_utf16_lossy(
&b.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
),
3 => String::from_utf16_lossy(
&b.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
),
_ => String::from_utf8_lossy(b).into_owned(),
}
}
/// Decodifica um registro (cabeçalho de tipos + corpo).
fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
let (hlen, n) = varint(p)?;
let hlen = hlen as usize;
if hlen < n || hlen > p.len() || hlen > 4096 {
return None;
}
let mut types = vec![];
let mut o = n;
while o < hlen {
let (t, m) = varint(&p[o..hlen])?;
types.push(t);
o += m;
}
let mut cells = Vec::with_capacity(types.len());
let mut o = hlen;
for t in types {
let l = serial_len(t)?;
let b = p.get(o..o + l)?;
o += l;
cells.push(match t {
0 => Cell::Null,
1..=6 => {
let mut v: i64 = if b[0] & 0x80 != 0 { -1 } else { 0 };
for &x in b {
v = (v << 8) | x as i64;
}
Cell::Int(v)
}
7 => Cell::Real(f64::from_be_bytes(b.try_into().ok()?)),
8 => Cell::Int(0),
9 => Cell::Int(1),
n if n % 2 == 1 => Cell::Text(decode_text(b, enc)),
_ => Cell::Blob {
len: b.len(),
head: b.iter().take(24).map(|x| format!("{x:02x}")).collect(),
mime: blob_mime(b),
},
});
}
Some(cells)
}
// ------------------------------------------------------------------ banco
impl<'a> Db<'a> {
pub fn open(src: &'a dyn Source) -> Res<Self> {
let mut h = [0u8; 100];
let n = src.read_at(0, &mut h).map_err(|e| e.to_string())?;
if n < 100 || &h[0..16] != MAGIC {
return Err("Não é um banco SQLite (cabeçalho ausente). Se o arquivo é um banco, ele pode estar criptografado (SQLCipher) ou ter o início sobrescrito.".into());
}
let ps = match u16::from_be_bytes([h[16], h[17]]) {
1 => 65536usize,
v if (512..=32768).contains(&v) && v.is_power_of_two() => v as usize,
_ => return Err("Tamanho de página inválido no cabeçalho do banco.".into()),
};
let reserved = h[20] as usize;
let mut pages = u32::from_be_bytes([h[28], h[29], h[30], h[31]]);
let file_pages = (src.size() / ps as u64) as u32;
if pages == 0 || pages > file_pages.max(1) * 4 + 1 {
pages = file_pages;
}
let enc = u32::from_be_bytes([h[56], h[57], h[58], h[59]]);
Ok(Self {
src,
ps,
usable: ps - reserved,
pages: pages.max(1),
enc: if (1..=3).contains(&enc) { enc } else { 1 },
})
}
fn page(&self, n: u32) -> Res<Vec<u8>> {
if n == 0 {
return Err("página 0 inválida".into());
}
let off = (n as u64 - 1) * self.ps as u64;
if off + self.ps as u64 > self.src.size() {
return Err(format!("página {n} fora do arquivo (banco truncado)"));
}
let mut b = vec![0u8; self.ps];
let mut got = 0;
while got < b.len() {
match self.src.read_at(off + got as u64, &mut b[got..]) {
Ok(0) => return Err(format!("página {n} incompleta")),
Ok(k) => got += k,
Err(e) => return Err(e.to_string()),
}
}
Ok(b)
}
fn enc_name(&self) -> &'static str {
match self.enc {
2 => "UTF-16 LE",
3 => "UTF-16 BE",
_ => "UTF-8",
}
}
/// Lê o payload completo de uma célula (segue as páginas de overflow).
fn payload(
&self,
page: &[u8],
cell_off: usize,
p: usize,
index: bool,
hdr: usize,
) -> Option<Vec<u8>> {
let u = self.usable;
let x = if index {
((u - 12) * 64 / 255).saturating_sub(23)
} else {
u - 35
};
let m = ((u - 12) * 32 / 255).saturating_sub(23);
let local = if p <= x {
p
} else {
let k = m + (p - m) % (u - 4);
if k <= x {
k
} else {
m
}
};
let start = cell_off + hdr;
let mut out = page.get(start..start + local)?.to_vec();
if p > local {
let mut next = be32(page, start + local)?;
let mut guard = 0;
while out.len() < p && next != 0 && guard < 100_000 {
guard += 1;
let ov = self.page(next).ok()?;
next = be32(&ov, 0)?;
let take = (p - out.len()).min(u - 4);
out.extend_from_slice(ov.get(4..4 + take)?);
}
if out.len() < p {
return None;
}
}
Some(out)
}
/// Percorre as folhas de uma árvore de tabela chamando `f(rowid, payload)`.
/// `f` devolve false para parar. Devolve os avisos de corrupção encontrados.
fn walk_table(&self, root: u32, f: &mut dyn FnMut(i64, &[u8]) -> bool) -> Vec<String> {
let mut warnings = vec![];
let mut stack = vec![(root, 0u32)];
let mut seen = HashSet::new();
while let Some((pn, depth)) = stack.pop() {
if depth > 40 || !seen.insert(pn) {
continue;
}
let pg = match self.page(pn) {
Ok(p) => p,
Err(e) => {
if warnings.len() < 5 {
warnings.push(e);
}
continue;
}
};
let hb = if pn == 1 { 100 } else { 0 };
let Some(&ty) = pg.get(hb) else { continue };
let ncell = be16(&pg, hb + 3).unwrap_or(0);
match ty {
0x05 => {
let mut kids = vec![];
for i in 0..ncell {
let Some(co) = be16(&pg, hb + 12 + i * 2) else {
break;
};
if let Some(c) = be32(&pg, co) {
kids.push(c);
}
}
if let Some(r) = be32(&pg, hb + 8) {
kids.push(r);
}
for k in kids.into_iter().rev() {
stack.push((k, depth + 1));
}
}
0x0D => {
for i in 0..ncell {
let Some(co) = be16(&pg, hb + 8 + i * 2) else {
break;
};
let Some(tail) = pg.get(co..) else { continue };
let Some((plen, a)) = varint(tail) else {
continue;
};
let Some((rowid, b)) = varint(&tail[a..]) else {
continue;
};
if plen < 0 || plen as usize > 1 << 28 {
continue;
}
if let Some(pl) = self.payload(&pg, co, plen as usize, false, a + b) {
if !f(rowid, &pl) {
return warnings;
}
} else if warnings.len() < 5 {
warnings.push(format!("célula ilegível na página {pn}"));
}
}
}
_ => {
if warnings.len() < 5 {
warnings.push(format!("página {pn} de tipo inesperado (0x{ty:02x})"));
}
}
}
}
warnings
}
fn leaf_pages(&self, root: u32) -> Vec<u32> {
let mut leaves = vec![];
let mut stack = vec![(root, 0u32)];
let mut seen = HashSet::new();
while let Some((pn, depth)) = stack.pop() {
if depth > 40 || !seen.insert(pn) {
continue;
}
let Ok(pg) = self.page(pn) else { continue };
let hb = if pn == 1 { 100 } else { 0 };
match pg.get(hb) {
Some(0x05) => {
let n = be16(&pg, hb + 3).unwrap_or(0);
for i in 0..n {
if let Some(c) = be16(&pg, hb + 12 + i * 2).and_then(|co| be32(&pg, co)) {
stack.push((c, depth + 1));
}
}
if let Some(r) = be32(&pg, hb + 8) {
stack.push((r, depth + 1));
}
}
Some(0x0D) => leaves.push(pn),
_ => {}
}
}
leaves
}
// ---------------------------------------------------------- esquema
pub fn schema(&self) -> DbSchema {
let mut s = DbSchema {
page_size: self.ps as u32,
pages: self.pages,
file_pages: self.src.size() / self.ps as u64,
encoding: self.enc_name().into(),
tables: vec![],
indexes: vec![],
views: vec![],
warnings: vec![],
};
if s.file_pages < self.pages as u64 {
s.warnings.push(format!(
"Arquivo truncado: o cabeçalho indica {} páginas, mas só há {}. Parte dos dados pode estar faltando.",
self.pages, s.file_pages
));
}
let mut rows: Vec<Vec<Cell>> = vec![];
let w = self.walk_table(1, &mut |_, p| {
if let Some(c) = decode_record(p, self.enc) {
rows.push(c);
}
true
});
s.warnings.extend(w);
let text = |c: &Cell| {
if let Cell::Text(t) = c {
t.clone()
} else {
String::new()
}
};
for r in rows {
if r.len() < 5 {
continue;
}
let (ty, name, rootpage, sql) = (
text(&r[0]),
text(&r[1]),
if let Cell::Int(i) = r[3] { i as u32 } else { 0 },
text(&r[4]),
);
match ty.as_str() {
"table" => {
let (columns, rowid_alias, without_rowid) = parse_create(&sql);
s.tables.push(DbTable {
virtual_table: sql.to_uppercase().starts_with("CREATE VIRTUAL"),
name,
rootpage,
sql,
columns,
without_rowid,
rowid_alias,
});
}
"index" => s.indexes.push(name),
"view" => s.views.push(name),
_ => {}
}
}
if s.tables.is_empty() {
s.warnings.push(
"Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada."
.into(),
);
}
s
}
fn table(&self, name: &str) -> Res<DbTable> {
self.schema()
.tables
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| format!("tabela {name} não encontrada"))
}
/// Conta as linhas da tabela (sem decodificar o conteúdo).
pub fn count_rows(&self, t: &DbTable) -> u64 {
if t.rootpage == 0 || t.virtual_table || t.without_rowid {
return 0;
}
let mut n = 0u64;
for leaf in self.leaf_pages(t.rootpage) {
if let Ok(pg) = self.page(leaf) {
n += be16(&pg, if leaf == 1 { 103 } else { 3 }).unwrap_or(0) as u64;
}
}
n
}
// ---------------------------------------------------------- linhas
pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> {
let t = self.table(table)?;
let mut page = RowsPage {
columns: t.columns.iter().map(|c| c.name.clone()).collect(),
..Default::default()
};
if t.virtual_table || t.rootpage == 0 {
page.warnings
.push("Tabela virtual: o conteúdo não fica armazenado como linhas comuns.".into());
return Ok(page);
}
if t.without_rowid {
page.warnings.push(
"Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida."
.into(),
);
return Ok(page);
}
let mut seen = 0usize;
let ncols = t.columns.len();
page.warnings = self.walk_table(t.rootpage, &mut |rowid, p| {
seen += 1;
if seen <= offset {
return true;
}
if page.rows.len() >= limit {
return false;
}
if let Some(mut cells) = decode_record(p, self.enc) {
if let Some(a) = t.rowid_alias {
if a < cells.len() && matches!(cells[a], Cell::Null) {
cells[a] = Cell::Int(rowid);
}
}
cells.resize(cells.len().max(ncols), Cell::Null);
apply_affinity(&mut cells, &t.columns);
page.rows.push(DbRow {
rowid: Some(rowid),
cells,
deleted: false,
});
}
true
});
page.total = self.count_rows(&t);
Ok(page)
}
// ---------------------------------------------------------- registros apagados
/// Procura registros apagados nas áreas livres das páginas da tabela.
/// São "prováveis": o SQLite não marca o que foi apagado, então validamos pela forma
/// do registro (mesmo número de colunas, tipos e textos coerentes).
pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> {
let t = self.table(table)?;
let n = t.columns.len();
let mut out = RowsPage {
columns: t.columns.iter().map(|c| c.name.clone()).collect(),
..Default::default()
};
if n < 2 || t.rootpage == 0 || t.virtual_table || t.without_rowid {
return Ok(out);
}
for leaf in self.leaf_pages(t.rootpage) {
let Ok(pg) = self.page(leaf) else { continue };
let hb = if leaf == 1 { 100 } else { 0 };
let ncell = be16(&pg, hb + 3).unwrap_or(0);
let content = match be16(&pg, hb + 5) {
Some(0) => 65536,
Some(c) => c,
None => continue,
};
let ptr_end = hb + 8 + ncell * 2;
let mut regions: Vec<(usize, usize)> = vec![];
if content > ptr_end {
regions.push((ptr_end, content));
}
// cadeia de blocos livres
let mut fb = be16(&pg, hb + 1).unwrap_or(0);
let mut guard = 0;
while fb != 0 && guard < 2000 {
guard += 1;
let (Some(next), Some(size)) = (be16(&pg, fb), be16(&pg, fb + 2)) else {
break;
};
if fb + size <= self.usable && size >= 4 {
regions.push((fb, fb + size));
}
fb = next;
}
for (a, b) in regions {
let end = b.min(pg.len()).min(self.usable);
if a >= end {
continue;
}
let mut p = a;
while p + 3 < end {
if let Some((mut cells, used)) = self.try_deleted(&pg[p..end], &t) {
apply_affinity(&mut cells, &t.columns);
out.rows.push(DbRow {
rowid: None,
cells,
deleted: true,
});
p += used.max(1);
} else {
p += 1;
}
}
}
if out.rows.len() >= 5000 {
break;
}
}
out.total = out.rows.len() as u64;
Ok(out)
}
/// Tenta ler um registro apagado no início de `b`, em três formas:
/// (a) cabeçalho íntegro; (b) tamanho do cabeçalho perdido (o SQLite sobrescreve os 4
/// primeiros bytes do bloco livre); (c) como (b), mas sem o primeiro tipo (coluna que é
/// alias do rowid, sempre NULL).
fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> {
let n = t.columns.len();
// (a)
if let Some(r) = self.record_with_types(b, 1, n, None, t) {
return Some(r);
}
// (b)
if let Some(r) = self.record_with_types(b, 0, n, None, t) {
return Some(r);
}
// (c)
if t.rowid_alias == Some(0) {
return self.record_with_types(b, 0, n - 1, Some(0), t);
}
None
}
/// Lê `ntypes` tipos seriais a partir de `b[start..]` (com um tipo inicial opcional já
/// conhecido) e o corpo logo depois do cabeçalho.
fn record_with_types(
&self,
b: &[u8],
start: usize,
ntypes: usize,
first: Option<i64>,
t: &DbTable,
) -> Option<(Vec<Cell>, usize)> {
let n = t.columns.len();
let mut types: Vec<i64> = first.into_iter().collect();
let mut o = start;
let mut hdr_bytes = 1usize; // o próprio byte do tamanho do cabeçalho
for _ in 0..ntypes {
let (ty, m) = varint(b.get(o..)?)?;
types.push(ty);
o += m;
hdr_bytes += m;
}
if first.is_some() {
hdr_bytes += 1;
}
if start == 1 {
// forma (a): o primeiro byte declara o tamanho do cabeçalho e precisa bater
if *b.first()? as usize != o {
return None;
}
}
if types.len() != n {
return None;
}
for (ty, col) in types.iter().zip(&t.columns) {
if !type_ok(&col.decl_type, *ty) {
return None;
}
}
let body: usize = types
.iter()
.map(|&x| serial_len(x))
.sum::<Option<usize>>()?;
if body == 0 || o + body > b.len() {
return None;
}
// monta um registro canônico para reaproveitar o decodificador
let mut rec = vec![];
let mut hdr = vec![];
for &ty in &types {
put_varint(&mut hdr, ty);
}
put_varint(&mut rec, (hdr.len() + 1) as i64);
rec.extend(hdr);
rec.extend_from_slice(&b[o..o + body]);
let cells = decode_record(&rec, self.enc)?;
let mut texts = 0;
for c in &cells {
if let Cell::Text(s) = c {
if s.contains('\u{FFFD}')
|| s.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
{
return None;
}
texts += 1;
}
}
let non_null = cells.iter().filter(|c| !matches!(c, Cell::Null)).count();
if non_null < 2 || (texts == 0 && n < 3) {
return None;
}
let _ = hdr_bytes;
Some((cells, o + body))
}
}
/// O tipo serial é compatível com o tipo declarado da coluna (afinidade do SQLite)?
fn type_ok(decl: &str, ty: i64) -> bool {
if ty == 10 || ty == 11 || ty < 0 {
return false;
}
if ty == 0 {
return true;
}
let d = decl.to_uppercase();
let is_text = ty >= 13 && ty % 2 == 1;
let is_blob = ty >= 12 && ty % 2 == 0;
if d.contains("INT") {
ty <= 9 || is_text
} else if d.contains("CHAR") || d.contains("CLOB") || d.contains("TEXT") {
is_text || is_blob || ty <= 9
} else if d.contains("REAL") || d.contains("FLOA") || d.contains("DOUB") {
ty <= 9
} else {
true
}
}
fn put_varint(out: &mut Vec<u8>, v: i64) {
let v = v as u64;
if v <= 0x7F {
out.push(v as u8);
return;
}
let mut tmp = vec![];
let mut x = v;
while x > 0 {
tmp.push((x & 0x7F) as u8);
x >>= 7;
}
tmp.reverse();
let last = tmp.len() - 1;
for (i, b) in tmp.iter().enumerate() {
out.push(if i == last { *b } else { *b | 0x80 });
}
}
/// O SQLite guarda REAIS inteiros como inteiros; devolve-os como REAL quando a coluna é REAL.
fn apply_affinity(cells: &mut [Cell], cols: &[DbColumn]) {
for (c, col) in cells.iter_mut().zip(cols) {
let d = col.decl_type.to_uppercase();
if d.contains("REAL") || d.contains("FLOA") || d.contains("DOUB") {
if let Cell::Int(i) = c {
*c = Cell::Real(*i as f64);
}
}
}
}
// ------------------------------------------------------------------ CREATE TABLE
fn unquote(s: &str) -> String {
let t = s.trim();
let b = t.as_bytes();
if b.len() >= 2
&& matches!(
(b[0], b[b.len() - 1]),
(b'"', b'"') | (b'`', b'`') | (b'[', b']') | (b'\'', b'\'')
)
{
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}
/// Extrai colunas, alias de rowid e WITHOUT ROWID de um `CREATE TABLE`.
fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
let upper = sql.to_uppercase();
let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID");
let Some(open) = sql.find('(') else {
return (vec![], None, without_rowid);
};
// fecha o parêntese correspondente
let mut depth = 0;
let mut close = sql.len();
let mut in_q: Option<char> = None;
for (i, ch) in sql.char_indices().skip_while(|(i, _)| *i < open) {
match (in_q, ch) {
(Some(q), c) if c == q => in_q = None,
(Some(_), _) => {}
(None, '"' | '\'' | '`') => in_q = Some(ch),
(None, '(') => depth += 1,
(None, ')') => {
depth -= 1;
if depth == 0 {
close = i;
break;
}
}
_ => {}
}
}
let body = &sql[open + 1..close.min(sql.len())];
// separa por vírgulas de nível 0
let mut parts = vec![];
let (mut d, mut start, mut q) = (0, 0, None::<char>);
for (i, ch) in body.char_indices() {
match (q, ch) {
(Some(c), x) if c == x => q = None,
(Some(_), _) => {}
(None, '"' | '\'' | '`') => q = Some(ch),
(None, '(') => d += 1,
(None, ')') => d -= 1,
(None, ',') if d == 0 => {
parts.push(&body[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&body[start..]);
let mut cols = vec![];
let mut alias = None;
for part in parts {
let p = part.trim();
let up = p.to_uppercase();
if ["PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "CONSTRAINT"]
.iter()
.any(|k| up.starts_with(k))
{
continue;
}
let (name, rest) = if p.starts_with(['"', '`', '[']) {
let close = match p.as_bytes()[0] {
b'[' => ']',
b'`' => '`',
_ => '"',
};
let end = p[1..].find(close).map(|e| e + 2).unwrap_or(p.len());
(unquote(&p[..end]), p[end..].trim())
} else {
match p.split_once(char::is_whitespace) {
Some((a, b)) => (a.to_string(), b.trim()),
None => (p.to_string(), ""),
}
};
if name.is_empty() {
continue;
}
let ru = rest.to_uppercase();
let decl: String = rest
.split_whitespace()
.take_while(|w| {
![
"PRIMARY",
"NOT",
"NULL",
"UNIQUE",
"DEFAULT",
"CHECK",
"REFERENCES",
"COLLATE",
"CONSTRAINT",
"GENERATED",
"AS",
]
.contains(&w.to_uppercase().as_str())
})
.collect::<Vec<_>>()
.join(" ");
let pk = ru.contains("PRIMARY KEY");
if pk && decl.eq_ignore_ascii_case("INTEGER") && !ru.contains("DESC") {
alias = Some(cols.len());
}
cols.push(DbColumn {
name,
decl_type: decl,
pk,
});
}
(cols, alias, without_rowid)
}
/// O arquivo começa com o cabeçalho de um banco SQLite?
pub fn is_sqlite(head: &[u8]) -> bool {
head.len() >= 16 && &head[0..16] == MAGIC
}
+3 -1
View File
@@ -111,7 +111,9 @@ pub fn hashes_in(dir: &Path) -> HashMap<String, PathBuf> {
let p = e.path();
if p.is_dir() {
walk(&p, m);
} else if p.file_name().unwrap() != "guara-manifesto.json" {
} else if !["guara-manifesto.json", "guara-diario.jsonl"]
.contains(&p.file_name().unwrap().to_str().unwrap())
{
m.insert(sha256_file(&p), p);
}
}
Binary file not shown.
+1
View File
@@ -300,6 +300,7 @@ fn configuracoes_de_tema_persistem() {
assert_eq!(settings::load().theme, "auto");
settings::save(&settings::Settings {
theme: "dark".into(),
..Default::default()
})
.unwrap();
assert_eq!(settings::load().theme, "dark");
+223
View File
@@ -0,0 +1,223 @@
//! Saúde do disco, notificações, configurações e retomada da recuperação.
mod common;
use common::*;
use guara_core::*;
use serde_json::json;
// ------------------------------------------------------------------ saúde (SMART)
#[test]
fn smart_reprovado_e_setores_ruins_viram_alerta_serio() {
let v = json!({
"model_name": "WDC WD5000", "serial_number": "ABC123",
"power_on_time": {"hours": 43000}, "temperature": {"current": 38},
"smart_status": {"passed": false},
"ata_smart_attributes": {"table": [
{"id": 5, "raw": {"value": 120}}, {"id": 197, "raw": {"value": 3}}, {"id": 199, "raw": {"value": 0}}
]}
});
let h = health::parse_smartctl(&v);
assert!(h.available);
assert_eq!(h.status, "failing");
assert!(h
.details
.iter()
.any(|(k, v)| k == "Setores realocados" && v == "120"));
assert!(h
.details
.iter()
.any(|(k, _)| k == "Setores pendentes de realocação"));
assert!(
h.advice.iter().any(|a| a.contains("imagem")),
"deve recomendar a imagem primeiro"
);
}
#[test]
fn smart_saudavel_nvme_desgastado_e_nvme_critico() {
let ok = json!({"smart_status": {"passed": true}, "ata_smart_attributes": {"table": [{"id": 5, "raw": {"value": 0}}, {"id": 197, "raw": {"value": 0}}]}});
let h = health::parse_smartctl(&ok);
assert_eq!(h.status, "ok");
assert!(h.advice.is_empty());
let worn = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 93, "media_errors": 0, "available_spare": 80, "available_spare_threshold": 10}});
assert_eq!(health::parse_smartctl(&worn).status, "warning");
let crit = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 4, "percentage_used": 10, "media_errors": 0}});
assert_eq!(health::parse_smartctl(&crit).status, "failing");
let media = json!({"nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 10, "media_errors": 7}});
assert_eq!(health::parse_smartctl(&media).status, "failing");
}
#[test]
fn imagem_de_disco_nao_tem_smart() {
let f = tempfile::NamedTempFile::new().unwrap();
let h = health::check(f.path().to_str().unwrap());
assert!(!h.available);
assert_eq!(h.status, "unknown");
}
// ------------------------------------------------------------------ configurações
#[test]
fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
init();
// arquivo gravado pela v0.3: só tinha o tema
std::fs::create_dir_all(settings::config_dir()).unwrap();
std::fs::write(
settings::config_dir().join("settings.json"),
br#"{"theme":"dark"}"#,
)
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("dark", "auto", true, false)
);
settings::save(&settings::Settings {
theme: "light".into(),
lang: "en".into(),
notify: false,
maps: true,
})
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("light", "en", false, true)
);
}
// ------------------------------------------------------------------ notificações
#[cfg(target_os = "linux")]
#[test]
fn notificacao_chama_o_notify_send() {
use std::os::unix::fs::PermissionsExt;
let bin = tempfile::tempdir().unwrap();
let marker = bin.path().join("recebido");
let script = bin.path().join("notify-send");
std::fs::write(
&script,
format!("#!/bin/sh\nprintf '%s|' \"$@\" > '{}'\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()));
notify::send("Guará: varredura concluída", "7 arquivos");
for _ in 0..100 {
if marker.exists() && !std::fs::read(&marker).unwrap().is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(30));
}
std::env::set_var("PATH", old);
let got = String::from_utf8(std::fs::read(&marker).unwrap()).unwrap();
assert!(
got.contains("Guará: varredura concluída") && got.contains("7 arquivos"),
"{got}"
);
}
// ------------------------------------------------------------------ retomar recuperação
#[test]
fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
let Some(dir) = fixtures() else { return };
let img = dir.join("fat32.img");
let res = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
);
assert_eq!(res.found.len(), 7);
let src = source::open_path(&img).unwrap();
let files: Vec<&Found> = res.found.iter().collect();
let out = tempfile::tempdir().unwrap();
let key = "disco-de-teste";
// 1ª execução: "cai a energia" depois de 3 arquivos
let n = std::sync::atomic::AtomicUsize::new(0);
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3;
let r1 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&cancel,
&mut |_, _, _| {},
)
.unwrap();
assert!(r1.cancelled);
assert_eq!(r1.recovered.len(), 3);
let journal = std::fs::read_to_string(out.path().join(recover::JOURNAL)).unwrap();
assert_eq!(journal.lines().count(), 3);
// 2ª execução: retoma, pula os 3 e faz os outros 4
let r2 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert!(!r2.cancelled);
assert_eq!((r2.skipped, r2.recovered.len()), (3, 7));
let got = hashes_in(out.path());
for (h, p) in expected(&dir, "fat32") {
assert!(got.contains_key(&h), "faltou {p}");
}
assert_eq!(
std::fs::read_to_string(out.path().join(recover::JOURNAL))
.unwrap()
.lines()
.count(),
7
);
// um arquivo é danificado entre as execuções: é refeito, os outros 6 são pulados
let victim = out.path().join(&r2.recovered[0].path);
std::fs::write(&victim, b"lixo").unwrap();
let r3 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert_eq!((r3.skipped, r3.recovered.len(), r3.failed.len()), (6, 7, 0));
assert!(
hashes_in(out.path()).contains_key(&r2.recovered[0].sha256),
"o arquivo danificado deveria ter sido refeito"
);
// outro disco (outra chave) não reaproveita o diário
let r4 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
"outro-disco",
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert_eq!(r4.skipped, 0);
let _ = json!({});
}
+160
View File
@@ -0,0 +1,160 @@
//! Leitor de SQLite: esquema, linhas (com overflow e árvores de vários níveis),
//! registros apagados e robustez contra bancos danificados.
use guara_core::source::MemSource;
use guara_core::sqlite::{is_sqlite, Cell, Db};
const DB: &[u8] = include_bytes!("data/exemplo.sqlite");
fn text(c: &Cell) -> &str {
match c {
Cell::Text(t) => t,
other => panic!("esperava texto, veio {other:?}"),
}
}
#[test]
fn esquema_com_tabelas_indices_e_views() {
let src = MemSource(DB.to_vec());
assert!(is_sqlite(DB));
let db = Db::open(&src).unwrap();
let s = db.schema();
assert_eq!(s.page_size, 1024);
assert_eq!(s.encoding, "UTF-8");
let names: Vec<_> = s.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["usuarios", "notas", "sem_rowid"]);
let u = &s.tables[0];
assert_eq!(
u.columns
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
["id", "nome", "email", "idade", "saldo", "foto"]
);
assert_eq!(u.columns[0].decl_type, "INTEGER");
assert!(u.columns[0].pk && u.rowid_alias == Some(0));
assert!(s.tables[2].without_rowid);
assert_eq!(s.indexes, ["idx_usuarios_email"]);
assert_eq!(s.views, ["maiores"]);
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
assert_eq!(db.count_rows(u), 493); // 500 menos 7 apagados
}
#[test]
fn linhas_em_arvore_de_varios_niveis_e_paginacao() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let p = db.rows("usuarios", 0, 5).unwrap();
assert_eq!(p.total, 493);
assert_eq!(p.rows.len(), 5);
// o alias do rowid (INTEGER PRIMARY KEY) vem preenchido
assert!(matches!(p.rows[0].cells[0], Cell::Int(1)));
assert_eq!(text(&p.rows[0].cells[1]), "Pessoa 0001");
assert_eq!(text(&p.rows[0].cells[2]), "pessoa1@exemplo.com");
assert!(matches!(p.rows[0].cells[3], Cell::Int(19)));
assert!(matches!(p.rows[1].cells[4], Cell::Real(r) if (r - 3.0).abs() < 1e-9));
assert!(matches!(&p.rows[2].cells[5], Cell::Blob { len: 3, .. }));
// pula os apagados 101..103: a linha de índice 100 é o id 104
let q = db.rows("usuarios", 100, 2).unwrap();
assert!(matches!(q.rows[0].cells[0], Cell::Int(104)));
// última linha
let last = db.rows("usuarios", 492, 10).unwrap();
assert_eq!(last.rows.len(), 1);
assert_eq!(text(&last.rows[0].cells[1]), "Pessoa 0500");
}
#[test]
fn texto_longo_com_overflow_e_acentos() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let n = db.rows("notas", 0, 10).unwrap();
assert_eq!(n.rows.len(), 3);
assert_eq!(text(&n.rows[0].cells[1]), "texto pequeno");
assert_eq!(
text(&n.rows[1].cells[1]).len(),
"Lorem ipsum dolor sit amet. ".len() * 400
);
assert!(text(&n.rows[1].cells[1]).ends_with("amet. "));
assert_eq!(text(&n.rows[2].cells[1]), "coração, ação, relatório");
assert_eq!(text(&n.rows[2].cells[0]), "acentuação");
}
#[test]
fn recupera_registros_apagados_das_areas_livres() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let d = db.deleted_rows("usuarios").unwrap();
let nomes: Vec<String> = d
.rows
.iter()
.filter_map(|r| r.cells.get(1))
.filter_map(|c| {
if let Cell::Text(t) = c {
Some(t.clone())
} else {
None
}
})
.collect();
let apagados = [
"Pessoa 0101",
"Pessoa 0102",
"Pessoa 0103",
"Pessoa 0250",
"Pessoa 0251",
"Pessoa 0252",
"Pessoa 0253",
];
let achados = apagados
.iter()
.filter(|n| nomes.iter().any(|x| x == *n))
.count();
assert!(
achados >= 5,
"só {achados} de 7 registros apagados recuperados: {nomes:?}"
);
assert!(d.rows.iter().all(|r| r.deleted));
// a tabela de notas não teve nada apagado
assert!(db.deleted_rows("notas").unwrap().rows.is_empty());
}
#[test]
fn banco_truncado_e_corrompido_nao_derruba_nem_trava() {
// metade do arquivo
let half = DB[..DB.len() / 2].to_vec();
let src = MemSource(half);
let db = Db::open(&src).unwrap();
let s = db.schema();
assert!(
s.warnings.iter().any(|w| w.contains("truncado")),
"{:?}",
s.warnings
);
let p = db.rows("usuarios", 0, 1000).unwrap();
assert!(!p.rows.is_empty() && p.rows.len() < 493, "{}", p.rows.len());
// sem cabeçalho
let mut bad = DB.to_vec();
bad[0] = b'X';
assert!(Db::open(&MemSource(bad)).is_err());
// corrupção aleatória (semente fixa): nunca pode dar panic
let mut x: u64 = 0x1234_5678_9abc_def0;
for _ in 0..60 {
let mut d = DB.to_vec();
for _ in 0..40 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let i = 100 + (x as usize % (d.len() - 100));
d[i] = (x >> 24) as u8;
}
let src = MemSource(d);
if let Ok(db) = Db::open(&src) {
let s = db.schema();
for t in &s.tables {
let _ = db.rows(&t.name, 0, 50);
let _ = db.deleted_rows(&t.name);
let _ = db.count_rows(t);
}
}
}
}