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:
1 parent
4bb2397e3b
commit
bbcb42ae6e
27 files changed
+2681
-41
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user