//! Minimal UBIFS reader. //! //! Rather than walking the on-flash B-tree index (which vendor images often //! corrupt or lay out non-standardly), this performs a tolerant *linear* scan //! of every UBIFS node in a reconstructed volume image. It collects inode, //! directory-entry and data nodes, validates each with its CRC-32, then //! rebuilds the directory tree and writes out regular files, directories and //! symlinks. use std::collections::HashMap; use std::fs::{self, File}; use std::io::{Seek, Write}; use std::path::{Path, PathBuf}; use flate2::Crc; /// UBIFS common node header magic (little-endian on disk: 31 18 10 06). const UBIFS_NODE_MAGIC: u32 = 0x0610_1831; // Node types. const INO_NODE: u8 = 0; const DATA_NODE: u8 = 1; const DENT_NODE: u8 = 2; // Header / node sizes. const CH_SZ: usize = 24; // common header const DATA_NODE_SZ: usize = 48; // header + key + size + compr fields const KEY_OFF: usize = 24; // UBIFS logical data block size (uncompressed). const UBIFS_BLOCK_SIZE: u64 = 4096; // Compression types. const COMPR_NONE: u16 = 0; const COMPR_LZO: u16 = 1; const COMPR_ZLIB: u16 = 2; const COMPR_ZSTD: u16 = 3; // Inode item types stored in directory entries. const ITYPE_REG: u8 = 0; const ITYPE_DIR: u8 = 1; const ITYPE_LNK: u8 = 2; /// Root inode number in UBIFS. const ROOT_INO: u32 = 1; struct Inode { size: u64, /// Inline data (symlink target for links). inline: Vec, } struct Dent { parent: u32, target: u32, itype: u8, name: String, } /// Extract a reconstructed UBIFS volume image into `out_dir`. Returns the /// number of filesystem objects created. pub fn extract_ubifs(image: &[u8], out_dir: &Path) -> Result> { let mut inodes: HashMap = HashMap::new(); let mut dents: Vec = Vec::new(); // inode -> block_no -> decompressed bytes let mut data: HashMap>> = HashMap::new(); scan_nodes(image, &mut inodes, &mut dents, &mut data); if inodes.is_empty() && dents.is_empty() { return Err("no UBIFS nodes found".into()); } // Build parent -> children map. let mut children: HashMap> = HashMap::new(); for d in &dents { children.entry(d.parent).or_default().push(d); } fs::create_dir_all(out_dir)?; let mut count = 0usize; let mut stack: Vec<(u32, PathBuf)> = vec![(ROOT_INO, out_dir.to_path_buf())]; let mut visited: HashMap = HashMap::new(); while let Some((ino, dir_path)) = stack.pop() { if visited.insert(ino, ()).is_some() { continue; // guard against cycles } let Some(kids) = children.get(&ino) else { continue; }; for d in kids { let child_path = dir_path.join(sanitize_component(&d.name)); match d.itype { ITYPE_DIR => { if fs::create_dir_all(&child_path).is_ok() { count += 1; stack.push((d.target, child_path)); } } ITYPE_REG => { if write_regular_file(&child_path, d.target, &inodes, &data) { count += 1; } } ITYPE_LNK => { if let Some(ino) = inodes.get(&d.target) { let target = String::from_utf8_lossy(&ino.inline).to_string(); // Portable: record symlinks as text files (Windows-safe). if fs::write(&child_path, format!("SYMLINK -> {target}")).is_ok() { count += 1; } } } _ => { // Special files (dev/fifo/sock): note as empty placeholder. let _ = File::create(&child_path); count += 1; } } } } Ok(count) } /// Assemble and write a regular file from its data blocks, truncated to the /// inode size. fn write_regular_file( path: &Path, ino: u32, inodes: &HashMap, data: &HashMap>>, ) -> bool { let size = inodes.get(&ino).map(|i| i.size).unwrap_or(0); let mut out = match File::create(path) { Ok(f) => f, Err(_) => return false, }; if let Some(blocks) = data.get(&ino) { let mut block_nos: Vec = blocks.keys().cloned().collect(); block_nos.sort_unstable(); for bn in block_nos { let expected_off = bn * UBIFS_BLOCK_SIZE; // Pad holes with zeros to keep byte offsets correct. let cur = out.stream_position().unwrap_or(0); if expected_off > cur { let hole = (expected_off - cur) as usize; let _ = out.write_all(&vec![0u8; hole]); } let _ = out.write_all(&blocks[&bn]); } } // Truncate to the exact inode size when known. if size > 0 { let _ = out.flush(); if let Ok(f) = File::options().write(true).open(path) { let _ = f.set_len(size); } } true } /// Linear scan of the volume image collecting UBIFS nodes, validated by CRC-32. fn scan_nodes( image: &[u8], inodes: &mut HashMap, dents: &mut Vec, data: &mut HashMap>>, ) { let mut off = 0usize; let n = image.len(); while off + CH_SZ <= n { // UBIFS nodes are 8-byte aligned. if le32(image, off) != UBIFS_NODE_MAGIC { off += 8; continue; } let crc = le32(image, off + 4); // Common header: magic(0) crc(4) sqnum(8,u64) len(16,u32) node_type(20). let len = le32(image, off + 16) as usize; let node_type = image[off + 20]; if len < CH_SZ || off + len > n { off += 8; continue; } // Validate CRC-32 over everything after the crc field. UBIFS uses the // Linux crc32() (init 0xFFFFFFFF, no final XOR), which is the bitwise // complement of the standard zlib CRC that flate2 computes. let mut c = Crc::new(); c.update(&image[off + 8..off + len]); if (c.sum() ^ 0xFFFF_FFFF) != crc { off += 8; continue; } let node = &image[off..off + len]; match node_type { INO_NODE => parse_ino(node, inodes), DENT_NODE => parse_dent(node, dents), DATA_NODE => parse_data(node, data), _ => {} } // Advance to the next 8-byte-aligned node. off += (len + 7) & !7; } } fn parse_ino(node: &[u8], inodes: &mut HashMap) { if node.len() < 160 { return; } let ino = le32(node, KEY_OFF); // key[0] = inode number let size = le64(node, 48); let mode = le32(node, 104); let data_len = le32(node, 112) as usize; let itype = mode_to_itype(mode); let mut inline = Vec::new(); if itype == ITYPE_LNK && data_len > 0 && 160 + data_len <= node.len() { inline = node[160..160 + data_len].to_vec(); } inodes.insert(ino, Inode { size, inline }); } fn parse_dent(node: &[u8], dents: &mut Vec) { if node.len() < 56 { return; } let parent = le32(node, KEY_OFF); // key[0] = parent inode let target = le32(node, 40); // low 32 bits of __le64 inum let itype = node[49]; let nlen = le16(node, 50) as usize; if 56 + nlen > node.len() { return; } let name = String::from_utf8_lossy(&node[56..56 + nlen]).to_string(); if name.is_empty() { return; } dents.push(Dent { parent, target, itype, name, }); } fn parse_data(node: &[u8], data: &mut HashMap>>) { if node.len() < DATA_NODE_SZ { return; } let ino = le32(node, KEY_OFF); // key[0] = inode number let block_no = (le32(node, KEY_OFF + 4) & 0x1FFF_FFFF) as u64; // key[1] low bits let out_len = le32(node, 40) as usize; let compr = le16(node, 44); let payload = &node[DATA_NODE_SZ..]; let decompressed = match decompress_block(payload, compr, out_len) { Some(d) => d, None => return, }; data.entry(ino).or_default().insert(block_no, decompressed); } /// Decompress a single UBIFS data block payload. fn decompress_block(payload: &[u8], compr: u16, out_len: usize) -> Option> { match compr { COMPR_NONE => { let take = out_len.min(payload.len()); Some(payload[..take].to_vec()) } COMPR_LZO => match minilzo_rs::LZO::init() { Ok(lzo) => lzo.decompress(payload, out_len).ok(), Err(_) => None, }, COMPR_ZLIB => inflate_raw(payload, out_len), COMPR_ZSTD => crate::utils::compression::decompress_zstd(payload).ok(), _ => None, } } /// Raw DEFLATE inflate (UBIFS uses headerless zlib streams). fn inflate_raw(payload: &[u8], out_len: usize) -> Option> { use flate2::Decompress; use flate2::FlushDecompress; let mut d = Decompress::new(false); // false = raw deflate (no zlib header) let mut out = Vec::with_capacity(out_len.max(payload.len())); match d.decompress_vec(payload, &mut out, FlushDecompress::Finish) { Ok(_) => Some(out), Err(_) => { // Retry assuming a zlib header just in case. crate::utils::compression::decompress_zlib(payload).ok() } } } fn mode_to_itype(mode: u32) -> u8 { match mode & 0o170000 { 0o040000 => ITYPE_DIR, 0o120000 => ITYPE_LNK, 0o100000 => ITYPE_REG, _ => 0xFF, } } /// Make a single path component safe on the host filesystem. fn sanitize_component(name: &str) -> String { let cleaned: String = name .chars() .map(|c| { if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c } }) .collect(); match cleaned.trim() { "" | "." | ".." => "_".to_string(), other => other.to_string(), } } // --- little-endian helpers -------------------------------------------------- fn le16(b: &[u8], off: usize) -> u16 { u16::from_le_bytes([b[off], b[off + 1]]) } fn le32(b: &[u8], off: usize) -> u32 { u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]]) } fn le64(b: &[u8], off: usize) -> u64 { u64::from_le_bytes([ b[off], b[off + 1], b[off + 2], b[off + 3], b[off + 4], b[off + 5], b[off + 6], b[off + 7], ]) }