use std::io::Read;
use crate::Result;
use crate::block::BlockDevice;
use crate::fs::DirEntry;
mod directory;
mod file;
mod fragment;
mod inode;
mod metablock;
pub use file::FileReader;
const SQUASHFS_MAGIC: u32 = 0x7371_7368;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compression {
Gzip,
Lzma,
Lzo,
Xz,
Lz4,
Zstd,
Unknown(u16),
}
impl Compression {
fn from_id(id: u16) -> Self {
match id {
1 => Self::Gzip,
2 => Self::Lzma,
3 => Self::Lzo,
4 => Self::Xz,
5 => Self::Lz4,
6 => Self::Zstd,
other => Self::Unknown(other),
}
}
}
#[derive(Debug, Clone)]
pub struct Superblock {
pub magic: u32,
pub inode_count: u32,
pub mkfs_time: u32,
pub block_size: u32,
pub fragment_count: u32,
pub compression: Compression,
pub block_log: u16,
pub flags: u16,
pub id_count: u16,
pub major: u16,
pub minor: u16,
pub root_inode: u64,
pub bytes_used: u64,
pub id_table_start: u64,
pub xattr_id_table_start: u64,
pub inode_table_start: u64,
pub directory_table_start: u64,
pub fragment_table_start: u64,
pub export_table_start: u64,
}
impl Superblock {
pub fn decode(buf: &[u8]) -> Option<Self> {
if buf.len() < 96 {
return None;
}
let magic = u32::from_le_bytes(buf[0..4].try_into().ok()?);
if magic != SQUASHFS_MAGIC {
return None;
}
let inode_count = u32::from_le_bytes(buf[4..8].try_into().ok()?);
let mkfs_time = u32::from_le_bytes(buf[8..12].try_into().ok()?);
let block_size = u32::from_le_bytes(buf[12..16].try_into().ok()?);
let fragment_count = u32::from_le_bytes(buf[16..20].try_into().ok()?);
let compression = Compression::from_id(u16::from_le_bytes(buf[20..22].try_into().ok()?));
let block_log = u16::from_le_bytes(buf[22..24].try_into().ok()?);
let flags = u16::from_le_bytes(buf[24..26].try_into().ok()?);
let id_count = u16::from_le_bytes(buf[26..28].try_into().ok()?);
let major = u16::from_le_bytes(buf[28..30].try_into().ok()?);
let minor = u16::from_le_bytes(buf[30..32].try_into().ok()?);
let root_inode = u64::from_le_bytes(buf[32..40].try_into().ok()?);
let bytes_used = u64::from_le_bytes(buf[40..48].try_into().ok()?);
let id_table_start = u64::from_le_bytes(buf[48..56].try_into().ok()?);
let xattr_id_table_start = u64::from_le_bytes(buf[56..64].try_into().ok()?);
let inode_table_start = u64::from_le_bytes(buf[64..72].try_into().ok()?);
let directory_table_start = u64::from_le_bytes(buf[72..80].try_into().ok()?);
let fragment_table_start = u64::from_le_bytes(buf[80..88].try_into().ok()?);
let export_table_start = u64::from_le_bytes(buf[88..96].try_into().ok()?);
Some(Self {
magic,
inode_count,
mkfs_time,
block_size,
fragment_count,
compression,
block_log,
flags,
id_count,
major,
minor,
root_inode,
bytes_used,
id_table_start,
xattr_id_table_start,
inode_table_start,
directory_table_start,
fragment_table_start,
export_table_start,
})
}
}
pub fn probe(dev: &mut dyn BlockDevice) -> Result<bool> {
if dev.total_size() < 4 {
return Ok(false);
}
let mut head = [0u8; 4];
dev.read_at(0, &mut head)?;
Ok(u32::from_le_bytes(head) == SQUASHFS_MAGIC)
}
#[derive(Debug)]
pub struct Squashfs {
sb: Superblock,
}
impl Squashfs {
pub fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
if dev.total_size() < 96 {
return Err(crate::Error::InvalidImage(
"squashfs: device too small to hold a superblock".into(),
));
}
let mut buf = [0u8; 96];
dev.read_at(0, &mut buf)?;
let sb = Superblock::decode(&buf).ok_or_else(|| {
crate::Error::InvalidImage("squashfs: superblock magic mismatch".into())
})?;
if sb.major != 4 {
return Err(crate::Error::Unsupported(format!(
"squashfs: only version 4.x is supported (got {}.{})",
sb.major, sb.minor
)));
}
Ok(Self { sb })
}
pub fn total_bytes(&self) -> u64 {
self.sb.bytes_used
}
pub fn block_size(&self) -> u32 {
self.sb.block_size
}
pub fn compression(&self) -> Compression {
self.sb.compression
}
pub fn superblock(&self) -> &Superblock {
&self.sb
}
pub fn list_path(&self, dev: &mut dyn BlockDevice, path: &str) -> Result<Vec<DirEntry>> {
let resolved = directory::resolve_path(
dev,
self.sb.inode_table_start,
self.sb.directory_table_start,
self.sb.compression,
self.sb.root_inode,
self.sb.block_size,
path,
)?;
let dir = match resolved {
inode::Inode::Dir(d) => d,
_ => {
return Err(crate::Error::InvalidArgument(format!(
"squashfs: {path:?} is not a directory"
)));
}
};
let raw_entries = directory::read_directory_entries(
dev,
self.sb.directory_table_start,
self.sb.compression,
dir.block_index,
dir.block_offset,
dir.file_size,
)?;
Ok(raw_entries
.into_iter()
.map(|e| DirEntry {
name: e.name,
inode: e.inode_number,
kind: directory::entry_kind_from_type(e.inode_type),
})
.collect())
}
pub fn open_file_reader<'a>(
&self,
dev: &'a mut dyn BlockDevice,
path: &str,
) -> Result<Box<dyn Read + 'a>> {
let resolved = directory::resolve_path(
dev,
self.sb.inode_table_start,
self.sb.directory_table_start,
self.sb.compression,
self.sb.root_inode,
self.sb.block_size,
path,
)?;
let file_inode = match resolved {
inode::Inode::File(f) => f,
_ => {
return Err(crate::Error::InvalidArgument(format!(
"squashfs: {path:?} is not a regular file"
)));
}
};
Ok(Box::new(FileReader::new(
dev,
&file_inode,
self.sb.compression,
self.sb.fragment_table_start,
self.sb.fragment_count,
self.sb.block_size,
)))
}
pub fn read_symlink(&self, dev: &mut dyn BlockDevice, path: &str) -> Result<String> {
let resolved = directory::resolve_path(
dev,
self.sb.inode_table_start,
self.sb.directory_table_start,
self.sb.compression,
self.sb.root_inode,
self.sb.block_size,
path,
)?;
match resolved {
inode::Inode::Symlink(s) => Ok(s.target),
_ => Err(crate::Error::InvalidArgument(format!(
"squashfs: {path:?} is not a symlink"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block::MemoryBackend;
use crate::fs::EntryKind;
#[allow(clippy::too_many_arguments)]
fn fake_sb_v4(
comp: u16,
block_size: u32,
fragment_count: u32,
root_inode: u64,
bytes_used: u64,
inode_table_start: u64,
directory_table_start: u64,
fragment_table_start: u64,
) -> Vec<u8> {
let mut v = vec![0u8; 96];
v[0..4].copy_from_slice(&SQUASHFS_MAGIC.to_le_bytes());
v[4..8].copy_from_slice(&8u32.to_le_bytes());
v[8..12].copy_from_slice(&0u32.to_le_bytes());
v[12..16].copy_from_slice(&block_size.to_le_bytes());
v[16..20].copy_from_slice(&fragment_count.to_le_bytes());
v[20..22].copy_from_slice(&comp.to_le_bytes());
let block_log = block_size.trailing_zeros() as u16;
v[22..24].copy_from_slice(&block_log.to_le_bytes());
v[24..26].copy_from_slice(&0u16.to_le_bytes());
v[26..28].copy_from_slice(&0u16.to_le_bytes());
v[28..30].copy_from_slice(&4u16.to_le_bytes());
v[30..32].copy_from_slice(&0u16.to_le_bytes());
v[32..40].copy_from_slice(&root_inode.to_le_bytes());
v[40..48].copy_from_slice(&bytes_used.to_le_bytes());
v[48..56].copy_from_slice(&u64::MAX.to_le_bytes()); v[56..64].copy_from_slice(&u64::MAX.to_le_bytes()); v[64..72].copy_from_slice(&inode_table_start.to_le_bytes());
v[72..80].copy_from_slice(&directory_table_start.to_le_bytes());
v[80..88].copy_from_slice(&fragment_table_start.to_le_bytes());
v[88..96].copy_from_slice(&u64::MAX.to_le_bytes()); v
}
fn fake_sb(major: u16, comp: u16) -> Vec<u8> {
let mut v = fake_sb_v4(comp, 131072, 0, 0, 512, u64::MAX, u64::MAX, u64::MAX);
v[28..30].copy_from_slice(&major.to_le_bytes());
v
}
#[test]
fn decode_recognises_zstd() {
let v = fake_sb(4, 6);
let sb = Superblock::decode(&v).unwrap();
assert_eq!(sb.compression, Compression::Zstd);
assert_eq!(sb.block_size, 131072);
}
#[test]
fn open_rejects_v3() {
let mut dev = MemoryBackend::new(4096);
dev.write_at(0, &fake_sb(3, 1)).unwrap();
let err = Squashfs::open(&mut dev).unwrap_err();
match err {
crate::Error::Unsupported(_) => {}
_ => panic!("expected Unsupported, got {err:?}"),
}
}
#[test]
fn open_accepts_v4() {
let mut dev = MemoryBackend::new(4096);
dev.write_at(0, &fake_sb(4, 6)).unwrap();
let s = Squashfs::open(&mut dev).unwrap();
assert_eq!(s.compression(), Compression::Zstd);
}
#[test]
fn probe_matches_magic() {
let mut dev = MemoryBackend::new(4096);
dev.write_at(0, &SQUASHFS_MAGIC.to_le_bytes()).unwrap();
assert!(probe(&mut dev).unwrap());
}
use super::metablock::encode_uncompressed;
struct Built {
image: Vec<u8>,
root_inode_ref: u64,
inode_table_start: u64,
directory_table_start: u64,
data_offset: u64,
}
fn build_fixture() -> Built {
let file_payload = b"hello";
let data_offset = 96u64;
let data_block_size = file_payload.len() as u32 | 0x0100_0000;
let mut inodes: Vec<u8> = Vec::new();
inodes.extend_from_slice(&1u16.to_le_bytes()); inodes.extend_from_slice(&0o755u16.to_le_bytes()); inodes.extend_from_slice(&0u16.to_le_bytes()); inodes.extend_from_slice(&0u16.to_le_bytes()); inodes.extend_from_slice(&0u32.to_le_bytes()); inodes.extend_from_slice(&1u32.to_le_bytes()); inodes.extend_from_slice(&0u32.to_le_bytes()); inodes.extend_from_slice(&3u32.to_le_bytes()); let dir_size_patch_offset = inodes.len();
inodes.extend_from_slice(&0u16.to_le_bytes()); inodes.extend_from_slice(&0u16.to_le_bytes()); inodes.extend_from_slice(&0u32.to_le_bytes());
let file_inode_offset = inodes.len() as u16;
inodes.extend_from_slice(&2u16.to_le_bytes()); inodes.extend_from_slice(&0o644u16.to_le_bytes());
inodes.extend_from_slice(&0u16.to_le_bytes());
inodes.extend_from_slice(&0u16.to_le_bytes());
inodes.extend_from_slice(&0u32.to_le_bytes()); inodes.extend_from_slice(&2u32.to_le_bytes()); inodes.extend_from_slice(&(data_offset as u32).to_le_bytes()); inodes.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); inodes.extend_from_slice(&0u32.to_le_bytes()); inodes.extend_from_slice(&(file_payload.len() as u32).to_le_bytes()); inodes.extend_from_slice(&data_block_size.to_le_bytes());
let symlink_inode_offset = inodes.len() as u16;
inodes.extend_from_slice(&3u16.to_le_bytes()); inodes.extend_from_slice(&0o777u16.to_le_bytes());
inodes.extend_from_slice(&0u16.to_le_bytes());
inodes.extend_from_slice(&0u16.to_le_bytes());
inodes.extend_from_slice(&0u32.to_le_bytes());
inodes.extend_from_slice(&3u32.to_le_bytes()); let target = b"hi.txt";
inodes.extend_from_slice(&1u32.to_le_bytes()); inodes.extend_from_slice(&(target.len() as u32).to_le_bytes()); inodes.extend_from_slice(target);
let mut dirs: Vec<u8> = Vec::new();
dirs.extend_from_slice(&1u32.to_le_bytes()); dirs.extend_from_slice(&0u32.to_le_bytes()); dirs.extend_from_slice(&2u32.to_le_bytes()); dirs.extend_from_slice(&file_inode_offset.to_le_bytes());
dirs.extend_from_slice(&0i16.to_le_bytes()); dirs.extend_from_slice(&2u16.to_le_bytes()); dirs.extend_from_slice(&((b"hi.txt".len() - 1) as u16).to_le_bytes()); dirs.extend_from_slice(b"hi.txt");
dirs.extend_from_slice(&symlink_inode_offset.to_le_bytes());
dirs.extend_from_slice(&1i16.to_le_bytes()); dirs.extend_from_slice(&3u16.to_le_bytes()); dirs.extend_from_slice(&((b"lnk".len() - 1) as u16).to_le_bytes());
dirs.extend_from_slice(b"lnk");
let dir_size_real = dirs.len() as u16 + 3; let patch = dir_size_real.to_le_bytes();
inodes[dir_size_patch_offset..dir_size_patch_offset + 2].copy_from_slice(&patch);
let mut image = vec![0u8; data_offset as usize + file_payload.len()];
image[data_offset as usize..data_offset as usize + file_payload.len()]
.copy_from_slice(file_payload);
let inode_table_start = image.len() as u64;
image.extend_from_slice(&encode_uncompressed(&inodes));
let directory_table_start = image.len() as u64;
image.extend_from_slice(&encode_uncompressed(&dirs));
let root_inode_ref: u64 = 0;
let bytes_used = image.len() as u64;
let mut sb = fake_sb_v4(
1, 4096,
0,
root_inode_ref,
bytes_used,
inode_table_start,
directory_table_start,
u64::MAX,
);
image[..96].copy_from_slice(&sb[..]);
let _ = &mut sb;
Built {
image,
root_inode_ref,
inode_table_start,
directory_table_start,
data_offset,
}
}
#[test]
fn end_to_end_list_read_symlink() {
let built = build_fixture();
assert_eq!(built.root_inode_ref, 0);
assert!(built.inode_table_start > 0);
assert!(built.directory_table_start > built.inode_table_start);
assert!(built.data_offset < built.inode_table_start);
let mut dev = MemoryBackend::new(built.image.len() as u64 + 64);
dev.write_at(0, &built.image).unwrap();
let s = Squashfs::open(&mut dev).unwrap();
let entries = s.list_path(&mut dev, "/").unwrap();
assert_eq!(entries.len(), 2);
let by_name: std::collections::HashMap<_, _> = entries
.iter()
.map(|e| (e.name.as_str(), (e.inode, e.kind)))
.collect();
assert_eq!(by_name["hi.txt"].1, EntryKind::Regular);
assert_eq!(by_name["lnk"].1, EntryKind::Symlink);
assert_eq!(by_name["hi.txt"].0, 2);
assert_eq!(by_name["lnk"].0, 3);
let mut r = s.open_file_reader(&mut dev, "/hi.txt").unwrap();
let mut out = Vec::new();
std::io::Read::read_to_end(&mut r, &mut out).unwrap();
drop(r);
assert_eq!(out, b"hello");
let tgt = s.read_symlink(&mut dev, "/lnk").unwrap();
assert_eq!(tgt, "hi.txt");
}
#[test]
fn list_path_on_missing_entry_errors() {
let built = build_fixture();
let mut dev = MemoryBackend::new(built.image.len() as u64 + 64);
dev.write_at(0, &built.image).unwrap();
let s = Squashfs::open(&mut dev).unwrap();
let err = s.list_path(&mut dev, "/nope").unwrap_err();
assert!(matches!(err, crate::Error::InvalidArgument(_)));
}
#[test]
fn compressed_data_block_surfaces_unsupported() {
let mut built = build_fixture();
let sb_buf = &built.image[0..96];
let sb = Superblock::decode(sb_buf).unwrap();
let off = sb.inode_table_start as usize + 2 + 64; let mut word_bytes = [0u8; 4];
word_bytes.copy_from_slice(&built.image[off..off + 4]);
let mut word = u32::from_le_bytes(word_bytes);
word &= !0x0100_0000; built.image[off..off + 4].copy_from_slice(&word.to_le_bytes());
let mut dev = MemoryBackend::new(built.image.len() as u64 + 64);
dev.write_at(0, &built.image).unwrap();
let s = Squashfs::open(&mut dev).unwrap();
let mut r = s.open_file_reader(&mut dev, "/hi.txt").unwrap();
let mut sink = Vec::new();
let res = std::io::Read::read_to_end(&mut r, &mut sink);
let err = res.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("gzip"), "unexpected message: {msg}");
assert!(msg.contains("decompression"), "unexpected message: {msg}");
}
}