pub mod crypt;
pub mod encoding;
pub mod header;
pub mod table;
pub mod writer;
pub use table::{Entry, GRF_FLAG_DES, GRF_FLAG_FILE, GRF_FLAG_MIXCRYPT};
use std::collections::BTreeMap;
use std::io::Read;
use crate::Result;
use crate::block::BlockDevice;
use crate::fs::{FileMeta, FileSource, MutationCapability};
pub(crate) const HEADER_SIZE: usize = 0x2e;
#[derive(Debug, Clone)]
pub struct FormatOpts {
pub version: u32,
pub compression_level: u32,
}
impl Default for FormatOpts {
fn default() -> Self {
Self {
version: 0x200,
compression_level: 6,
}
}
}
impl FormatOpts {
pub fn apply_options(&mut self, map: &mut crate::format_opts::OptionMap) -> crate::Result<()> {
if let Some(v) = map.take_u32("version")? {
self.version = v;
}
if let Some(n) = map.take_u32("compression_level")? {
if n > 9 {
return Err(crate::Error::InvalidImage(format!(
"compression_level {n} out of range (0..=9)"
)));
}
self.compression_level = n;
}
Ok(())
}
}
pub struct Grf {
pub version: u32,
pub table_offset: u32,
pub seed: u32,
pub encrypted_header: bool,
pub entries: BTreeMap<String, Entry>,
data_end: u64,
wasted_space: u64,
dirty: bool,
fresh: bool,
}
impl Grf {
pub fn format_with(_dev: &mut dyn BlockDevice, opts: &FormatOpts) -> Result<Self> {
if opts.version != 0x200 {
return Err(crate::Error::Unsupported(format!(
"grf: writer only emits v0x200 (asked for {:#x})",
opts.version
)));
}
Ok(Self {
version: opts.version,
table_offset: 0,
seed: 0,
encrypted_header: false,
entries: BTreeMap::new(),
data_end: HEADER_SIZE as u64,
wasted_space: 0,
dirty: true,
fresh: true,
})
}
pub fn open_dev(dev: &mut dyn BlockDevice) -> Result<Self> {
let mut head_buf = [0u8; HEADER_SIZE];
dev.read_at(0, &mut head_buf)?;
let head = header::Header::decode(&head_buf)?;
let table_abs = head.table_offset as u64 + HEADER_SIZE as u64;
let entries = read_table(dev, table_abs, head.version, head.filecount)?;
let mut data_end = HEADER_SIZE as u64;
for e in entries.values() {
let end = HEADER_SIZE as u64 + e.pos as u64 + e.len_aligned as u64;
if end > data_end {
data_end = end;
}
}
let wasted_space = table_abs.saturating_sub(data_end);
Ok(Self {
version: head.version,
table_offset: head.table_offset,
seed: head.seed,
encrypted_header: head.encrypted_header,
entries,
data_end,
wasted_space,
dirty: false,
fresh: false,
})
}
pub fn read_entry(&self, dev: &mut dyn BlockDevice, entry: &Entry) -> Result<Vec<u8>> {
let abs = HEADER_SIZE as u64 + entry.pos as u64;
let mut comp = vec![0u8; entry.len_aligned as usize];
if entry.len_aligned > 0 {
dev.read_at(abs, &mut comp)?;
}
if let Some(cycle) = entry.crypto_cycle() {
let flag_type = if cycle == 0 { 1 } else { 0 };
crypt::decode_des_etc(&mut comp, flag_type, cycle);
}
let plain = crate::compression::decompress(
crate::compression::Algo::Zlib,
&comp[..entry.len as usize],
entry.size as usize,
)?;
Ok(plain)
}
pub fn wasted_space(&self) -> u64 {
self.wasted_space
}
}
fn read_table(
dev: &mut dyn BlockDevice,
table_abs: u64,
version: u32,
filecount: u32,
) -> Result<BTreeMap<String, Entry>> {
if filecount == 0 {
return Ok(BTreeMap::new());
}
let dev_size = dev.total_size();
if table_abs >= dev_size {
return Err(crate::Error::InvalidImage(
"grf: table offset past end of file".into(),
));
}
let entries = match version {
0x102 | 0x103 => {
let table = read_compressed_table(dev, table_abs, true)?;
table::decode_v102(&table)?
}
0x200 => {
let table = read_compressed_table(dev, table_abs, false)?;
table::decode_v200(&table)?
}
other => {
return Err(crate::Error::Unsupported(format!(
"grf: cannot read table for version {other:#x}"
)));
}
};
let mut map = BTreeMap::new();
for e in entries {
map.insert(normalise_path(&e.name), e);
}
Ok(map)
}
fn read_compressed_table(
dev: &mut dyn BlockDevice,
table_abs: u64,
legacy_framing: bool,
) -> Result<Vec<u8>> {
let dev_size = dev.total_size();
let mut posinfo = [0u8; 8];
if table_abs + 8 > dev_size {
return Err(crate::Error::InvalidImage(
"grf: table header truncated".into(),
));
}
dev.read_at(table_abs, &mut posinfo)?;
let comp_size = u32::from_le_bytes(posinfo[0..4].try_into().unwrap()) as usize;
let uncomp_size = u32::from_le_bytes(posinfo[4..8].try_into().unwrap()) as usize;
let comp_start = table_abs + 8;
if comp_start + comp_size as u64 > dev_size {
return Err(crate::Error::InvalidImage(
"grf: compressed table payload past end of file".into(),
));
}
let mut comp = vec![0u8; comp_size];
dev.read_at(comp_start, &mut comp)?;
let _ = legacy_framing;
crate::compression::decompress(crate::compression::Algo::Zlib, &comp, uncomp_size)
}
fn normalise_path(s: &str) -> String {
s.trim_start_matches('/').to_string()
}
impl crate::fs::FilesystemFactory for Grf {
type FormatOpts = FormatOpts;
fn format(dev: &mut dyn BlockDevice, opts: &Self::FormatOpts) -> Result<Self> {
Self::format_with(dev, opts)
}
fn open(dev: &mut dyn BlockDevice) -> Result<Self> {
Self::open_dev(dev)
}
}
impl crate::fs::Filesystem for Grf {
fn create_file(
&mut self,
dev: &mut dyn BlockDevice,
path: &std::path::Path,
src: FileSource,
_meta: FileMeta,
) -> Result<()> {
let key = normalise_path(
path.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
);
writer::add_file(self, dev, key, src)
}
fn create_dir(
&mut self,
_dev: &mut dyn BlockDevice,
_path: &std::path::Path,
_meta: FileMeta,
) -> Result<()> {
Ok(())
}
fn create_symlink(
&mut self,
_dev: &mut dyn BlockDevice,
_path: &std::path::Path,
_target: &std::path::Path,
_meta: FileMeta,
) -> Result<()> {
Err(crate::Error::Unsupported(
"grf: symlinks are not part of the archive format".into(),
))
}
fn create_device(
&mut self,
_dev: &mut dyn BlockDevice,
_path: &std::path::Path,
_kind: crate::fs::DeviceKind,
_major: u32,
_minor: u32,
_meta: FileMeta,
) -> Result<()> {
Err(crate::Error::Unsupported(
"grf: device nodes are not part of the archive format".into(),
))
}
fn remove(&mut self, _dev: &mut dyn BlockDevice, path: &std::path::Path) -> Result<()> {
let key = normalise_path(
path.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
);
writer::remove(self, &key)
}
fn list(
&mut self,
_dev: &mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<Vec<crate::fs::DirEntry>> {
let prefix = {
let s = path
.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?;
let trimmed = s.trim_start_matches('/').trim_end_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("{trimmed}/")
}
};
use std::collections::BTreeMap as B;
let mut children: B<String, crate::fs::EntryKind> = B::new();
let mut sizes: B<String, u64> = B::new();
for (name, entry) in &self.entries {
let Some(tail) = name.strip_prefix(&prefix) else {
continue;
};
if tail.is_empty() {
continue;
}
if let Some((leaf, _)) = tail.split_once('/') {
children.insert(leaf.to_string(), crate::fs::EntryKind::Dir);
sizes.insert(leaf.to_string(), 0);
} else {
children.insert(tail.to_string(), crate::fs::EntryKind::Regular);
sizes.insert(tail.to_string(), entry.size as u64);
}
}
Ok(children
.into_iter()
.map(|(name, kind)| {
let size = *sizes.get(&name).unwrap_or(&0);
crate::fs::DirEntry {
name,
inode: 0,
kind,
size,
}
})
.collect())
}
fn read_file<'a>(
&'a mut self,
dev: &'a mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<Box<dyn Read + 'a>> {
let key = normalise_path(
path.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
);
let entry =
self.entries.get(&key).cloned().ok_or_else(|| {
crate::Error::InvalidArgument(format!("grf: no entry at {key:?}"))
})?;
let bytes = self.read_entry(dev, &entry)?;
Ok(Box::new(std::io::Cursor::new(bytes)))
}
fn open_file_ro<'a>(
&'a mut self,
dev: &'a mut dyn BlockDevice,
path: &std::path::Path,
) -> Result<Box<dyn crate::fs::FileReadHandle + 'a>> {
let key = normalise_path(
path.to_str()
.ok_or_else(|| crate::Error::InvalidArgument("grf: non-UTF-8 path".into()))?,
);
let entry =
self.entries.get(&key).cloned().ok_or_else(|| {
crate::Error::InvalidArgument(format!("grf: no entry at {key:?}"))
})?;
let bytes = self.read_entry(dev, &entry)?;
Ok(Box::new(GrfFileReadHandle {
cursor: std::io::Cursor::new(bytes),
}))
}
fn flush(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
writer::flush(self, dev)
}
fn mutation_capability(&self) -> MutationCapability {
MutationCapability::Mutable
}
}
struct GrfFileReadHandle {
cursor: std::io::Cursor<Vec<u8>>,
}
impl Read for GrfFileReadHandle {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.cursor.read(buf)
}
}
impl std::io::Seek for GrfFileReadHandle {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.cursor.seek(pos)
}
}
impl crate::fs::FileReadHandle for GrfFileReadHandle {
fn len(&self) -> u64 {
self.cursor.get_ref().len() as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block::MemoryBackend;
use crate::fs::{Filesystem, FilesystemFactory};
#[test]
fn empty_round_trip() {
let mut dev = MemoryBackend::new(64 * 1024);
let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
grf.flush(&mut dev).unwrap();
let reopen = Grf::open(&mut dev).unwrap();
assert_eq!(reopen.version, 0x200);
assert_eq!(reopen.entries.len(), 0);
}
#[test]
fn add_read_round_trip() {
let mut dev = MemoryBackend::new(64 * 1024);
let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
let body = b"hello, world!";
grf.create_file(
&mut dev,
std::path::Path::new("/data/info.txt"),
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(body.to_vec())),
len: body.len() as u64,
},
FileMeta::default(),
)
.unwrap();
grf.flush(&mut dev).unwrap();
let mut reopen = Grf::open(&mut dev).unwrap();
assert_eq!(reopen.entries.len(), 1);
let entries = reopen
.list(&mut dev, std::path::Path::new("/data"))
.unwrap();
assert!(entries.iter().any(|e| e.name == "info.txt"));
let entry = reopen.entries.get("data/info.txt").cloned().unwrap();
let bytes = reopen.read_entry(&mut dev, &entry).unwrap();
assert_eq!(bytes, body);
}
#[test]
fn open_file_ro_random_seek() {
use std::io::{Read, Seek, SeekFrom};
let mut dev = MemoryBackend::new(64 * 1024);
let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
let body: Vec<u8> = (0..1024u32).map(|i| (i & 0xff) as u8).collect();
grf.create_file(
&mut dev,
std::path::Path::new("/blob.bin"),
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(body.clone())),
len: body.len() as u64,
},
FileMeta::default(),
)
.unwrap();
grf.flush(&mut dev).unwrap();
let mut grf = Grf::open(&mut dev).unwrap();
let mut h = grf
.open_file_ro(&mut dev, std::path::Path::new("/blob.bin"))
.unwrap();
assert_eq!(h.len(), body.len() as u64);
h.seek(SeekFrom::Start(500)).unwrap();
let mut chunk = [0u8; 32];
h.read_exact(&mut chunk).unwrap();
assert_eq!(&chunk[..], &body[500..532]);
h.seek(SeekFrom::Current(-32)).unwrap();
h.read_exact(&mut chunk).unwrap();
assert_eq!(&chunk[..], &body[500..532]);
}
#[test]
fn hangul_filename_round_trip() {
let mut dev = MemoryBackend::new(64 * 1024);
let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
grf.create_file(
&mut dev,
std::path::Path::new("/data/한글.txt"),
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(b"hi".to_vec())),
len: 2,
},
FileMeta::default(),
)
.unwrap();
grf.flush(&mut dev).unwrap();
let reopen = Grf::open(&mut dev).unwrap();
assert!(reopen.entries.contains_key("data/한글.txt"));
}
#[test]
fn remove_marks_wasted_space() {
let mut dev = MemoryBackend::new(64 * 1024);
let mut grf = Grf::format(&mut dev, &FormatOpts::default()).unwrap();
grf.create_file(
&mut dev,
std::path::Path::new("/a.txt"),
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(vec![0u8; 4096])),
len: 4096,
},
FileMeta::default(),
)
.unwrap();
grf.create_file(
&mut dev,
std::path::Path::new("/b.txt"),
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(vec![0u8; 4096])),
len: 4096,
},
FileMeta::default(),
)
.unwrap();
grf.flush(&mut dev).unwrap();
let mut reopen = Grf::open(&mut dev).unwrap();
reopen
.remove(&mut dev, std::path::Path::new("/a.txt"))
.unwrap();
reopen.flush(&mut dev).unwrap();
assert!(reopen.wasted_space() > 0);
}
}