diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index f05e6b6..4b7747e 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -61,7 +61,7 @@ enum Cmd { Image { path: String, dest: String, - /// Formato da imagem: raw (padrão) ou e01 (Expert Witness, com hash e metadados) + /// Formato da imagem: raw (padrão), e01 (Expert Witness) ou aff4 (AFF4 v1.0), com hash e metadados #[arg(long, default_value = "raw")] format: String, /// E01: número do caso @@ -413,6 +413,25 @@ fn run(cli: Cli) -> Result<(), String> { eng.save_session(&job, &session).map_err(s)?; Ok(()) } + Cmd::Verify { path } if path.to_ascii_lowercase().ends_with(".aff4") => { + let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(&path)).map_err(s)?; + println!("Formato: AFF4"); + for (k, v) in &a.info { + println!("{k}: {v}"); + } + eprintln!("Recalculando o hash..."); + match a.verify_stream_md5().map_err(s)? { + Some(true) => { + println!("MD5 gravado e recalculado conferem."); + Ok(()) + } + Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()), + None => { + println!("Esta imagem não traz MD5 para conferir."); + Ok(()) + } + } + } Cmd::Verify { path } => { let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?; println!("Formato: {}", if e.info.version == 2 { "Ex01 (EnCase 7)" } else { "E01 (EnCase)" }); @@ -445,7 +464,8 @@ fn run(cli: Cli) -> Result<(), String> { segment_mib, compression, }, - x => return Err(format!("formato de imagem desconhecido: {x} (use raw ou e01)")), + "aff4" => guara_core::image::ImageFormat::Aff4 { case_number: case, examiner, evidence_number: evidence, notes }, + x => return Err(format!("formato de imagem desconhecido: {x} (use raw, e01 ou aff4)")), }; let job = eng.start_image_as(&path, &dest, &fmt).map_err(s)?; let st = wait(&eng, &job, None)?; diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index bff50ee..1210799 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -20,6 +20,9 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] thiserror = "2" tiny_http = "0.12" bzip2 = "0.6" +zip = { version = "2", default-features = false, features = ["deflate"] } +snap = "1" +lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] } aes = "0.8" hmac = "0.12" pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } diff --git a/crates/guara-core/src/aff4.rs b/crates/guara-core/src/aff4.rs new file mode 100644 index 0000000..a0ef03c --- /dev/null +++ b/crates/guara-core/src/aff4.rs @@ -0,0 +1,724 @@ +//! AFF4 (Advanced Forensic Format 4, padrão v1.0): contêiner ZIP com metadados em Turtle. +//! +//! Leitura de imagens lineares e mapeadas (inclusive esparsas), com blocos comprimidos por +//! snappy, deflate, LZ4 ou sem compressão. Gravação de uma imagem linear com blocos snappy. +//! Conjuntos em vários volumes (spanned/striped) não são suportados. + +use crate::error::{Error, Result}; +use crate::hashing::MultiHasher; +use crate::image::ImageReport; +use crate::source::{read_exact_at, Source}; +use std::collections::HashMap; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::path::Path; +use std::sync::Mutex; + +pub fn is_aff4(head: &[u8]) -> bool { + // ZIP local file header + primeiro nome "container.description" ou "version.txt". + head.len() > 50 && head.starts_with(b"PK\x03\x04") && (find(&head[30..head.len().min(80)], b"container.description") || find(&head[30..head.len().min(80)], b"version.txt")) +} + +fn find(h: &[u8], n: &[u8]) -> bool { + h.windows(n.len()).any(|w| w == n) +} + +const NS: &str = "http://aff4.org/Schema#"; + +// ------------------------------------------------------------------ Turtle + +#[derive(Debug, Default, Clone)] +struct Resource { + types: Vec, + props: HashMap>, +} + +impl Resource { + fn get(&self, k: &str) -> Option<&str> { + self.props.get(k).and_then(|v| v.first()).map(|s| s.as_str()) + } + fn num(&self, k: &str) -> Option { + self.get(k)?.parse().ok() + } + fn is(&self, t: &str) -> bool { + self.types.iter().any(|x| x == t) + } +} + +struct Turtle<'a> { + b: &'a [u8], + i: usize, + prefixes: HashMap, +} + +impl Turtle<'_> { + fn ws(&mut self) { + loop { + while self.i < self.b.len() && self.b[self.i].is_ascii_whitespace() { + self.i += 1; + } + if self.i < self.b.len() && self.b[self.i] == b'#' { + while self.i < self.b.len() && self.b[self.i] != b'\n' { + self.i += 1; + } + } else { + break; + } + } + } + fn expand(&self, tok: &str) -> String { + if let Some((p, l)) = tok.split_once(':') { + if let Some(base) = self.prefixes.get(p) { + return format!("{base}{l}"); + } + } + tok.to_string() + } + /// Um termo: , "literal"[^^tipo], nome:prefixado ou `a`. + fn term(&mut self) -> Option { + self.ws(); + let c = *self.b.get(self.i)?; + match c { + b'<' => { + let s = self.i + 1; + while self.i < self.b.len() && self.b[self.i] != b'>' { + self.i += 1; + } + let v = String::from_utf8_lossy(&self.b[s..self.i]).to_string(); + self.i += 1; + Some(v) + } + b'"' => { + self.i += 1; + let mut out = Vec::new(); + while self.i < self.b.len() && self.b[self.i] != b'"' { + if self.b[self.i] == b'\\' && self.i + 1 < self.b.len() { + self.i += 1; + } + out.push(self.b[self.i]); + self.i += 1; + } + self.i += 1; + if self.b[self.i..].starts_with(b"^^") { + self.i += 2; + let _ = self.term(); + } else if self.b.get(self.i) == Some(&b'@') { + while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() { + self.i += 1; + } + } + Some(String::from_utf8_lossy(&out).to_string()) + } + _ => { + let s = self.i; + while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !matches!(self.b[self.i], b';' | b',') { + self.i += 1; + } + // ponto final de sentença colado ao termo + let mut e = self.i; + if e > s && self.b[e - 1] == b'.' { + e -= 1; + self.i = e; + } + let tok = String::from_utf8_lossy(&self.b[s..e]).to_string(); + Some(if tok == "a" { tok } else { self.expand(&tok) }) + } + } + } +} + +fn parse_turtle(text: &str) -> HashMap { + let mut t = Turtle { b: text.as_bytes(), i: 0, prefixes: HashMap::new() }; + let mut out: HashMap = HashMap::new(); + loop { + t.ws(); + if t.i >= t.b.len() { + break; + } + if t.b[t.i..].starts_with(b"@prefix") { + t.i += 7; + t.ws(); + let s = t.i; + while t.i < t.b.len() && t.b[t.i] != b':' { + t.i += 1; + } + let name = String::from_utf8_lossy(&t.b[s..t.i]).trim().to_string(); + t.i += 1; + let iri = t.term().unwrap_or_default(); + t.ws(); + if t.b.get(t.i) == Some(&b'.') { + t.i += 1; + } + t.prefixes.insert(name, iri); + continue; + } + // sujeito + let Some(subj) = (if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) { + t.i += 1; + Some(t.prefixes.get("").cloned().unwrap_or_default()) + } else { + t.term() + }) else { + break; + }; + let res = out.entry(subj).or_default(); + loop { + let Some(pred) = t.term() else { return out }; + let mut objs = vec![]; + loop { + let Some(o) = t.term() else { return out }; + objs.push(o); + t.ws(); + if t.b.get(t.i) == Some(&b',') { + t.i += 1; + continue; + } + break; + } + if pred == "a" { + res.types.extend(objs); + } else { + res.props.entry(pred).or_default().extend(objs); + } + t.ws(); + match t.b.get(t.i) { + Some(b';') => { + t.i += 1; + t.ws(); + // ";" final antes de "." + if t.b.get(t.i) == Some(&b'.') { + t.i += 1; + break; + } + } + Some(b'.') => { + t.i += 1; + break; + } + _ => break, + } + } + } + out +} + +// ------------------------------------------------------------------ leitor + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Method { + Stored, + Deflate, + Zlib, + Snappy, + Lz4, +} + +#[derive(Clone)] +struct ImageStream { + urn: String, + chunk: u64, + per_bevy: u64, + method: Method, + size: u64, +} + +#[derive(Clone, Copy)] +struct MapEntry { + start: u64, + len: u64, + target_off: u64, + target: u32, +} + +#[derive(Clone)] +enum Target { + Stream(usize), + Zero, + Fill(u8), +} + +#[derive(Clone, Copy)] +struct Loc { + data_start: u64, + size: u64, +} + +pub struct Aff4Source { + file: File, + entries: HashMap, + streams: Vec, + targets: Vec, + map: Vec, + size: u64, + /// Metadados legíveis: caso, examinador, notas, hashes. + pub info: HashMap, + cache: Mutex)>>, + index_cache: Mutex>>>, +} + +fn enc(urn: &str) -> String { + urn.replace(':', "%3A").replace('/', "%2F") +} + +fn method_of(uri: &str) -> Method { + let u = uri.to_ascii_lowercase(); + if u.contains("snappy") { + Method::Snappy + } else if u.contains("lz4") { + Method::Lz4 + } else if u.contains("rfc1951") { + Method::Deflate + } else if u.contains("rfc1950") { + Method::Zlib + } else { + Method::Stored + } +} + +impl Aff4Source { + pub fn open(path: &Path) -> Result { + let file = File::open(path)?; + let mut zip = zip::ZipArchive::new(file.try_clone()?).map_err(|e| Error::msg(format!("AFF4 inválido (ZIP): {e}")))?; + let mut entries = HashMap::new(); + for i in 0..zip.len() { + let f = zip.by_index_raw(i).map_err(|e| Error::msg(e.to_string()))?; + entries.insert(f.name().to_string(), Loc { data_start: f.data_start(), size: f.compressed_size() }); + } + let turtle = { + let mut f = zip.by_name("information.turtle").map_err(|_| Error::msg("AFF4 sem information.turtle"))?; + let mut s = String::new(); + f.read_to_string(&mut s)?; + s + }; + let res = parse_turtle(&turtle); + let img_t = format!("{NS}Image"); + let disk_t = format!("{NS}DiskImage"); + let ds = format!("{NS}dataStream"); + let image = res + .values() + .find(|r| (r.is(&img_t) || r.is(&disk_t)) && r.get(&ds).is_some()) + .ok_or_else(|| Error::msg("AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)"))?; + let mut info = HashMap::new(); + if let Some(v) = image.get(&format!("{NS}diskModel")) { + info.insert("Modelo do disco".to_string(), v.to_string()); + } + for (k, label) in [("caseNumber", "Número do caso"), ("examiner", "Examinador"), ("notes", "Notas"), ("evidenceNumber", "Número da evidência"), ("caseName", "Nome do caso")] { + if let Some(v) = res.values().find_map(|r| r.get(&format!("{NS}{k}"))) { + info.insert(label.to_string(), v.to_string()); + } + } + let data_urn = image.get(&ds).unwrap().to_string(); + // Hashes gravados no fluxo de dados (ou no mapa). + for r in res.values().filter(|r| r.get(&format!("{NS}hash")).is_some()) { + for h in r.props.get(&format!("{NS}hash")).into_iter().flatten() { + match h.len() { + 32 if h.chars().all(|c| c.is_ascii_hexdigit()) => { + info.entry("md5".to_string()).or_insert_with(|| h.clone()); + } + 40 if h.chars().all(|c| c.is_ascii_hexdigit()) => { + info.entry("sha1".to_string()).or_insert_with(|| h.clone()); + } + _ => {} + } + } + } + let mut me = Self { + file, + entries, + streams: vec![], + targets: vec![], + map: vec![], + size: 0, + info, + cache: Mutex::new(None), + index_cache: Mutex::new(HashMap::new()), + }; + let top = res.get(&data_urn).ok_or_else(|| Error::msg("AFF4: fluxo de dados da imagem não encontrado"))?; + let map_t = format!("{NS}Map"); + if top.is(&map_t) { + me.size = top.num(&format!("{NS}size")).unwrap_or(0); + let mp = enc(&data_urn); + let idx_txt = me.read_entry(&format!("{mp}/idx"))?; + for line in String::from_utf8_lossy(&idx_txt).lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(sym) = line.strip_prefix(&format!("{NS}SymbolicStream")) { + let b = u8::from_str_radix(sym, 16).map_err(|_| Error::msg("AFF4: fluxo simbólico inválido"))?; + me.targets.push(Target::Fill(b)); + } else if line == format!("{NS}Zero") { + me.targets.push(Target::Zero); + } else if line.starts_with(NS) { + me.targets.push(Target::Zero); + } else { + let s = me.load_stream(&res, line)?; + me.streams.push(s); + me.targets.push(Target::Stream(me.streams.len() - 1)); + } + } + let m = me.read_entry(&format!("{mp}/map"))?; + for c in m.chunks_exact(28) { + me.map.push(MapEntry { + start: u64::from_le_bytes(c[0..8].try_into().unwrap()), + len: u64::from_le_bytes(c[8..16].try_into().unwrap()), + target_off: u64::from_le_bytes(c[16..24].try_into().unwrap()), + target: u32::from_le_bytes(c[24..28].try_into().unwrap()), + }); + } + me.map.sort_by_key(|e| e.start); + } else { + let s = me.load_stream(&res, &data_urn)?; + me.size = s.size; + me.streams.push(s); + me.targets.push(Target::Stream(0)); + me.map = vec![MapEntry { start: 0, len: me.size, target_off: 0, target: 0 }]; + } + if me.size == 0 { + me.size = image.num(&format!("{NS}size")).unwrap_or(0); + } + Ok(me) + } + + fn load_stream(&self, res: &HashMap, urn: &str) -> Result { + let r = res.get(urn).ok_or_else(|| Error::msg(format!("AFF4: fluxo {urn} não descrito")))?; + Ok(ImageStream { + urn: urn.to_string(), + chunk: r.num(&format!("{NS}chunkSize")).unwrap_or(32768), + per_bevy: r.num(&format!("{NS}chunksInSegment")).unwrap_or(2048), + method: r.get(&format!("{NS}compressionMethod")).map(method_of).unwrap_or(Method::Stored), + size: r.num(&format!("{NS}size")).unwrap_or(0), + }) + } + + /// MD5 do primeiro fluxo de blocos (o que o AFF4 grava em `aff4:hash`), para conferir a integridade. + pub fn verify_stream_md5(&self) -> Result> { + let Some(want) = self.info.get("md5").cloned() else { return Ok(None) }; + let Some(st) = self.streams.first() else { return Ok(None) }; + let mut h = MultiHasher::new(); + let mut buf = vec![0u8; st.chunk as usize]; + let mut pos = 0u64; + while pos < st.size { + let n = (st.size - pos).min(st.chunk) as usize; + self.read_stream(0, pos, &mut buf[..n])?; + h.update(&buf[..n]); + pos += n as u64; + } + Ok(Some(h.finalize().md5 == want)) + } + + fn read_entry(&self, name: &str) -> Result> { + let loc = self.entries.get(name).ok_or_else(|| Error::msg(format!("AFF4: entrada {name} ausente")))?; + let mut v = vec![0u8; loc.size as usize]; + read_exact_at_file(&self.file, loc.data_start, &mut v)?; + Ok(v) + } + + fn chunk(&self, si: usize, chunk_no: u64) -> io::Result> { + { + let c = self.cache.lock().unwrap(); + if let Some((s, n, d)) = c.as_ref() { + if *s == si && *n == chunk_no { + return Ok(d.clone()); + } + } + } + let st = &self.streams[si]; + let (bevy, within) = (chunk_no / st.per_bevy, chunk_no % st.per_bevy); + let base = format!("{}/{:08}", enc(&st.urn), bevy); + let index = { + let mut ic = self.index_cache.lock().unwrap(); + if let Some(i) = ic.get(&(si, bevy)) { + i.clone() + } else { + let loc = self.entries.get(&format!("{base}.index")).ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "índice do bevy ausente"))?; + let mut v = vec![0u8; loc.size as usize]; + read_exact_at_file(&self.file, loc.data_start, &mut v)?; + let a = std::sync::Arc::new(v); + ic.insert((si, bevy), a.clone()); + a + } + }; + let e = index.get(within as usize * 12..within as usize * 12 + 12).ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "bloco fora do índice"))?; + let (off, len) = (u64::from_le_bytes(e[0..8].try_into().unwrap()), u32::from_le_bytes(e[8..12].try_into().unwrap()) as usize); + let loc = self.entries.get(&base).ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bevy ausente"))?; + let mut raw = vec![0u8; len]; + read_exact_at_file(&self.file, loc.data_start + off, &mut raw)?; + let want = st.chunk.min(st.size.saturating_sub(chunk_no * st.chunk)) as usize; + let data = if len == st.chunk as usize || st.method == Method::Stored { + raw + } else { + let bad = |m: String| io::Error::new(io::ErrorKind::InvalidData, m); + match st.method { + Method::Snappy => snap::raw::Decoder::new().decompress_vec(&raw).map_err(|e| bad(format!("snappy: {e}")))?, + Method::Lz4 => lz4_flex::block::decompress(&raw, st.chunk as usize).map_err(|e| bad(format!("lz4: {e}")))?, + Method::Deflate => { + let mut v = Vec::with_capacity(st.chunk as usize); + flate2::read::DeflateDecoder::new(&raw[..]).read_to_end(&mut v).map_err(|e| bad(format!("deflate: {e}")))?; + v + } + Method::Zlib => { + let mut v = Vec::with_capacity(st.chunk as usize); + flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut v).map_err(|e| bad(format!("zlib: {e}")))?; + v + } + Method::Stored => unreachable!(), + } + }; + let mut data = data; + data.resize(want.max(data.len().min(st.chunk as usize)), 0); + *self.cache.lock().unwrap() = Some((si, chunk_no, data.clone())); + Ok(data) + } + + fn read_stream(&self, si: usize, off: u64, buf: &mut [u8]) -> io::Result<()> { + let cs = self.streams[si].chunk; + let mut done = 0; + while done < buf.len() { + let pos = off + done as u64; + let (cn, within) = (pos / cs, (pos % cs) as usize); + let d = self.chunk(si, cn)?; + if within >= d.len() { + buf[done..].fill(0); + return Ok(()); + } + let n = (d.len() - within).min(buf.len() - done); + buf[done..done + n].copy_from_slice(&d[within..within + n]); + done += n; + } + Ok(()) + } +} + +fn read_exact_at_file(f: &File, off: u64, buf: &mut [u8]) -> io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_exact_at(f, buf, off) + } + #[cfg(windows)] + { + let mut done = 0; + while done < buf.len() { + let n = std::os::windows::fs::FileExt::seek_read(f, &mut buf[done..], off + done as u64)?; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "AFF4 truncado")); + } + done += n; + } + Ok(()) + } +} + +impl Source for Aff4Source { + 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 mut done = 0usize; + while done < want { + let pos = offset + done as u64; + let i = self.map.partition_point(|e| e.start + e.len <= pos); + let Some(e) = self.map.get(i).filter(|e| e.start <= pos) else { + // vão do mapa: zeros até a próxima entrada + let next = self.map.get(i).map(|e| e.start).unwrap_or(self.size); + let n = ((next - pos) as usize).min(want - done); + buf[done..done + n].fill(0); + done += n; + continue; + }; + let n = ((e.start + e.len - pos) as usize).min(want - done); + let toff = e.target_off + (pos - e.start); + match self.targets.get(e.target as usize) { + Some(Target::Stream(si)) => self.read_stream(*si, toff, &mut buf[done..done + n])?, + Some(Target::Fill(b)) => buf[done..done + n].fill(*b), + _ => buf[done..done + n].fill(0), + } + done += n; + } + Ok(done) + } +} + +// ------------------------------------------------------------------ gravação + +#[derive(Debug, Clone)] +pub struct Aff4Options { + pub case_name: String, + pub examiner: String, + pub notes: String, + pub evidence_number: String, + pub chunk_size: u32, +} + +impl Default for Aff4Options { + fn default() -> Self { + Self { case_name: String::new(), examiner: String::new(), notes: String::new(), evidence_number: String::new(), chunk_size: 32768 } + } +} + +fn new_uuid() -> String { + let mut b = [0u8; 16]; + let _ = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut b)); + if b == [0u8; 16] { + let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(1); + b = (t ^ (std::process::id() as u128) << 64).to_le_bytes(); + } + b[6] = (b[6] & 0x0F) | 0x40; + b[8] = (b[8] & 0x3F) | 0x80; + let h: Vec = b.iter().map(|x| format!("{x:02x}")).collect(); + format!("{}-{}-{}-{}-{}", h[0..4].concat(), h[4..6].concat(), h[6..8].concat(), h[8..10].concat(), h[10..16].concat()) +} + +fn iso_now() -> String { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + let days = secs.div_euclid(86400); + let rem = secs.rem_euclid(86400); + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z", rem / 3600, rem % 3600 / 60, rem % 60) +} + +fn esc(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n") +} + +/// Grava uma imagem AFF4 linear (um fluxo de blocos snappy) de `src`. +pub fn write_aff4( + src: &dyn Source, + dest: &Path, + opts: &Aff4Options, + cancelled: &(dyn Fn() -> bool + Sync), + progress: &mut dyn FnMut(u64, u64), +) -> Result { + let out = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(dest) + .map_err(|e| Error::msg(format!("não foi possível criar {}: {e} (o arquivo não pode existir)", dest.display())))?; + crate::owner::give_to_user(dest); + let total = src.size(); + let chunk = opts.chunk_size as u64; + let per_bevy = 2048u64; + let volume = format!("aff4://{}", new_uuid()); + let stream = format!("aff4://{}", new_uuid()); + let image = format!("aff4://{}", new_uuid()); + let mut zip = zip::ZipWriter::new(out); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true); + let result = (|| -> Result { + zip.start_file("container.description", stored).map_err(zerr)?; + zip.write_all(volume.as_bytes())?; + zip.start_file("version.txt", stored).map_err(zerr)?; + zip.write_all(format!("major=1\nminor=2\ntool=Guara {}\n", crate::VERSION).as_bytes())?; + + let mut rep = ImageReport::default(); + let mut hasher = MultiHasher::new(); + let mut buf = vec![0u8; chunk as usize]; + let mut pos = 0u64; + let mut chunk_no = 0u64; + let mut enc_snap = snap::raw::Encoder::new(); + while pos < total { + let bevy = chunk_no / per_bevy; + let name = format!("{}/{:08}", enc(&stream), bevy); + zip.start_file(name.as_str(), stored).map_err(zerr)?; + let mut index: Vec = vec![]; + let mut boff = 0u64; + for _ in 0..per_bevy { + if pos >= total { + break; + } + if cancelled() { + rep.cancelled = true; + break; + } + let want = (total - pos).min(chunk) as usize; + let b = &mut buf[..want]; + if read_exact_at(src, pos, b).is_err() { + let mut o = 0; + while o < want { + let n = 512.min(want - o); + if read_exact_at(src, pos + o as u64, &mut b[o..o + n]).is_err() { + b[o..o + n].fill(0); + match rep.bad_ranges.last_mut() { + Some(l) if l.0 + l.1 == pos + o as u64 => l.1 += n as u64, + _ => rep.bad_ranges.push((pos + o as u64, n as u64)), + } + } + o += n; + } + } + hasher.update(b); + // bloco curto (último) é guardado como está; comprimido só se ficar menor + let comp = enc_snap.compress_vec(b).map_err(|e| Error::msg(e.to_string()))?; + // Só um bloco cheio pode ficar sem compressão (comprimento == tamanho do bloco). + let data: &[u8] = if want as u64 == chunk && comp.len() >= want { b } else { &comp }; + zip.write_all(data)?; + index.extend_from_slice(&boff.to_le_bytes()); + index.extend_from_slice(&(data.len() as u32).to_le_bytes()); + boff += data.len() as u64; + pos += want as u64; + chunk_no += 1; + progress(pos, total); + } + zip.start_file(format!("{name}.index").as_str(), stored).map_err(zerr)?; + zip.write_all(&index)?; + if rep.cancelled { + break; + } + } + let h = hasher.finalize(); + rep.bytes = pos; + rep.md5 = h.md5.clone(); + rep.sha1 = h.sha1.clone(); + rep.sha256 = h.sha256.clone(); + + let now = iso_now(); + let sectors = total.div_ceil(512); + let ttl = format!( + "@prefix aff4: .\n@prefix xsd: .\n@prefix rdf: .\n\n\ +<{stream}>\n a aff4:ImageStream ;\n aff4:chunkSize \"{chunk}\"^^xsd:int ;\n aff4:chunksInSegment \"{per_bevy}\"^^xsd:int ;\n aff4:compressionMethod ;\n aff4:hash \"{sha1}\"^^aff4:SHA1 , \"{md5}\"^^aff4:MD5 ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:version \"1\"^^xsd:int .\n\n\ +<{image}>\n a aff4:ContiguousImage , aff4:DiskImage , aff4:Image ;\n aff4:blockSize \"512\"^^xsd:int ;\n aff4:dataStream <{stream}> ;\n aff4:sectorCount \"{sectors}\"^^xsd:long ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> .\n\n\ +\n a aff4:CaseNotes ;\n aff4:caseNumber \"{case}\" ;\n aff4:evidenceNumber \"{ev}\" ;\n aff4:examiner \"{ex}\" ;\n aff4:notes \"{notes}\" ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:timestamp \"{now}\"^^xsd:dateTime .\n\n\ +<{volume}>\n a aff4:ZipVolume ;\n aff4:contains <{stream}> , <{image}> ;\n aff4:creationTime \"{now}\"^^xsd:dateTime ;\n aff4:interface aff4:Volume ;\n aff4:stored \"{fname}\" .\n", + sha1 = h.sha1, + md5 = h.md5, + notes_id = new_uuid(), + case = esc(&opts.case_name), + ev = esc(&opts.evidence_number), + ex = esc(&opts.examiner), + notes = esc(&opts.notes), + fname = esc(&dest.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()), + ); + zip.start_file("information.turtle", stored).map_err(zerr)?; + zip.write_all(ttl.as_bytes())?; + Ok(rep) + })(); + match result { + Ok(rep) => { + zip.finish().map_err(zerr)?; + crate::owner::write_owned(&dest.with_extension("map.json"), serde_json::to_vec_pretty(&rep)?)?; + Ok(rep) + } + Err(e) => { + drop(zip); + let _ = std::fs::remove_file(dest); + Err(e) + } + } +} + +fn zerr(e: zip::result::ZipError) -> Error { + Error::msg(format!("AFF4/ZIP: {e}")) +} diff --git a/crates/guara-core/src/image.rs b/crates/guara-core/src/image.rs index 1bd3934..4a5d7eb 100644 --- a/crates/guara-core/src/image.rs +++ b/crates/guara-core/src/image.rs @@ -48,6 +48,17 @@ pub enum ImageFormat { #[serde(default = "default_compression")] compression: u32, }, + /// AFF4 (padrão v1.0, blocos snappy). + Aff4 { + #[serde(default)] + case_number: String, + #[serde(default)] + examiner: String, + #[serde(default)] + evidence_number: String, + #[serde(default)] + notes: String, + }, } fn default_segment_mib() -> u64 { @@ -66,6 +77,17 @@ pub fn create_image_as( ) -> Result { match format { ImageFormat::Raw => create_image(src, dest, cancelled, progress), + ImageFormat::Aff4 { case_number, examiner, evidence_number, notes } => { + let opts = crate::aff4::Aff4Options { + case_name: case_number.clone(), + examiner: examiner.clone(), + notes: notes.clone(), + evidence_number: evidence_number.clone(), + ..Default::default() + }; + let dest = if dest.extension().map_or(false, |e| e.eq_ignore_ascii_case("aff4")) { dest.to_path_buf() } else { dest.with_extension("aff4") }; + crate::aff4::write_aff4(src, &dest, &opts, cancelled, progress) + } ImageFormat::E01 { case_number, description, evidence_number, examiner, notes, segment_mib, compression } => { let opts = crate::ewf::E01Options { case_number: case_number.clone(), diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 7ddf2a2..0855532 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 aff4; pub mod assemble; pub mod carve; pub mod defrag; diff --git a/crates/guara-core/src/source.rs b/crates/guara-core/src/source.rs index 2789ac1..2d85513 100644 --- a/crates/guara-core/src/source.rs +++ b/crates/guara-core/src/source.rs @@ -274,8 +274,13 @@ pub fn open_path(path: &Path) -> io::Result> { } let file = FileSource::open(path)?; // Imagens forenses EWF (E01/Ex01) são abertas como o disco que contêm. - let mut head = [0u8; 8]; - if file.read_at(0, &mut head).unwrap_or(0) == 8 && crate::ewf::is_ewf_header(&head) { + let mut head = [0u8; 96]; + let got = file.read_at(0, &mut head).unwrap_or(0); + if got >= 4 && head.starts_with(b"PK") && crate::aff4::is_aff4(&head[..got]) { + let a = crate::aff4::Aff4Source::open(path).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + return Ok(Arc::new(a)); + } + if got >= 8 && crate::ewf::is_ewf_header(&head) { let e = crate::ewf::EwfSource::open(path).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; return Ok(Arc::new(e)); } diff --git a/crates/guara-core/tests/aff4.rs b/crates/guara-core/tests/aff4.rs new file mode 100644 index 0000000..27a28cc --- /dev/null +++ b/crates/guara-core/tests/aff4.rs @@ -0,0 +1,63 @@ +//! AFF4: imagens de referência da Evimetry (github.com/aff4/ReferenceImages) e ida e volta. + +use guara_core::aff4::{write_aff4, Aff4Options, Aff4Source}; +use guara_core::source::{open_path, read_vec, MemSource, Source}; +use std::path::PathBuf; + +fn dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/aff4") +} + +#[test] +fn reads_reference_images_and_stored_hash_matches() { + let mut seen = 0; + for name in ["Base-Linear.aff4", "Base-Allocated.aff4", "Base-Linear-AllHashes.aff4"] { + let p = dir().join(name); + if !p.exists() { + eprintln!("SKIP {name}"); + continue; + } + let a = Aff4Source::open(&p).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(a.size(), 268_435_456, "{name}"); + assert_eq!(a.verify_stream_md5().unwrap(), Some(true), "{name}: hash do fluxo"); + // setor 0 legível pelo caminho genérico + let s = open_path(&p).unwrap(); + let head = read_vec(s.as_ref(), 0, 4096).unwrap(); + assert_eq!(head.len(), 4096); + // leitura em vãos do mapa devolve zeros + let far = read_vec(s.as_ref(), 200 << 20, 512).unwrap(); + assert_eq!(far.len(), 512); + eprintln!("{name}: {} {:?}", a.info.get("Examinador").cloned().unwrap_or_default(), guara_core::partition::detect_fs_in(&read_vec(s.as_ref(), 0, 4096).unwrap()).map(|x| x.0)); + seen += 1; + } + if seen == 0 { + eprintln!("SKIP: execute scripts/fetch-testdata.sh"); + } +} + +#[test] +fn roundtrip_with_snappy_blocks_and_odd_size() { + let mut x = 77u64; + let mut data: Vec = (0..5_000_001u32) + .map(|i| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + if i % 100_000 < 60_000 { 0 } else { (x >> 24) as u8 } + }) + .collect(); + data.truncate(5_000_001); + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("t.aff4"); + let opts = Aff4Options { case_name: "caso".into(), examiner: "Lucas".into(), notes: "teste".into(), ..Default::default() }; + let rep = write_aff4(&MemSource(data.clone()), &p, &opts, &|| false, &mut |_, _| {}).unwrap(); + let a = Aff4Source::open(&p).unwrap(); + assert_eq!(a.size(), data.len() as u64); + assert_eq!(a.info.get("md5").map(String::as_str), Some(rep.md5.as_str())); + assert_eq!(a.info.get("Examinador").map(String::as_str), Some("Lucas")); + assert_eq!(a.verify_stream_md5().unwrap(), Some(true)); + let got = read_vec(&a, 0, data.len()).unwrap(); + assert!(got == data); + let part = read_vec(&a, 123_456, 70_000).unwrap(); + assert_eq!(part, &data[123_456..193_456]); +} diff --git a/scripts/fetch-testdata.sh b/scripts/fetch-testdata.sh index 8683307..ec0104f 100755 --- a/scripts/fetch-testdata.sh +++ b/scripts/fetch-testdata.sh @@ -16,4 +16,8 @@ mkdir -p "$dest/dfvfs" for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 luks1.raw luks2.raw bdetogo.raw; do [ -f "$dest/dfvfs/$f" ] || curl -fsSL "https://raw.githubusercontent.com/log2timeline/dfvfs/main/test_data/$f" -o "$dest/dfvfs/$f" done +mkdir -p "$dest/aff4" +for f in Base-Linear.aff4 Base-Allocated.aff4 Base-Linear-AllHashes.aff4; do + [ -f "$dest/aff4/$f" ] || curl -fsSL "https://raw.githubusercontent.com/aff4/ReferenceImages/master/AFF4Std/$f" -o "$dest/aff4/$f" +done echo "ok: $dest"