diff --git a/crates/guara-core/src/assemble.rs b/crates/guara-core/src/assemble.rs new file mode 100644 index 0000000..e1b209f --- /dev/null +++ b/crates/guara-core/src/assemble.rs @@ -0,0 +1,184 @@ +//! Monta o disco virtual a partir do que o usuário informou: membros extras (RAID/LVM) e +//! senhas ou chaves para volumes cifrados. É a única porta de entrada para varrer e recuperar +//! discos que não são "simples". + +use crate::crypt::{self, Secret, UnlockError}; +use crate::error::{Error, Result}; +use crate::partition; +use crate::source::{open_path, Source, SubSource}; +use crate::types::{FsKind, PartInfo}; +use crate::volume::{Overlay, Region}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SecretInput { + Password { value: String }, + Recovery { value: String }, + /// Caminho de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS). + Keyfile { path: String }, + /// Chave do volume em hexadecimal. + Key { hex: String }, +} + +/// Chave de volume guardada na sessão (só quando o usuário pede para lembrar). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SavedKey { + pub start: u64, + pub kind: String, + pub key_hex: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Assembly { + /// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal. + #[serde(default)] + pub members: Vec, + /// Senhas e chaves: nunca vão para o disco junto com a sessão. + #[serde(default, skip_serializing)] + pub secrets: Vec, + /// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo. + #[serde(default)] + pub remember_keys: bool, + #[serde(default)] + pub keys: Vec, +} + +impl Assembly { + pub fn is_plain(&self) -> bool { + self.members.is_empty() && self.secrets.is_empty() && self.keys.is_empty() + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct UnlockInfo { + pub start: u64, + pub len: u64, + pub kind: String, + pub description: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LockedInfo { + pub start: u64, + pub kind: String, + pub reason: String, +} + +pub struct Assembled { + pub source: Arc, + pub unlocked: Vec, + pub locked: Vec, + /// Chaves derivadas, para guardar na sessão se o usuário permitir. + pub keys: Vec, + /// Volumes lógicos / partições extras que não estão na tabela do disco. + pub extra_parts: Vec, + pub notes: Vec, +} + +fn to_secrets(a: &Assembly) -> Result> { + let mut v = vec![]; + for s in &a.secrets { + v.push(match s { + SecretInput::Password { value } => Secret::Password(value.clone()), + SecretInput::Recovery { value } => Secret::RecoveryKey(value.clone()), + SecretInput::Keyfile { path } => Secret::KeyFile( + std::fs::read(path).map_err(|e| Error::msg(format!("não foi possível ler o arquivo de chave {path}: {e}")))?, + ), + SecretInput::Key { hex } => Secret::VolumeKey( + crypt::from_hex(hex).ok_or_else(|| Error::msg("chave do volume em hexadecimal inválida"))?, + ), + }); + } + Ok(v) +} + +fn kind_name(fs: FsKind) -> &'static str { + match fs { + FsKind::Bitlocker => "bitlocker", + FsKind::Luks => "luks", + FsKind::Corestorage => "filevault", + _ => "?", + } +} + +fn try_unlock(window: Arc, fs: FsKind, secrets: &[Secret]) -> crypt::UnlockResult { + match fs { + FsKind::Bitlocker => crypt::bitlocker::unlock(window, secrets), + FsKind::Luks => crypt::luks::unlock(window, secrets), + FsKind::Corestorage => crypt::filevault::unlock(window, secrets), + _ => Err(UnlockError::Unsupported("tipo".into())), + } +} + +/// Abre `path` e aplica a montagem pedida. +pub fn assemble(path: &str, asm: &Assembly) -> Result { + let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?; + assemble_source(base, asm) +} + +pub fn assemble_source(base: Arc, asm: &Assembly) -> Result { + let secrets = to_secrets(asm)?; + let mut regions: Vec = vec![]; + let mut out = Assembled { + source: base.clone(), + unlocked: vec![], + locked: vec![], + keys: vec![], + extra_parts: vec![], + notes: vec![], + }; + // Até três rodadas: o que foi aberto pode conter outro volume cifrado. + for _ in 0..3 { + let current: Arc = Arc::new(Overlay::new(base.clone(), regions.clone())); + let layout = partition::analyze(current.as_ref(), false); + let mut progress = false; + out.locked.clear(); + for p in &layout.partitions { + if p.encrypted.is_none() || regions.iter().any(|r| r.start == p.start) { + continue; + } + let len = if p.len == 0 { current.size().saturating_sub(p.start) } else { p.len }; + let window: Arc = Arc::new(SubSource::new(current.clone(), p.start, len)); + let mut all = secrets.clone(); + for k in asm.keys.iter().filter(|k| k.start == p.start) { + if let Some(b) = crypt::from_hex(&k.key_hex) { + all.push(Secret::VolumeKey(b)); + } + } + match try_unlock(window, p.fs, &all) { + Ok(u) => { + let ulen = u.source.size(); + out.unlocked.push(UnlockInfo { + start: p.start, + len: ulen, + kind: kind_name(p.fs).into(), + description: u.description.clone(), + }); + if let Some(k) = &u.volume_key { + out.keys.push(SavedKey { start: p.start, kind: kind_name(p.fs).into(), key_hex: crypt::to_hex(k) }); + } + regions.push(Region { start: p.start, len: ulen, src: u.source, label: u.description }); + progress = true; + } + Err(e) => out.locked.push(LockedInfo { + start: p.start, + kind: kind_name(p.fs).into(), + reason: match e { + UnlockError::WrongSecret if secrets.is_empty() && asm.keys.is_empty() => "volume cifrado: informe a senha, a chave de recuperação ou o arquivo de chave".into(), + other => other.to_string(), + }, + }), + } + } + if !progress { + break; + } + } + if !regions.is_empty() { + out.source = Arc::new(Overlay::new(base, regions)); + } + Ok(out) +} diff --git a/crates/guara-core/src/fs/ext.rs b/crates/guara-core/src/fs/ext.rs index 0305701..1ad7ca6 100644 --- a/crates/guara-core/src/fs/ext.rs +++ b/crates/guara-core/src/fs/ext.rs @@ -95,7 +95,6 @@ struct Ext<'a> { inodes: u64, groups: Vec, journal_inum: u32, - incompat: u32, } /// Sequência de (bloco lógico, bloco físico, quantidade, não-inicializado). @@ -163,7 +162,6 @@ impl<'a> Ext<'a> { inodes: le32(&sb, 0) as u64, groups, journal_inum: le32(&sb, 224), - incompat, }) } @@ -327,19 +325,6 @@ impl<'a> Ext<'a> { out } - fn read_file(&self, ino: &Inode, max: u64) -> Vec { - let size = ino.size.min(max); - let mut out = Vec::with_capacity(size as usize); - for e in self.file_extents(ino, size) { - if e.sparse { - out.extend(std::iter::repeat(0).take(e.len as usize)); - } else if let Ok(v) = read_vec(self.src, e.offset, e.len as usize) { - out.extend(v); - } - } - out - } - /// Mapa de blocos livres (bitmap), em extents absolutos. fn free_map(&self, ctx: &Ctx) -> (Vec, Vec) { let mut free: Vec = vec![]; diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index fec5fcc..921b9c0 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -3,6 +3,7 @@ //! Princípio central: a origem é aberta **somente para leitura**; nada neste //! crate escreve no dispositivo que está sendo recuperado. +pub mod assemble; pub mod carve; pub mod device; pub mod crypt; @@ -31,6 +32,7 @@ pub mod source; pub mod sqlite; pub mod thumb; pub mod types; +pub mod volume; pub use error::{Error, Result}; pub use types::*; diff --git a/crates/guara-core/src/partition.rs b/crates/guara-core/src/partition.rs index f14173c..b72aba3 100644 --- a/crates/guara-core/src/partition.rs +++ b/crates/guara-core/src/partition.rs @@ -13,7 +13,21 @@ pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option)> if b.len() < 1100 { return None; } - detect_fs_in(&b) + if let Some(r) = detect_fs_in(&b) { + return Some(r); + } + // Assinaturas mais adiante no volume: Btrfs (64 KiB) e RAID mdadm 1.2 (4 KiB). + if let Ok(h) = read_vec(src, start + 0x10000, 0x100) { + if h.len() >= 0x80 && &h[0x40..0x48] == b"_BHRfS_M" { + return Some((FsKind::Btrfs, Some(u64::from_le_bytes(h[0x70..0x78].try_into().unwrap())))); + } + } + if let Ok(h) = read_vec(src, start + 4096, 16) { + if h.len() >= 4 && h[..4] == [0xFC, 0x4E, 0x2B, 0xA9] { + return Some((FsKind::Raid, None)); + } + } + None } /// Mesma detecção sobre um buffer que começa no setor candidato (>= 1100 bytes). @@ -35,6 +49,27 @@ pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option)> { return Some((FsKind::Exfat, Some(vol << shift))); } } + if b.len() >= 520 && &b[512..520] == b"LABELONE" { + return Some((FsKind::Lvm, None)); + } + if b[..4] == [0xFC, 0x4E, 0x2B, 0xA9] { + return Some((FsKind::Raid, None)); + } + if &b[0..4] == b"XFSB" { + let bs = u32::from_be_bytes(b[4..8].try_into().unwrap()) as u64; + let blocks = u64::from_be_bytes(b[8..16].try_into().unwrap()); + if bs.is_power_of_two() && (512..=65536).contains(&bs) { + return Some((FsKind::Xfs, Some(bs * blocks))); + } + } + if &b[3..7] == b"ReFS" && &b[16..20] == b"FSRS" { + let sectors = u64::from_le_bytes(b[24..32].try_into().unwrap()); + let bps = u32::from_le_bytes(b[32..36].try_into().unwrap()) as u64; + return Some((FsKind::Refs, Some(sectors * bps))); + } + if crate::crypt::filevault::detect(b) { + return Some((FsKind::Corestorage, Some(u64::from_le_bytes(b[64..72].try_into().unwrap())))); + } if crate::crypt::bitlocker::detect(b) { return Some((FsKind::Bitlocker, None)); } diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs index 17d412b..ad6f2c4 100644 --- a/crates/guara-core/src/scan.rs +++ b/crates/guara-core/src/scan.rs @@ -83,6 +83,15 @@ pub fn run( l } }; + let mut layout = layout; + // Volumes lógicos e outros volumes montados que não estão na tabela do disco. + for p in &opts.extra_parts { + if !layout.partitions.iter().any(|q| q.start == p.start) { + let mut p = p.clone(); + p.index = layout.partitions.len(); + layout.partitions.push(p); + } + } cp.layout = Some(layout.clone()); // Índices para fusão: primeiro offset -> (id, confiança), e extents de achados por metadados. diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 23f647d..79bc26f 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -204,10 +204,25 @@ impl Engine { // ---------------------------------------------------------------- varredura - pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result { - let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; - let fp = crate::source::fingerprint(src.as_ref()); - let job = self.register(JobKind::Scan, path.to_string(), opts, Some(src)); + pub fn start_scan(&self, path: &str, mut opts: ScanOptions) -> Result { + let raw = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; + let fp = crate::source::fingerprint(raw.as_ref()); + let asm = crate::assemble::assemble_source(raw, &opts.assembly)?; + opts.extra_parts = asm.extra_parts.clone(); + if opts.assembly.remember_keys { + opts.assembly.keys = asm.keys.clone(); + } + let job = self.register(JobKind::Scan, path.to_string(), opts, Some(asm.source.clone())); + { + let mut s = job.st.lock().unwrap(); + for u in &asm.unlocked { + s.warnings.push(format!("Volume cifrado aberto na posição {}: {}.", u.start, u.description)); + } + for l in &asm.locked { + s.warnings.push(format!("Volume cifrado na posição {} não foi aberto: {}.", l.start, l.reason)); + } + s.warnings.extend(asm.notes.iter().cloned()); + } *job.fingerprint.lock().unwrap() = fp; *job.session_file.lock().unwrap() = Some(new_session_path(path)); spawn_scan(job.clone(), None); @@ -814,8 +829,8 @@ impl Engine { let mut last_err = None; for path in &tried { match open_path(Path::new(path)) { - Ok(src) if crate::reconnect::matches(src.as_ref(), size, fp.as_deref()) => { - *j.src.lock().unwrap() = Some(src); + Ok(src) if crate::reconnect::matches(src.as_ref(), if j.opts.assembly.is_plain() { size } else { 0 }, fp.as_deref()) => { + *j.src.lock().unwrap() = Some(finish_source(&j, src)); *j.src_path.lock().unwrap() = path.clone(); return Ok(None); } @@ -826,7 +841,7 @@ impl Engine { if let Some((path, src)) = crate::reconnect::find_matching_source(size, fp.as_deref(), &tried) { - *j.src.lock().unwrap() = Some(src); + *j.src.lock().unwrap() = Some(finish_source(&j, src)); *j.src_path.lock().unwrap() = path.clone(); return Ok(Some(path)); } @@ -863,7 +878,7 @@ impl Engine { if fp.is_none() { *j.fingerprint.lock().unwrap() = crate::source::fingerprint(src.as_ref()); } - *j.src.lock().unwrap() = Some(src); + *j.src.lock().unwrap() = Some(finish_source(&j, src)); *j.src_path.lock().unwrap() = path.to_string(); j.st.lock() .unwrap() @@ -1483,6 +1498,14 @@ impl Engine { // ---------------------------------------------------------------- sessões automáticas +/// Reaplica ao disco reaberto a montagem (volumes desbloqueados, RAID) da sessão. +fn finish_source(j: &Job, raw: Arc) -> Arc { + match crate::assemble::assemble_source(raw.clone(), &j.opts.assembly) { + Ok(a) => a.source, + Err(_) => raw, + } +} + fn sessions_dir() -> PathBuf { crate::settings::config_dir().join("sessoes") } diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index e2f75b6..ee19190 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -210,8 +210,17 @@ pub enum FsKind { Ext, Apfs, HfsPlus, + Btrfs, + Xfs, + Refs, Bitlocker, Luks, + /// FileVault 2 (Core Storage). + Corestorage, + /// Membro de volume LVM (physical volume). + Lvm, + /// Membro de RAID por software (mdadm). + Raid, Unknown, } @@ -226,8 +235,14 @@ impl FsKind { FsKind::Ext => "ext2/3/4", FsKind::Apfs => "APFS", FsKind::HfsPlus => "HFS+", + FsKind::Btrfs => "Btrfs", + FsKind::Xfs => "XFS", + FsKind::Refs => "ReFS", FsKind::Bitlocker => "BitLocker", FsKind::Luks => "LUKS", + FsKind::Corestorage => "FileVault 2", + FsKind::Lvm => "LVM", + FsKind::Raid => "RAID (mdadm)", FsKind::Unknown => "desconhecido", } } @@ -237,6 +252,7 @@ impl FsKind { match self { FsKind::Bitlocker => Some("BitLocker"), FsKind::Luks => Some("LUKS"), + FsKind::Corestorage => Some("FileVault 2"), _ => None, } } @@ -302,6 +318,12 @@ pub struct ScanOptions { /// Tamanho máximo aceito para arquivos carved (bytes). #[serde(default = "default_max_carve")] pub max_carve_size: u64, + /// Membros extras (RAID/LVM) e segredos para volumes cifrados. + #[serde(default)] + pub assembly: crate::assemble::Assembly, + /// Partições que não estão na tabela do disco (volumes lógicos, desbloqueados). + #[serde(default)] + pub extra_parts: Vec, } fn default_max_carve() -> u64 { @@ -315,6 +337,8 @@ impl Default for ScanOptions { include_allocated: false, categories: vec![], max_carve_size: default_max_carve(), + assembly: Default::default(), + extra_parts: vec![], } } } diff --git a/crates/guara-core/src/volume.rs b/crates/guara-core/src/volume.rs new file mode 100644 index 0000000..e1d9b35 --- /dev/null +++ b/crates/guara-core/src/volume.rs @@ -0,0 +1,75 @@ +//! Disco virtual: a origem real com trechos substituídos por volumes desbloqueados, +//! membros de RAID montados ou volumes lógicos (LVM) anexados depois do fim do disco. +//! +//! Os deslocamentos continuam sendo "do disco": quem varre e quem recupera enxergam o mesmo +//! espaço de endereços, desde que a montagem seja refeita igual (a `Assembly` é determinística). + +use crate::source::Source; +use std::io; +use std::sync::Arc; + +#[derive(Clone)] +pub struct Region { + pub start: u64, + pub len: u64, + pub src: Arc, + pub label: String, +} + +pub struct Overlay { + base: Arc, + regions: Vec, + size: u64, +} + +impl Overlay { + pub fn new(base: Arc, mut regions: Vec) -> Self { + regions.sort_by_key(|r| r.start); + let size = regions.iter().map(|r| r.start + r.len).max().unwrap_or(0).max(base.size()); + Self { base, regions, size } + } + pub fn regions(&self) -> &[Region] { + &self.regions + } +} + +impl Source for Overlay { + fn size(&self) -> u64 { + self.size + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result { + if offset >= self.size || buf.is_empty() { + return Ok(0); + } + let want = buf.len().min((self.size - offset) as usize); + let i = self.regions.partition_point(|r| r.start + r.len <= offset); + if let Some(r) = self.regions.get(i) { + if r.start <= offset { + let n = want.min((r.start + r.len - offset) as usize); + return r.src.read_at(offset - r.start, &mut buf[..n]); + } + // Antes da próxima região: lê da base só até ela. + let n = want.min((r.start - offset) as usize); + return self.read_base(offset, &mut buf[..n]); + } + self.read_base(offset, &mut buf[..want]) + } +} + +impl Overlay { + fn read_base(&self, offset: u64, buf: &mut [u8]) -> io::Result { + if offset >= self.base.size() { + // Lacuna entre a base e uma região anexada. + buf.fill(0); + return Ok(buf.len()); + } + let n = buf.len().min((self.base.size() - offset) as usize); + let got = self.base.read_at(offset, &mut buf[..n])?; + if got < buf.len() && got == n { + buf[n..].fill(0); + return Ok(buf.len()); + } + Ok(got) + } +} diff --git a/crates/guara-core/tests/e2e_crypt.rs b/crates/guara-core/tests/e2e_crypt.rs new file mode 100644 index 0000000..8dae126 --- /dev/null +++ b/crates/guara-core/tests/e2e_crypt.rs @@ -0,0 +1,96 @@ +//! Ponta a ponta: volume LUKS2 com ext4 dentro, arquivo apagado, senha informada ao Guará. + +mod common; +use common::*; +use guara_core::assemble::{assemble_source, Assembly, SecretInput}; +use guara_core::scan::{self, Outcome}; +use guara_core::source::{FileSource, Source}; +use guara_core::*; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +fn have(t: &str) -> bool { + Command::new("sh").args(["-c", &format!("command -v {t}")]).output().map(|o| o.status.success()).unwrap_or(false) +} + +fn run_stdin(c: &mut Command, input: &str) -> bool { + use std::io::Write; + let mut ch = c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + ch.stdin.take().unwrap().write_all(input.as_bytes()).unwrap(); + ch.wait().unwrap().success() +} + +fn data(seed: u64, n: usize) -> Vec { + let mut x = seed | 1; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }) + .collect() +} + +#[test] +fn deleted_file_inside_luks2_is_recovered_with_password() { + if !(have("cryptsetup") && have("mke2fs") && have("debugfs") && have("python3")) { + eprintln!("SKIP: ferramentas ausentes"); + return; + } + init(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + std::fs::create_dir_all(root.join("docs")).unwrap(); + let secret_doc = data(11, 120_000); + std::fs::write(root.join("docs/segredo.dat"), &secret_doc).unwrap(); + std::fs::write(root.join("docs/fica.dat"), data(12, 5000)).unwrap(); + let plain = dir.path().join("plain.img"); + std::fs::File::create(&plain).unwrap().set_len(8 << 20).unwrap(); + assert!(Command::new("mke2fs").args(["-q", "-F", "-t", "ext4", "-d"]).arg(&root).arg(&plain).status().unwrap().success()); + let dbg = |c: &str| { + Command::new("debugfs").arg("-w").arg("-R").arg(c).arg(&plain).output().unwrap() + }; + dbg("rm /docs/segredo.dat"); + + let img = dir.path().join("luks.img"); + std::fs::File::create(&img).unwrap().set_len(32 << 20).unwrap(); + let pw = "correct horse"; + let mut fmt = Command::new("cryptsetup"); + fmt.args(["luksFormat", "--batch-mode", "--type", "luks2", "--pbkdf", "pbkdf2", "--pbkdf-force-iterations", "1000", "--sector-size", "512", "--key-file", "-"]).arg(&img); + assert!(run_stdin(&mut fmt, pw)); + let vkf = dir.path().join("vk"); + let mut d = Command::new("cryptsetup"); + d.args(["luksDump", "--dump-volume-key", "--batch-mode", "--key-file", "-", "--volume-key-file"]).arg(&vkf).arg(&img); + assert!(run_stdin(&mut d, pw)); + let out = Command::new("cryptsetup").arg("luksDump").arg(&img).output().unwrap(); + let t = String::from_utf8_lossy(&out.stdout).to_string(); + let off: u64 = t.lines().skip_while(|l| !l.starts_with("Data segments")).find(|l| l.contains("offset:")).unwrap().split(':').nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert!(Command::new("python3") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/luks_encrypt.py")) + .arg(&img).arg(&plain).arg(&vkf).arg(off.to_string()).arg("aes-xts-plain64").arg("512") + .status().unwrap().success()); + + let base: Arc = Arc::new(FileSource::open(&img).unwrap()); + // Sem senha: o volume segue fechado e a varredura não inventa arquivos. + let locked = assemble_source(base.clone(), &Assembly::default()).unwrap(); + assert_eq!(locked.unlocked.len(), 0); + assert_eq!(locked.locked.len(), 1); + + let asm = Assembly { secrets: vec![SecretInput::Password { value: pw.into() }], ..Default::default() }; + let open = assemble_source(base, &asm).unwrap(); + assert_eq!(open.unlocked.len(), 1); + let mut sink = Collect::default(); + let outc = scan::run(open.source.as_ref(), &ScanOptions { mode: ScanMode::Deep, ..Default::default() }, None, &[], &|| false, &mut sink); + assert!(matches!(outc, Outcome::Completed)); + let f = sink.found.iter().find(|f| f.name == "segredo.dat").unwrap_or_else(|| panic!("não achou: {:?}", sink.found.iter().map(|f| &f.name).collect::>())); + assert_eq!(f.size, 120_000); + let dest = tempfile::tempdir().unwrap(); + let refs = vec![f]; + let rep = recover::recover(open.source.as_ref(), &refs, dest.path(), &|| false, &mut |_, _, _| {}).unwrap(); + assert!(rep.failed.is_empty()); + let p = &rep.recovered[0].path; + let p = if Path::new(p).is_absolute() { Path::new(p).to_path_buf() } else { dest.path().join(p) }; + assert!(std::fs::read(p).unwrap() == secret_doc); +}