CI / Format and clippy (push) Successful in 1m57s
CI / Dependency audit (push) Successful in 13s
CI / Check desktop app (Linux) (push) Successful in 3m54s
CI / Tests (Linux) (push) Failing after 7m25s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / CLI (linux-x86_64) (push) Successful in 2m39s
Release / Desktop app (linux-aarch64) (push) Successful in 10m9s
Release / Desktop app (linux-x86_64) (push) Successful in 12m20s
Release / Desktop app (windows) (push) Successful in 7m32s
Release / Build latest.json (push) Successful in 5s
Completes the English-only standardization for code, comments, scripts and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en, untouched). Covers the remaining crates/, apps/gui/ui/ and apps/gui/src-tauri/ comments, .gitea/ workflow and template comments, .githooks/ hooks, and the scripts/ source comments for the files already renamed in a prior commit (check.sh, generate-logo.py, install-hooks.sh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
527 lines
18 KiB
Rust
527 lines
18 KiB
Rust
//! Writes recovered files to a separate destination volume along with SHA-256
|
|
//! digests, recovery journal, and session manifest.
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::fs::sanitize_name;
|
|
use crate::hashing::{Hashes, MultiHasher};
|
|
use crate::source::{read_vec, Source};
|
|
use crate::types::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RecoveredFile {
|
|
pub id: u64,
|
|
pub path: String,
|
|
pub size: u64,
|
|
pub sha256: String,
|
|
#[serde(default)]
|
|
pub md5: String,
|
|
#[serde(default)]
|
|
pub sha1: String,
|
|
pub confidence: Confidence,
|
|
pub origin: Origin,
|
|
pub partial: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct RecoverReport {
|
|
pub destination: String,
|
|
pub recovered: Vec<RecoveredFile>,
|
|
pub failed: Vec<(u64, String)>,
|
|
pub total_bytes: u64,
|
|
pub cancelled: bool,
|
|
/// Files already recovered and SHA-256 verified in a prior run (skipped on resume).
|
|
#[serde(default)]
|
|
pub skipped: usize,
|
|
}
|
|
|
|
/// Rejects destination paths located on the same physical device as the source.
|
|
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
|
crate::owner::create_dir_all_owned(dest)?;
|
|
let dest = dest.canonicalize()?;
|
|
let Some(sp) = source_path else { return Ok(()) };
|
|
let sp_str = sp.to_string_lossy().to_string();
|
|
for (dev, mnt) in crate::device::mount_table() {
|
|
let same = dev == sp_str
|
|
|| (dev.starts_with(&sp_str)
|
|
&& dev[sp_str.len()..]
|
|
.chars()
|
|
.next()
|
|
.map(|c| c.is_ascii_digit() || c == 'p' || c == 's')
|
|
.unwrap_or(false));
|
|
if same {
|
|
let m = Path::new(&mnt);
|
|
if dest.starts_with(m) && m != Path::new("/") {
|
|
return Err(Error::msg(format!(
|
|
"O destino {} fica em {} (montado a partir da origem {}). Escolha outro disco para não sobrescrever dados recuperáveis.",
|
|
dest.display(),
|
|
mnt,
|
|
sp_str
|
|
)));
|
|
}
|
|
if m == Path::new("/") {
|
|
return Err(Error::msg(format!("A origem {sp_str} contém o sistema (/). Recupere para um disco externo ou use uma imagem.")));
|
|
}
|
|
}
|
|
}
|
|
if let Ok(meta) = std::fs::metadata(sp) {
|
|
if meta.is_file() {
|
|
// For image files: only reject writing directly over the source image file itself.
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Directory layout strategy for recovered files inside the destination folder.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum RecoverLayout {
|
|
/// Preserve original folder hierarchy when known; group carved files by type (default).
|
|
#[default]
|
|
Folders,
|
|
/// Group into one folder per category (Photos, Documents, Videos, etc.).
|
|
Type,
|
|
/// Group into `YYYY/YYYY-MM` folders by capture or modification date.
|
|
Date,
|
|
/// Place all files flat inside the destination folder.
|
|
Flat,
|
|
}
|
|
|
|
impl RecoverLayout {
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
Some(match s {
|
|
"folders" | "pastas" => Self::Folders,
|
|
"type" | "tipo" => Self::Type,
|
|
"date" | "data" => Self::Date,
|
|
"flat" | "plano" => Self::Flat,
|
|
_ => return None,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn recover(
|
|
src: &dyn Source,
|
|
files: &[&Found],
|
|
dest: &Path,
|
|
cancelled: &(dyn Fn() -> bool + Sync),
|
|
progress: &mut dyn FnMut(usize, usize, u64),
|
|
) -> Result<RecoverReport> {
|
|
recover_with(
|
|
src,
|
|
files,
|
|
dest,
|
|
RecoverLayout::Folders,
|
|
cancelled,
|
|
progress,
|
|
)
|
|
}
|
|
|
|
pub fn recover_with(
|
|
src: &dyn Source,
|
|
files: &[&Found],
|
|
dest: &Path,
|
|
layout: RecoverLayout,
|
|
cancelled: &(dyn Fn() -> bool + Sync),
|
|
progress: &mut dyn FnMut(usize, usize, u64),
|
|
) -> Result<RecoverReport> {
|
|
recover_resumable(src, files, dest, layout, "", false, cancelled, progress)
|
|
}
|
|
|
|
/// A single entry in the recovery journal (`guara-diario.jsonl`) for a written and verified file.
|
|
#[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)
|
|
}
|
|
|
|
/// Like `recover_with`, but maintains an append-only journal and, when `resume` is set, skips
|
|
/// files already written and SHA-256 verified during an interrupted run.
|
|
/// `source_key` binds the journal to the source disk so another disk's journal is never reused.
|
|
#[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);
|
|
// resume check: does the file already exist on disk with the expected size and SHA-256?
|
|
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;
|
|
}
|
|
// incomplete or modified partial file from our previous run: overwrite it
|
|
let _ = std::fs::remove_file(&p);
|
|
}
|
|
}
|
|
let rel = target_rel(f, layout);
|
|
let mut path = dest.join(&rel);
|
|
if let Some(parent) = path.parent() {
|
|
if let Err(e) = crate::owner::create_dir_all_owned(parent) {
|
|
report.failed.push((f.id, e.to_string()));
|
|
continue;
|
|
}
|
|
}
|
|
let mut n = 1;
|
|
while path.exists() || used.contains(&path) {
|
|
let stem = Path::new(&f.name)
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
let name = if f.ext.is_empty() {
|
|
format!("{stem} ({n})")
|
|
} else {
|
|
format!("{stem} ({n}).{}", f.ext)
|
|
};
|
|
path = path.with_file_name(sanitize_name(&name));
|
|
n += 1;
|
|
}
|
|
used.insert(path.clone());
|
|
match write_file(src, f, &path) {
|
|
Ok(h) => {
|
|
report.total_bytes += f.size;
|
|
let rec = RecoveredFile {
|
|
id: f.id,
|
|
path: path
|
|
.strip_prefix(dest)
|
|
.unwrap_or(&path)
|
|
.to_string_lossy()
|
|
.replace('\\', "/"),
|
|
size: f.size,
|
|
sha256: h.sha256,
|
|
md5: h.md5,
|
|
sha1: h.sha1,
|
|
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())),
|
|
}
|
|
}
|
|
progress(files.len(), files.len(), report.total_bytes);
|
|
let manifest = serde_json::json!({
|
|
"ferramenta": "Guará",
|
|
"versao": env!("CARGO_PKG_VERSION"),
|
|
"gerado_em": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
|
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
|
"relatorio": &report,
|
|
});
|
|
let manifest_path = dest.join("guara-manifesto.json");
|
|
crate::owner::write_owned(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
|
|
crate::sign::sign_if_configured(&manifest_path);
|
|
Ok(report)
|
|
}
|
|
|
|
fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
|
|
let mut p = PathBuf::new();
|
|
match layout {
|
|
RecoverLayout::Folders => match &f.dir {
|
|
Some(d) if !d.is_empty() => {
|
|
p.push("Recuperados");
|
|
for c in d.split('/') {
|
|
if !c.is_empty() {
|
|
p.push(sanitize_name(c));
|
|
}
|
|
}
|
|
}
|
|
_ => p.push(f.category.label_pt()),
|
|
},
|
|
RecoverLayout::Type => p.push(f.category.label_pt()),
|
|
RecoverLayout::Date => match f.when() {
|
|
Some(t) => {
|
|
let (y, m, _) = crate::meta::ymd(t);
|
|
p.push(format!("{y:04}"));
|
|
p.push(format!("{y:04}-{m:02}"));
|
|
}
|
|
None => p.push("Sem data"),
|
|
},
|
|
RecoverLayout::Flat => {}
|
|
}
|
|
p.push(sanitize_name(&f.name));
|
|
p
|
|
}
|
|
|
|
/// Returns available free bytes on the filesystem containing `path`.
|
|
pub fn free_space(path: &Path) -> Option<u64> {
|
|
let mut p = path;
|
|
while !p.exists() {
|
|
p = p.parent()?;
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?;
|
|
// SAFETY: `c` is a valid NUL-terminated C string and `st` is a valid local output struct.
|
|
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
|
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
|
|
return None;
|
|
}
|
|
Some((st.f_bavail as u64).saturating_mul(st.f_frsize as u64))
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::ffi::OsStrExt;
|
|
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
|
let wide: Vec<u16> = p
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let mut free = 0u64;
|
|
// SAFETY: `wide` is NUL-terminated and the output pointer is valid.
|
|
let ok = unsafe {
|
|
GetDiskFreeSpaceExW(
|
|
wide.as_ptr(),
|
|
&mut free,
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
)
|
|
};
|
|
(ok != 0).then_some(free)
|
|
}
|
|
}
|
|
|
|
/// Streams the recovered file content to `out` and returns its hex SHA-256 digest.
|
|
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
|
let mut out = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)?;
|
|
crate::owner::give_to_user(path);
|
|
let mut hasher = MultiHasher::new();
|
|
match &f.data {
|
|
DataLoc::Resident { bytes } => {
|
|
out.write_all(bytes)?;
|
|
hasher.update(bytes);
|
|
}
|
|
DataLoc::Parts { parts } => {
|
|
for part in parts {
|
|
match part {
|
|
Part::Bytes { bytes } => {
|
|
out.write_all(bytes)?;
|
|
hasher.update(bytes);
|
|
}
|
|
Part::Zeros { len } => {
|
|
let z = vec![0u8; (1 << 20).min(*len as usize).max(1)];
|
|
let mut left = *len;
|
|
while left > 0 {
|
|
let n = left.min(z.len() as u64) as usize;
|
|
out.write_all(&z[..n])?;
|
|
hasher.update(&z[..n]);
|
|
left -= n as u64;
|
|
}
|
|
}
|
|
Part::Packed(pk) => {
|
|
let v = pk.read(src)?;
|
|
out.write_all(&v)?;
|
|
hasher.update(&v);
|
|
}
|
|
Part::Run { offset, len } => {
|
|
let mut done = 0u64;
|
|
while done < *len {
|
|
let n = (*len - done).min(1 << 20) as usize;
|
|
let got = read_vec(src, offset + done, n)?;
|
|
if got.len() < n {
|
|
return Err(Error::msg("leitura além do fim da origem"));
|
|
}
|
|
out.write_all(&got)?;
|
|
hasher.update(&got);
|
|
done += n as u64;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
DataLoc::Extents { extents } => {
|
|
let mut left = f.size;
|
|
let mut buf = vec![0u8; 1 << 20];
|
|
for e in extents {
|
|
let mut done = 0u64;
|
|
while done < e.len && left > 0 {
|
|
let n = (e.len - done).min(left).min(buf.len() as u64) as usize;
|
|
if e.sparse {
|
|
buf[..n].fill(0);
|
|
} else {
|
|
let got = read_vec(src, e.offset + done, n)?;
|
|
if got.len() < n {
|
|
return Err(Error::msg("leitura além do fim da origem"));
|
|
}
|
|
buf[..n].copy_from_slice(&got);
|
|
}
|
|
out.write_all(&buf[..n])?;
|
|
hasher.update(&buf[..n]);
|
|
done += n as u64;
|
|
left -= n as u64;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(m) = f.modified.filter(|m| *m > 0) {
|
|
let _ = out.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(m as u64));
|
|
}
|
|
out.flush()?;
|
|
Ok(hasher.finalize())
|
|
}
|
|
|
|
/// Reads up to `buf.len()` bytes of a recovered file's logical content starting at `offset`
|
|
/// (used for zero-copy streaming and preview without writing to disk).
|
|
pub fn read_found_at(
|
|
src: &dyn Source,
|
|
f: &Found,
|
|
offset: u64,
|
|
buf: &mut [u8],
|
|
) -> std::io::Result<usize> {
|
|
if offset >= f.size || buf.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
let want = buf.len().min((f.size - offset) as usize);
|
|
match &f.data {
|
|
DataLoc::Resident { bytes } => {
|
|
let o = offset as usize;
|
|
let n = want.min(bytes.len().saturating_sub(o));
|
|
buf[..n].copy_from_slice(&bytes[o..o + n]);
|
|
Ok(n)
|
|
}
|
|
DataLoc::Parts { parts } => {
|
|
let mut pos = 0u64;
|
|
for p in parts {
|
|
let plen = p.len();
|
|
if offset < pos + plen {
|
|
let within = offset - pos;
|
|
let n = want.min((plen - within) as usize);
|
|
return match p {
|
|
Part::Bytes { bytes } => {
|
|
buf[..n].copy_from_slice(&bytes[within as usize..within as usize + n]);
|
|
Ok(n)
|
|
}
|
|
Part::Run { offset: o, .. } => src.read_at(o + within, &mut buf[..n]),
|
|
Part::Zeros { .. } => {
|
|
buf[..n].fill(0);
|
|
Ok(n)
|
|
}
|
|
Part::Packed(pk) => {
|
|
let v = pk.read(src)?;
|
|
buf[..n].copy_from_slice(&v[within as usize..within as usize + n]);
|
|
Ok(n)
|
|
}
|
|
};
|
|
}
|
|
pos += plen;
|
|
}
|
|
Ok(0)
|
|
}
|
|
DataLoc::Extents { extents } => {
|
|
let mut pos = 0u64;
|
|
for e in extents {
|
|
let elen = e.len.min(f.size.saturating_sub(pos));
|
|
if offset < pos + elen {
|
|
let within = offset - pos;
|
|
let n = want.min((elen - within) as usize);
|
|
if e.sparse {
|
|
buf[..n].fill(0);
|
|
return Ok(n);
|
|
}
|
|
return src.read_at(e.offset + within, &mut buf[..n]);
|
|
}
|
|
pos += elen;
|
|
}
|
|
Ok(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adapter exposing a recovered `Found` file as a positional `Source` (for format parsers).
|
|
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)
|
|
}
|
|
}
|