//! 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, 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 { 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 { 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 { 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 { 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 { 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 = HashSet::new(); let journal_path = dest.join(JOURNAL); let mut prior: std::collections::HashMap = 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::(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 { 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 = 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 { 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 { 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 { read_found_at(self.src, self.found, offset, buf) } }