From b8dfe21fd72e41b2c16c057869806681f4242808 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 18 Oct 2026 23:29:11 -0300 Subject: [PATCH] =?UTF-8?q?Detec=C3=A7=C3=A3o=20autom=C3=A1tica=20de=20par?= =?UTF-8?q?=C3=A2metros=20de=20RAID=20sem=20metadados=20e=20montagem=20int?= =?UTF-8?q?egrada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/guara-core/src/assemble.rs | 59 ++++++- crates/guara-core/src/fs/ext.rs | 57 +++++++ crates/guara-core/src/lib.rs | 1 + crates/guara-core/src/raid_detect.rs | 227 +++++++++++++++++++++++++ crates/guara-core/src/scan.rs | 5 + crates/guara-core/src/service.rs | 1 + crates/guara-core/src/types.rs | 4 + crates/guara-core/tests/e2e_raid.rs | 66 +++++++ crates/guara-core/tests/raid_detect.rs | 84 +++++++++ 9 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 crates/guara-core/src/raid_detect.rs create mode 100644 crates/guara-core/tests/raid_detect.rs diff --git a/crates/guara-core/src/assemble.rs b/crates/guara-core/src/assemble.rs index 777011a..2dacc77 100644 --- a/crates/guara-core/src/assemble.rs +++ b/crates/guara-core/src/assemble.rs @@ -31,8 +31,22 @@ pub struct SavedKey { pub key_hex: String, } +/// RAID sem metadados: o que o usuário sabe; o resto é descoberto. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct RaidSpec { + pub level: Option, + /// Tamanho da faixa em KiB. + pub chunk_kb: Option, + pub layout: Option, + /// Ordem dos discos: índice 0 é o alvo principal, depois `members` na ordem dada. + pub order: Option>, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Assembly { + /// Trata o alvo e `members` como discos de um RAID sem metadados (parâmetros descobertos). + #[serde(default)] + pub raid: Option, /// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal. #[serde(default)] pub members: Vec, @@ -48,7 +62,7 @@ pub struct Assembly { impl Assembly { pub fn is_plain(&self) -> bool { - self.members.is_empty() && self.secrets.is_empty() && self.keys.is_empty() + self.members.is_empty() && self.secrets.is_empty() && self.keys.is_empty() && self.raid.is_none() } } @@ -77,6 +91,8 @@ pub struct Assembled { pub extra_parts: Vec, /// Partições reais que são só contêineres (membros de RAID/LVM já remontados). pub covered: Vec, + /// O disco principal é só um membro de RAID: não varrer o disco cru. + pub skip_primary: bool, pub notes: Vec, } @@ -168,6 +184,7 @@ pub fn assemble_source(base: Arc, asm: &Assembly) -> Result> = vec![]; @@ -175,6 +192,16 @@ pub fn assemble_source(base: Arc, asm: &Assembly) -> Result> = vec![base.clone()]; + disks.extend(members.iter().cloned()); + let params = pick_raid(&disks, spec, &mut out.notes)?; + let src: Arc = Arc::new(crate::raid::RaidSource::new(params).map_err(Error::msg)?); + out.notes.push(format!("RAID montado a partir de {} disco(s) sem metadados.", disks.len())); + out.skip_primary = true; + members.clear(); + add_virtual(&mut regions, &mut next_virtual, &mut out, src, "RAID".into()); + } let mut done_containers: std::collections::BTreeSet = Default::default(); let mut tried_pool = false; // Rodadas: o que foi aberto/montado pode conter outro volume cifrado, RAID ou LVM. @@ -368,3 +395,33 @@ fn add_virtual(regions: &mut Vec, next: &mut u64, out: &mut Assembled, s } regions.push(Region { start, len, src, label }); } + +/// Escolhe os parâmetros do RAID: o que o usuário deu manda; o resto vem da detecção. +fn pick_raid(disks: &[Arc], spec: &RaidSpec, notes: &mut Vec) -> Result { + use crate::raid::{Member, RaidParams}; + if let (Some(level), Some(chunk), Some(layout), Some(order)) = (spec.level, spec.chunk_kb, spec.layout, spec.order.clone()) { + if order.len() == disks.len() && order.iter().all(|&i| i < disks.len()) { + let members = order.iter().map(|&i| Some(Member { src: disks[i].clone(), data_offset: 0 })).collect(); + return Ok(RaidParams::simple(level, chunk * 1024, layout, members)); + } + } + let cands = crate::raid_detect::detect(disks, &|| false); + let c = cands + .into_iter() + .find(|c| { + spec.level.map_or(true, |l| c.level == l) + && spec.chunk_kb.map_or(true, |k| c.chunk == k * 1024) + && spec.layout.map_or(true, |l| c.layout == l) + && spec.order.as_ref().map_or(true, |o| *o == c.order) + }) + .ok_or_else(|| Error::msg("não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)"))?; + notes.push(format!( + "Parâmetros do RAID descobertos: {}, faixa de {} KiB, layout {:?}, ordem dos discos {:?} (pontuação {:.1}).", + c.level.name(), + c.chunk / 1024, + c.layout, + c.order, + c.score + )); + Ok(c.params(disks)) +} diff --git a/crates/guara-core/src/fs/ext.rs b/crates/guara-core/src/fs/ext.rs index 1ad7ca6..668826e 100644 --- a/crates/guara-core/src/fs/ext.rs +++ b/crates/guara-core/src/fs/ext.rs @@ -831,3 +831,60 @@ fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList { } out } + +/// Quão coerente é a estrutura do volume: fração das entradas de diretório cujo inode confere +/// com o tipo declarado. Serve para escolher parâmetros de RAID sem metadados. +pub fn consistency_score(src: &dyn Source, base: u64) -> Option { + let ext = Ext::open(src, base)?; + let mut queue = vec![2u64]; + let mut seen = HashSet::new(); + let (mut ok, mut total) = (0u32, 0u32); + let (mut dots_ok, mut dots_total) = (0u32, 0u32); + while let Some(d) = queue.pop() { + if !seen.insert(d) || total >= 600 { + continue; + } + let Some(ino) = ext.read_inode(d).filter(|i| i.is_dir()) else { continue }; + let mut ents = vec![]; + for e in ext.file_extents(&ino, ino.size.min(1 << 20)) { + if e.sparse { + continue; + } + let mut o = 0; + while o < e.len { + if let Ok(b) = read_vec(ext.src, e.offset + o, ext.bs as usize) { + if b.len() as u64 == ext.bs { + parse_dir_block(&b, ext.inodes, &mut ents); + } + } + o += ext.bs; + } + } + // A entrada "." de um diretório aponta para ele mesmo: só confere se inode e bloco + // vieram do lugar certo. + dots_total += 1; + if ents.iter().any(|e| e.live && e.name == "." && e.ino == d) { + dots_ok += 1; + } + for en in ents.iter().filter(|e| e.live && e.name != "." && e.name != "..") { + total += 1; + if let Some(i) = ext.read_inode(en.ino) { + if i.mode != 0 && i.links > 0 { + ok += 1; + if i.is_dir() { + queue.push(en.ino); + } + } + } + if total >= 600 { + break; + } + } + } + if total == 0 { + return None; + } + let a = ok as f64 / total as f64; + let b = if dots_total > 0 { dots_ok as f64 / dots_total as f64 } else { 0.0 }; + Some(1.0 + 5.0 * a + 5.0 * b) +} diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 662c5a4..d11e128 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod owner; pub mod partition; pub mod reconnect; pub mod raid; +pub mod raid_detect; pub mod recover; pub mod report; pub mod scan; diff --git a/crates/guara-core/src/raid_detect.rs b/crates/guara-core/src/raid_detect.rs new file mode 100644 index 0000000..fe4f0cf --- /dev/null +++ b/crates/guara-core/src/raid_detect.rs @@ -0,0 +1,227 @@ +//! Descobre sozinho nível, ordem dos discos, tamanho de faixa e layout de um RAID sem +//! metadados, testando as combinações e medindo a coerência do sistema de arquivos que sai. + +use crate::partition; +use crate::raid::{Layout, Level, Member, RaidParams, RaidSource}; +use crate::source::{read_vec, MemSource, Source}; +use crate::types::FsKind; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct Candidate { + pub level: Level, + pub chunk: u64, + pub layout: Layout, + /// Índices dos membros fornecidos, na ordem do array. + pub order: Vec, + pub score: f64, +} + +impl Candidate { + pub fn params(&self, members: &[Arc]) -> RaidParams { + RaidParams::simple( + self.level, + self.chunk, + self.layout, + self.order.iter().map(|&i| Some(Member { src: members[i].clone(), data_offset: 0 })).collect(), + ) + } +} + +const PREFIX: u64 = 24 << 20; +const CHUNKS: [u64; 9] = [4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576]; + +fn permutations(n: usize) -> Vec> { + fn rec(cur: &mut Vec, used: &mut Vec, n: usize, out: &mut Vec>) { + if cur.len() == n { + out.push(cur.clone()); + return; + } + for i in 0..n { + if !used[i] { + used[i] = true; + cur.push(i); + rec(cur, used, n, out); + cur.pop(); + used[i] = false; + } + } + } + let mut out = vec![]; + rec(&mut vec![], &mut vec![false; n], n, &mut out); + out +} + +fn ntfs_score(src: &dyn Source, start: u64) -> f64 { + let Ok(b) = read_vec(src, start, 512) else { return 0.0 }; + if b.len() < 512 || &b[3..11] != b"NTFS " { + return 0.0; + } + let bps = u16::from_le_bytes([b[11], b[12]]) as u64; + let spc = b[13] as u64; + let mft = u64::from_le_bytes(b[0x30..0x38].try_into().unwrap()); + let rs = b[0x40] as i8; + let rec = if rs > 0 { rs as u64 * bps * spc } else { 1u64 << (-rs) }; + if bps == 0 || spc == 0 || rec == 0 || rec > 65536 { + return 0.0; + } + let mft_off = start + mft * bps * spc; + let (mut ok, mut total) = (0, 0); + for k in [0u64, 1, 2, 3, 5, 11, 16, 24, 40, 100, 250, 600, 1500, 3000, 6000, 12000, 24000, 50000] { + let off = mft_off + k * rec; + if off + rec > src.size() { + break; + } + let Ok(r) = read_vec(src, off, 64) else { continue }; + if r.len() < 64 { + continue; + } + // Registros além do fim do MFT estão zerados: não contam contra. + if r.iter().all(|&x| x == 0) { + continue; + } + total += 1; + if &r[..4] == b"FILE" && u32::from_le_bytes(r[0x2C..0x30].try_into().unwrap()) as u64 == k { + ok += 1; + } + } + if total == 0 { + 1.0 + } else { + 1.0 + 10.0 * ok as f64 / total as f64 + } +} + +/// Coerência do volume que começa em `start`. +fn volume_score(src: &dyn Source, start: u64, fs: FsKind) -> f64 { + match fs { + FsKind::Ntfs => ntfs_score(src, start), + FsKind::Ext => crate::fs::ext::consistency_score(src, start).unwrap_or(0.5), + FsKind::Unknown => 0.0, + _ => 1.0, + } +} + +pub fn fs_score(src: &dyn Source) -> f64 { + let layout = partition::analyze(src, false); + let mut best = 0.0f64; + for p in &layout.partitions { + best = best.max(volume_score(src, p.start, p.fs) + if p.fs != FsKind::Unknown { 0.5 } else { 0.0 }); + } + best +} + +fn first_sector_plausible(src: &dyn Source) -> bool { + let Ok(b) = read_vec(src, 0, 4096) else { return false }; + b.len() >= 1100 && ((b[510] == 0x55 && b[511] == 0xAA) || partition::detect_fs_in(&b).is_some()) +} + +/// Testa as combinações e devolve as melhores (maior pontuação primeiro). +pub fn detect(members: &[Arc], cancelled: &(dyn Fn() -> bool + Sync)) -> Vec { + let n = members.len(); + if n < 2 { + return vec![]; + } + // Trabalha sobre um prefixo em memória de cada disco. + let cached: Vec> = members + .iter() + .map(|m| { + let len = m.size().min(PREFIX) as usize; + Arc::new(MemSource(read_vec(m.as_ref(), 0, len).unwrap_or_default())) as Arc + }) + .collect(); + let min_len = cached.iter().map(|c| c.size()).min().unwrap_or(0); + if min_len < 1 << 20 { + return vec![]; + } + let mut out: Vec = vec![]; + + // RAID 1: cópias idênticas. + let probe = |s: &Arc| read_vec(s.as_ref(), 0, 1 << 20).unwrap_or_default(); + let first = probe(&cached[0]); + if first.iter().any(|&x| x != 0) && cached.iter().skip(1).all(|c| probe(c) == first) { + let c = Candidate { level: Level::R1, chunk: 65536, layout: Layout::LeftSymmetric, order: (0..n).collect(), score: fs_score(cached[0].as_ref()) + 1.0 }; + out.push(c); + } + + // RAID 5/4 satisfaz "XOR de todos os discos = 0" em toda faixa. + let sample = 4usize << 20; + let bufs: Vec> = cached.iter().map(|c| read_vec(c.as_ref(), 0, sample).unwrap_or_default()).collect(); + let mut xor_zero = 0usize; + let mut nonzero_rows = 0usize; + for blk in 0..sample / 4096 { + let mut any = false; + let mut z = true; + for i in 0..4096 { + let mut x = 0u8; + for b in &bufs { + x ^= b[blk * 4096 + i]; + any |= b[blk * 4096 + i] != 0; + } + if x != 0 { + z = false; + break; + } + } + if any { + nonzero_rows += 1; + if z { + xor_zero += 1; + } + } + } + let parity5 = nonzero_rows > 8 && xor_zero * 100 >= nonzero_rows * 95; + + let mut levels: Vec = vec![]; + if parity5 && n >= 3 { + levels.push(Level::R5); + } else { + levels.push(Level::R0); + if n >= 4 { + levels.push(Level::R6); + } + } + let ok_first: Vec = cached.iter().map(|c| first_sector_plausible(c.as_ref())).collect(); + let any_first = ok_first.iter().any(|&b| b); + let perms = permutations(n); + for level in levels { + let layouts: Vec = if matches!(level, Level::R0) { vec![Layout::LeftSymmetric] } else { Layout::all_raid5().to_vec() }; + for &chunk in &CHUNKS { + for layout in &layouts { + for order in &perms { + if cancelled() { + return finish(out); + } + let cand = Candidate { level, chunk, layout: *layout, order: order.clone(), score: 0.0 }; + let params = cand.params(&cached); + let Ok(r) = RaidSource::new(params) else { continue }; + // O primeiro setor do volume precisa estar num disco que o tenha. + if any_first { + let mut b = [0u8; 512]; + let _ = r.read_at(0, &mut b); + let m0 = order.iter().position(|&i| { + let mut c = [0u8; 512]; + let _ = cached[i].read_at(0, &mut c); + c == b + }); + match m0 { + Some(p) if ok_first[order[p]] => {} + _ => continue, + } + } + let score = fs_score(&r); + if score > 0.0 { + out.push(Candidate { score, ..cand }); + } + } + } + } + } + finish(out) +} + +fn finish(mut out: Vec) -> Vec { + out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + out.truncate(10); + out +} diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs index 7d8fa51..2fe71c5 100644 --- a/crates/guara-core/src/scan.rs +++ b/crates/guara-core/src/scan.rs @@ -84,6 +84,11 @@ pub fn run( } }; let mut layout = layout; + if opts.skip_primary { + layout.partitions.clear(); + layout.gaps.clear(); + layout.table = "membro de RAID".into(); + } // 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) { diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 75a7443..f50fcb2 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -210,6 +210,7 @@ impl Engine { let asm = crate::assemble::assemble_source(raw, &opts.assembly)?; opts.extra_parts = asm.extra_parts.clone(); opts.covered = asm.covered.clone(); + opts.skip_primary = asm.skip_primary; if opts.assembly.remember_keys { opts.assembly.keys = asm.keys.clone(); } diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index 5c2ad5c..2ce68c5 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -327,6 +327,9 @@ pub struct ScanOptions { /// Partições que só contêm RAID/LVM remontado: a varredura entra pelos volumes virtuais. #[serde(default)] pub covered: Vec, + /// O disco principal é só membro de RAID sem metadados: varre apenas os volumes virtuais. + #[serde(default)] + pub skip_primary: bool, } fn default_max_carve() -> u64 { @@ -343,6 +346,7 @@ impl Default for ScanOptions { assembly: Default::default(), extra_parts: vec![], covered: vec![], + skip_primary: false, } } } diff --git a/crates/guara-core/tests/e2e_raid.rs b/crates/guara-core/tests/e2e_raid.rs index 7d15a9e..04ff4aa 100644 --- a/crates/guara-core/tests/e2e_raid.rs +++ b/crates/guara-core/tests/e2e_raid.rs @@ -121,3 +121,69 @@ fn deleted_file_on_degraded_raid5() { 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() == doc); } + +#[test] +fn raid5_without_metadata_is_detected_and_scanned() { + if !(have("mke2fs") && have("debugfs")) { + return; + } + init(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + for d in 0..10 { + std::fs::create_dir_all(root.join(format!("d{d}"))).unwrap(); + for f in 0..30 { + std::fs::write(root.join(format!("d{d}/f{f}.txt")), vec![b'x'; 500 + f * 50]).unwrap(); + } + } + let doc = data(9, 200_000); + std::fs::write(root.join("d3/perdido.dat"), &doc).unwrap(); + let plain = dir.path().join("plain.img"); + let (n, chunk) = (3usize, 64 * 1024usize); + let dd = n - 1; + let rows = 160usize; + std::fs::File::create(&plain).unwrap().set_len((rows * dd * chunk) as u64).unwrap(); + assert!(Command::new("mke2fs").args(["-q", "-F", "-t", "ext4", "-d"]).arg(&root).arg(&plain).status().unwrap().success()); + Command::new("debugfs").arg("-w").arg("-R").arg("rm /d3/perdido.dat").arg(&plain).output().unwrap(); + let logical = std::fs::read(&plain).unwrap(); + // left-asymmetric + let mut disks = vec![vec![0u8; rows * chunk]; n]; + for row in 0..rows { + let pd = n - 1 - row % n; + let mut p = vec![0u8; chunk]; + for (k, disk) in (0..n).filter(|&i| i != pd).enumerate() { + let s = &logical[(row * dd + k) * chunk..(row * dd + k + 1) * chunk]; + disks[disk][row * chunk..(row + 1) * chunk].copy_from_slice(s); + for i in 0..chunk { + p[i] ^= s[i]; + } + } + disks[pd][row * chunk..(row + 1) * chunk].copy_from_slice(&p); + } + let order = [1usize, 2, 0]; // fornecidos fora de ordem + let mut paths = vec![]; + for (j, &i) in order.iter().enumerate() { + let p = dir.path().join(format!("m{j}.img")); + std::fs::write(&p, &disks[i]).unwrap(); + paths.push(p); + } + let base: Arc = Arc::new(FileSource::open(&paths[0]).unwrap()); + let asm = Assembly { + members: paths[1..].iter().map(|p| p.to_string_lossy().to_string()).collect(), + raid: Some(guara_core::assemble::RaidSpec::default()), + ..Default::default() + }; + let a = assemble_source(base, &asm).unwrap(); + assert!(a.skip_primary); + assert!(a.notes.iter().any(|n| n.contains("descobertos")), "{:?}", a.notes); + let opts = ScanOptions { mode: ScanMode::Deep, extra_parts: a.extra_parts.clone(), skip_primary: true, ..Default::default() }; + let mut sink = Collect::default(); + let out = scan::run(a.source.as_ref(), &opts, None, &[], &|| false, &mut sink); + assert!(matches!(out, Outcome::Completed)); + let f = sink.found.iter().find(|f| f.name == "perdido.dat").expect("perdido.dat"); + let dest = tempfile::tempdir().unwrap(); + let rep = recover::recover(a.source.as_ref(), &[f], dest.path(), &|| false, &mut |_, _, _| {}).unwrap(); + 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() == doc); +} diff --git a/crates/guara-core/tests/raid_detect.rs b/crates/guara-core/tests/raid_detect.rs new file mode 100644 index 0000000..39f0e3e --- /dev/null +++ b/crates/guara-core/tests/raid_detect.rs @@ -0,0 +1,84 @@ +//! Descoberta automática dos parâmetros de um RAID sem metadados. + +use guara_core::raid::Level; +use guara_core::raid_detect; +use guara_core::source::{MemSource, Source}; +use std::process::Command; +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 ext4_image(mib: u64) -> Option> { + if !have("mke2fs") { + return None; + } + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + for d in 0..12 { + std::fs::create_dir_all(root.join(format!("pasta{d}/sub"))).unwrap(); + for f in 0..40 { + std::fs::write(root.join(format!("pasta{d}/arq{f}.txt")), vec![b'a' + (f % 26) as u8; 300 + f * 37]).unwrap(); + } + } + let img = dir.path().join("e.img"); + std::fs::File::create(&img).unwrap().set_len(mib << 20).unwrap(); + assert!(Command::new("mke2fs").args(["-q", "-F", "-t", "ext4", "-b", "1024", "-d"]).arg(&root).arg(&img).status().unwrap().success()); + Some(std::fs::read(img).unwrap()) +} + +#[test] +fn finds_raid5_chunk_layout_and_shuffled_order() { + let Some(logical) = ext4_image(24) else { return }; + let (n, chunk) = (3usize, 32768usize); + let dd = n - 1; + let rows = logical.len() / (chunk * dd); + let logical = &logical[..rows * chunk * dd]; + // right-asymmetric + let mut disks = vec![vec![0u8; rows * chunk]; n]; + for row in 0..rows { + let pd = row % n; + let order: Vec = (0..n).filter(|&i| i != pd).collect(); + let mut p = vec![0u8; chunk]; + for (k, &disk) in order.iter().enumerate() { + let s = &logical[(row * dd + k) * chunk..(row * dd + k + 1) * chunk]; + disks[disk][row * chunk..(row + 1) * chunk].copy_from_slice(s); + for i in 0..chunk { + p[i] ^= s[i]; + } + } + disks[pd][row * chunk..(row + 1) * chunk].copy_from_slice(&p); + } + // Entrega os discos embaralhados: fornecidos = [2, 0, 1] do array real. + let given: Vec> = [2usize, 0, 1].iter().map(|&i| Arc::new(MemSource(disks[i].clone())) as Arc).collect(); + let found = raid_detect::detect(&given, &|| false); + assert!(!found.is_empty()); + for c in found.iter().take(6) { + eprintln!("CAND {:?} {} {:?} {:?} {:.2}", c.level, c.chunk, c.layout, c.order, c.score); + } + let best = &found[0]; + assert_eq!(best.level, Level::R5); + assert_eq!(best.chunk, chunk as u64); + // Layouts e ordens diferentes podem ser equivalentes; o que importa é o conteúdo montado. + let r = guara_core::raid::RaidSource::new(best.params(&given)).unwrap(); + let got = guara_core::source::read_vec(&r, 0, logical.len()).unwrap(); + assert!(got == logical, "montagem escolhida ({:?}, {:?}, {:?}) não reproduz o volume", best.layout, best.order, best.level); +} + +#[test] +fn finds_raid0() { + let Some(logical) = ext4_image(24) else { return }; + let (n, chunk) = (2usize, 65536usize); + let rows = logical.len() / (chunk * n); + let mut disks = vec![vec![]; n]; + for c in 0..rows * n { + disks[c % n].extend_from_slice(&logical[c * chunk..(c + 1) * chunk]); + } + let given: Vec> = [1usize, 0].iter().map(|&i| Arc::new(MemSource(disks[i].clone())) as Arc).collect(); + let found = raid_detect::detect(&given, &|| false); + let best = &found[0]; + assert_eq!(best.level, Level::R0); + assert_eq!(best.chunk, chunk as u64); + assert_eq!(best.order, vec![1, 0]); +}