use crate::error::{err_number, VBError, VBResult};
use crate::state::file::{self, AccessMode, LockMode, OpenMode};
const ORDINAL_MARKER: u16 = 0xFFFF;
const SIZE_PREFIX_LEN: usize = 8;
const HEADER_TRAILER_LEN: usize = 16;
const RECORD_ALIGNMENT: usize = 4;
fn align_up(value: usize) -> usize {
value.next_multiple_of(RECORD_ALIGNMENT)
}
pub mod rt {
pub const CURSOR: u16 = 1;
pub const BITMAP: u16 = 2;
pub const ICON: u16 = 3;
pub const MENU: u16 = 4;
pub const DIALOG: u16 = 5;
pub const STRING: u16 = 6;
pub const FONTDIR: u16 = 7;
pub const FONT: u16 = 8;
pub const ACCELERATOR: u16 = 9;
pub const RCDATA: u16 = 10;
pub const MESSAGETABLE: u16 = 11;
pub const GROUP_CURSOR: u16 = 12;
pub const GROUP_ICON: u16 = 14;
pub const VERSION: u16 = 16;
pub const MANIFEST: u16 = 24;
}
pub const VB_RES_BITMAP: i32 = 0;
pub const VB_RES_ICON: i32 = 1;
pub const VB_RES_CURSOR: i32 = 2;
pub const STRINGS_PER_BUNDLE: u16 = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResId {
Ordinal(u16),
Name(String),
}
impl ResId {
pub fn as_ordinal(&self) -> Option<u16> {
match self {
Self::Ordinal(value) => Some(*value),
Self::Name(_) => None,
}
}
pub fn matches_ordinal(&self, ordinal: u16) -> bool {
match self {
Self::Ordinal(value) => *value == ordinal,
Self::Name(name) => name
.strip_prefix('#')
.unwrap_or(name)
.parse::<u16>()
.is_ok_and(|parsed| parsed == ordinal),
}
}
pub fn matches_name(&self, name: &str) -> bool {
match self {
Self::Name(value) => value.eq_ignore_ascii_case(name),
Self::Ordinal(_) => false,
}
}
}
impl std::fmt::Display for ResId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ordinal(value) => write!(f, "{value}"),
Self::Name(name) => write!(f, "{name}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResEntry {
pub res_type: ResId,
pub name: ResId,
pub language: u16,
pub memory_flags: u16,
pub data_version: u32,
pub version: u32,
pub characteristics: u32,
pub data_offset: usize,
pub data_size: usize,
}
#[derive(Debug, Clone)]
pub struct ResFile {
path: String,
buffer: Vec<u8>,
entries: Vec<ResEntry>,
}
impl ResFile {
pub fn load(path: &str) -> VBResult<Self> {
let path_ref = std::path::Path::new(path);
if !file::file_exists(path_ref) {
return Err(VBError::new(err_number::FILE_NOT_FOUND));
}
let file_number = file::free_file(0);
if file_number == 0 {
return Err(VBError::new(err_number::TOO_MANY_FILES));
}
file::open_file(
path_ref,
OpenMode::Binary,
AccessMode::Read,
LockMode::Shared,
0,
file_number,
)?;
let read = file::read_file_to_vec(file_number);
let closed = file::close_file(file_number);
let buffer = read?;
closed?;
Self::parse(path, buffer)
}
pub fn parse(path: &str, buffer: Vec<u8>) -> VBResult<Self> {
let entries = parse_entries(&buffer)?;
Ok(Self {
path: path.to_string(),
buffer,
entries,
})
}
pub fn path(&self) -> &str {
&self.path
}
pub fn entries(&self) -> &[ResEntry] {
&self.entries
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn data(&self, entry: &ResEntry) -> &[u8] {
&self.buffer[entry.data_offset..entry.data_offset + entry.data_size]
}
pub fn find_by_ordinal(&self, res_type: u16, name: u16) -> Option<&ResEntry> {
self.entries.iter().find(|entry| {
entry.res_type.matches_ordinal(res_type) && entry.name.matches_ordinal(name)
})
}
pub fn find_by_name(&self, res_type: u16, name: &str) -> Option<&ResEntry> {
self.entries
.iter()
.find(|entry| entry.res_type.matches_ordinal(res_type) && entry.name.matches_name(name))
}
pub fn find_any_type(&self, name: &ResId) -> Option<&ResEntry> {
self.entries.iter().find(|entry| match name {
ResId::Ordinal(ordinal) => entry.name.matches_ordinal(*ordinal),
ResId::Name(text) => entry.name.matches_name(text),
})
}
pub fn entries_of_type(&self, res_type: u16) -> impl Iterator<Item = &ResEntry> {
self.entries
.iter()
.filter(move |entry| entry.res_type.matches_ordinal(res_type))
}
}
fn parse_entries(buffer: &[u8]) -> VBResult<Vec<ResEntry>> {
let mut entries = Vec::new();
let mut offset = 0usize;
while offset + SIZE_PREFIX_LEN <= buffer.len() {
let (entry, next_offset) = parse_record(buffer, offset)?;
let is_null_record = entry.data_size == 0
&& entry.res_type == ResId::Ordinal(0)
&& entry.name == ResId::Ordinal(0);
if !is_null_record {
entries.push(entry);
}
debug_assert!(next_offset > offset, "record parse made no progress");
offset = next_offset;
}
Ok(entries)
}
fn parse_record(buffer: &[u8], offset: usize) -> VBResult<(ResEntry, usize)> {
let data_size = read_u32(buffer, offset)? as usize;
let header_size = read_u32(buffer, offset + 4)? as usize;
let min_header_size = SIZE_PREFIX_LEN + 2 * ORDINAL_FIELD_LEN + HEADER_TRAILER_LEN;
if header_size < min_header_size {
return Err(invalid_format());
}
let mut cursor = offset + SIZE_PREFIX_LEN;
let res_type = read_res_id(buffer, &mut cursor)?;
let name = read_res_id(buffer, &mut cursor)?;
cursor = offset + align_up(cursor - offset);
let data_version = read_u32(buffer, cursor)?;
let memory_flags = read_u16(buffer, cursor + 4)?;
let language = read_u16(buffer, cursor + 6)?;
let version = read_u32(buffer, cursor + 8)?;
let characteristics = read_u32(buffer, cursor + 12)?;
cursor += HEADER_TRAILER_LEN;
if cursor - offset != header_size {
return Err(invalid_format());
}
let data_offset = offset + header_size;
let data_end = data_offset
.checked_add(data_size)
.ok_or_else(invalid_format)?;
if data_end > buffer.len() {
return Err(invalid_format());
}
let next_offset = offset + align_up(header_size + data_size);
Ok((
ResEntry {
res_type,
name,
language,
memory_flags,
data_version,
version,
characteristics,
data_offset,
data_size,
},
next_offset,
))
}
const ORDINAL_FIELD_LEN: usize = 4;
fn read_res_id(buffer: &[u8], cursor: &mut usize) -> VBResult<ResId> {
if read_u16(buffer, *cursor)? == ORDINAL_MARKER {
let ordinal = read_u16(buffer, *cursor + 2)?;
*cursor += ORDINAL_FIELD_LEN;
return Ok(ResId::Ordinal(ordinal));
}
let mut units = Vec::new();
loop {
let unit = read_u16(buffer, *cursor)?;
*cursor += 2;
if unit == 0 {
break;
}
units.push(unit);
}
Ok(ResId::Name(String::from_utf16_lossy(&units)))
}
fn read_u16(buffer: &[u8], offset: usize) -> VBResult<u16> {
buffer
.get(offset..offset + 2)
.map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]))
.ok_or_else(invalid_format)
}
fn read_u32(buffer: &[u8], offset: usize) -> VBResult<u32> {
buffer
.get(offset..offset + 4)
.map(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
.ok_or_else(invalid_format)
}
fn invalid_format() -> VBError {
VBError::new(err_number::INVALID_FORMAT_IN_RESOURCE_FILE)
}
#[cfg(test)]
mod tests {
use super::*;
fn record(res_type: u16, name: u16, language: u16, data: &[u8]) -> Vec<u8> {
let header_size = SIZE_PREFIX_LEN + 2 * ORDINAL_FIELD_LEN + HEADER_TRAILER_LEN;
let mut bytes = Vec::new();
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(header_size as u32).to_le_bytes());
bytes.extend_from_slice(&ORDINAL_MARKER.to_le_bytes());
bytes.extend_from_slice(&res_type.to_le_bytes());
bytes.extend_from_slice(&ORDINAL_MARKER.to_le_bytes());
bytes.extend_from_slice(&name.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&language.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(data);
bytes.resize(align_up(bytes.len()), 0);
bytes
}
fn named_record(res_type: &str, name: &str, data: &[u8]) -> Vec<u8> {
let mut fields = Vec::new();
for text in [res_type, name] {
for unit in text.encode_utf16() {
fields.extend_from_slice(&unit.to_le_bytes());
}
fields.extend_from_slice(&0u16.to_le_bytes());
}
let header_size = align_up(SIZE_PREFIX_LEN + fields.len()) + HEADER_TRAILER_LEN;
let mut bytes = Vec::new();
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(header_size as u32).to_le_bytes());
bytes.extend_from_slice(&fields);
bytes.resize(align_up(bytes.len()), 0); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&0x0409u16.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(data);
bytes.resize(align_up(bytes.len()), 0);
bytes
}
fn null_record() -> Vec<u8> {
record(0, 0, 0, &[])
}
#[test]
fn null_record_is_not_surfaced_as_an_entry() {
let file = ResFile::parse("t.res", null_record()).unwrap();
assert_eq!(file.entry_count(), 0);
}
#[test]
fn parses_ordinal_type_and_name_with_data() {
let mut bytes = null_record();
bytes.extend(record(rt::RCDATA, 101, 0x0409, b"payload"));
let file = ResFile::parse("t.res", bytes).unwrap();
assert_eq!(file.entry_count(), 1);
let entry = &file.entries()[0];
assert_eq!(entry.res_type, ResId::Ordinal(rt::RCDATA));
assert_eq!(entry.name, ResId::Ordinal(101));
assert_eq!(entry.language, 0x0409);
assert_eq!(entry.data_size, 7);
assert_eq!(file.data(entry), b"payload");
}
#[test]
fn data_excludes_alignment_padding() {
let mut bytes = null_record();
bytes.extend(record(rt::RCDATA, 1, 0, b"12345"));
bytes.extend(record(rt::RCDATA, 2, 0, b"second"));
let file = ResFile::parse("t.res", bytes).unwrap();
assert_eq!(file.data(&file.entries()[0]), b"12345");
assert_eq!(file.data(&file.entries()[1]), b"second");
}
#[test]
fn walks_every_record_in_a_multi_record_file() {
let mut bytes = null_record();
for id in 1..=5u16 {
bytes.extend(record(rt::ICON, id, 0x0409, &[id as u8; 3]));
}
let file = ResFile::parse("t.res", bytes).unwrap();
assert_eq!(file.entry_count(), 5);
for (index, entry) in file.entries().iter().enumerate() {
let id = index as u16 + 1;
assert_eq!(entry.name, ResId::Ordinal(id));
assert_eq!(file.data(entry), &[id as u8; 3]);
}
}
#[test]
fn parses_string_type_and_name() {
let mut bytes = null_record();
bytes.extend(named_record("DLL", "FMOD", b"binary"));
let file = ResFile::parse("t.res", bytes).unwrap();
let entry = &file.entries()[0];
assert_eq!(entry.res_type, ResId::Name("DLL".to_string()));
assert_eq!(entry.name, ResId::Name("FMOD".to_string()));
assert_eq!(file.data(entry), b"binary");
}
#[test]
fn find_by_name_is_case_insensitive() {
let mut bytes = null_record();
bytes.extend(named_record("DLL", "FMOD", b"binary"));
let file = ResFile::parse("t.res", bytes).unwrap();
let entry = file
.find_any_type(&ResId::Name("fmod".to_string()))
.unwrap();
assert_eq!(file.data(entry), b"binary");
assert!(file
.find_any_type(&ResId::Name("zlib".to_string()))
.is_none());
}
#[test]
fn numeric_string_type_matches_the_equivalent_ordinal() {
let mut bytes = null_record();
bytes.extend(named_record("#24", "1", b"<assembly/>"));
let file = ResFile::parse("t.res", bytes).unwrap();
let entry = file.find_by_ordinal(rt::MANIFEST, 1).unwrap();
assert_eq!(file.data(entry), b"<assembly/>");
}
#[test]
fn find_by_ordinal_returns_the_matching_entry() {
let mut bytes = null_record();
bytes.extend(record(rt::ICON, 1, 0x0409, b"icon-one"));
bytes.extend(record(rt::ICON, 2, 0x0409, b"icon-two"));
bytes.extend(record(rt::RCDATA, 2, 0x0409, b"data-two"));
let file = ResFile::parse("t.res", bytes).unwrap();
assert_eq!(
file.data(file.find_by_ordinal(rt::ICON, 2).unwrap()),
b"icon-two"
);
assert_eq!(
file.data(file.find_by_ordinal(rt::RCDATA, 2).unwrap()),
b"data-two"
);
assert!(file.find_by_ordinal(rt::BITMAP, 2).is_none());
assert!(file.find_by_ordinal(rt::ICON, 99).is_none());
}
#[test]
fn entries_of_type_filters_by_resource_type() {
let mut bytes = null_record();
bytes.extend(record(rt::ICON, 1, 0, b"a"));
bytes.extend(record(rt::RCDATA, 2, 0, b"b"));
bytes.extend(record(rt::ICON, 3, 0, b"c"));
let file = ResFile::parse("t.res", bytes).unwrap();
let icon_names: Vec<_> = file
.entries_of_type(rt::ICON)
.map(|e| e.name.clone())
.collect();
assert_eq!(icon_names, vec![ResId::Ordinal(1), ResId::Ordinal(3)]);
}
#[test]
fn same_name_in_two_languages_resolves_to_the_first() {
let mut bytes = null_record();
bytes.extend(record(rt::STRING, 1, 0x0409, b"english"));
bytes.extend(record(rt::STRING, 1, 0x040C, b"french"));
let file = ResFile::parse("t.res", bytes).unwrap();
assert_eq!(file.entry_count(), 2);
let entry = file.find_by_ordinal(rt::STRING, 1).unwrap();
assert_eq!(entry.language, 0x0409);
assert_eq!(file.data(entry), b"english");
}
#[test]
fn zero_length_resource_data_is_valid() {
let mut bytes = null_record();
bytes.extend(record(rt::RCDATA, 7, 0, b""));
let file = ResFile::parse("t.res", bytes).unwrap();
let entry = file.find_by_ordinal(rt::RCDATA, 7).unwrap();
assert_eq!(entry.data_size, 0);
assert!(file.data(entry).is_empty());
}
#[test]
fn empty_buffer_yields_no_entries() {
let file = ResFile::parse("t.res", Vec::new()).unwrap();
assert_eq!(file.entry_count(), 0);
}
#[test]
fn truncated_header_is_rejected() {
let mut bytes = null_record();
bytes.extend(record(rt::RCDATA, 1, 0, b"payload"));
bytes.truncate(bytes.len() - 12);
let error = ResFile::parse("t.res", bytes).unwrap_err();
assert_eq!(error.number, err_number::INVALID_FORMAT_IN_RESOURCE_FILE);
}
#[test]
fn data_size_past_end_of_buffer_is_rejected() {
let mut bytes = null_record();
bytes.extend(record(rt::RCDATA, 1, 0, b"payload"));
let data_size_at = null_record().len();
bytes[data_size_at..data_size_at + 4].copy_from_slice(&0xFFFF_0000u32.to_le_bytes());
let error = ResFile::parse("t.res", bytes).unwrap_err();
assert_eq!(error.number, err_number::INVALID_FORMAT_IN_RESOURCE_FILE);
}
#[test]
fn header_size_below_the_minimum_is_rejected() {
let mut bytes = null_record();
bytes[4..8].copy_from_slice(&8u32.to_le_bytes());
let error = ResFile::parse("t.res", bytes).unwrap_err();
assert_eq!(error.number, err_number::INVALID_FORMAT_IN_RESOURCE_FILE);
}
#[test]
fn header_size_disagreeing_with_parsed_fields_is_rejected() {
let mut bytes = null_record();
bytes[4..8].copy_from_slice(&36u32.to_le_bytes());
bytes.resize(36, 0);
let error = ResFile::parse("t.res", bytes).unwrap_err();
assert_eq!(error.number, err_number::INVALID_FORMAT_IN_RESOURCE_FILE);
}
#[test]
fn unterminated_string_name_is_rejected() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&32u32.to_le_bytes()); bytes.extend_from_slice(&ORDINAL_MARKER.to_le_bytes());
bytes.extend_from_slice(&rt::RCDATA.to_le_bytes());
bytes.extend_from_slice(&[0x41, 0x00, 0x42, 0x00]);
let error = ResFile::parse("t.res", bytes).unwrap_err();
assert_eq!(error.number, err_number::INVALID_FORMAT_IN_RESOURCE_FILE);
}
#[test]
fn res_id_ordinal_and_name_accessors() {
assert_eq!(ResId::Ordinal(42).as_ordinal(), Some(42));
assert_eq!(ResId::Name("A".to_string()).as_ordinal(), None);
assert!(ResId::Name("24".to_string()).matches_ordinal(24));
assert!(ResId::Name("#24".to_string()).matches_ordinal(24));
assert!(!ResId::Name("DLL".to_string()).matches_ordinal(24));
assert!(!ResId::Ordinal(24).matches_name("24"));
assert_eq!(ResId::Ordinal(7).to_string(), "7");
assert_eq!(ResId::Name("LOGO".to_string()).to_string(), "LOGO");
}
fn with_res_file<T>(name: &str, content: &[u8], f: impl FnOnce(&str) -> T) -> T {
let _guard = crate::state::test_support::lock_test();
let _ = file::close_all_files();
file::set_backend(Box::new(file::memory::MemoryBackend::new()));
file::set_root("/");
let path = format!("/{name}");
file::write_memory_file(&path, content).unwrap();
let result = f(&path);
let _ = file::close_all_files();
file::reset_backend();
result
}
#[test]
fn load_reads_through_the_file_backend() {
let mut bytes = null_record();
bytes.extend(record(rt::RCDATA, 101, 0x0409, b"from-backend"));
with_res_file("app.res", &bytes, |path| {
let file = ResFile::load(path).unwrap();
assert_eq!(file.path(), path);
assert_eq!(file.entry_count(), 1);
let entry = file.find_by_ordinal(rt::RCDATA, 101).unwrap();
assert_eq!(file.data(entry), b"from-backend");
});
}
#[test]
fn load_releases_the_file_number() {
let bytes = null_record();
with_res_file("app.res", &bytes, |path| {
let before = file::free_file(0);
ResFile::load(path).unwrap();
assert_eq!(file::free_file(0), before, "file number leaked");
});
}
#[test]
fn load_reports_a_missing_file() {
with_res_file("app.res", &null_record(), |_| {
let error = ResFile::load("/absent.res").unwrap_err();
assert_eq!(error.number, err_number::FILE_NOT_FOUND);
});
}
#[test]
fn load_reports_a_malformed_file() {
let bytes = vec![0u8; 8];
with_res_file("bad.res", &bytes, |path| {
let error = ResFile::load(path).unwrap_err();
assert_eq!(error.number, err_number::INVALID_FORMAT_IN_RESOURCE_FILE);
});
}
fn load_test_data(relative_path: &str) -> ResFile {
let _guard = crate::state::test_support::lock_test();
let _ = file::close_all_files();
file::reset_backend();
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.to_path_buf();
file::set_root(workspace_root);
let result = ResFile::load(relative_path);
let _ = file::close_all_files();
file::reset_backend();
result.expect("test-data .res file should parse")
}
#[test]
fn parses_xpmanifest_res_from_test_data() {
let file =
load_test_data("test-data/Bitrate-calculator/Windows/Source-code/XPManifest.res");
assert_eq!(file.entry_count(), 1);
let entry = file.find_by_ordinal(rt::MANIFEST, 1).unwrap();
assert_eq!(entry.data_size, 644);
let xml = file.data(entry);
assert!(xml.starts_with(b"<?xml version=\"1.0\""));
assert!(xml.windows(8).any(|w| w == b"assembly"));
}
#[test]
fn parses_mexe2_2_res_from_test_data() {
let file = load_test_data("test-data/Environment/mexe2_2.res");
assert_eq!(file.entry_count(), 5);
let icons: Vec<_> = file.entries_of_type(rt::ICON).collect();
assert_eq!(icons.len(), 3);
assert_eq!(icons[0].data_size, 744);
assert_eq!(icons[0].language, 0x0409);
assert_eq!(&file.data(icons[0])[..4], &40u32.to_le_bytes());
let group = file.find_any_type(&ResId::Name("A".to_string())).unwrap();
assert_eq!(group.res_type, ResId::Ordinal(rt::GROUP_ICON));
assert_eq!(group.data_size, 48);
assert!(file.find_by_ordinal(rt::MANIFEST, 1).is_some());
}
#[test]
fn parses_m2000_res_with_a_numeric_string_type() {
let file = load_test_data("test-data/Environment/M2000.RES");
assert_eq!(file.entry_count(), 1);
let entry = &file.entries()[0];
assert_eq!(entry.res_type, ResId::Name("#24".to_string()));
assert_eq!(entry.data_size, 1176);
assert!(file.find_by_ordinal(rt::MANIFEST, 1).is_some());
}
#[test]
fn parses_project1_res_with_string_types_and_names() {
let file = load_test_data("test-data/CdiuBeatUpEditor/Project1.RES");
assert_eq!(file.entry_count(), 2);
let fmod = file
.find_any_type(&ResId::Name("FMOD".to_string()))
.unwrap();
assert_eq!(fmod.res_type, ResId::Name("DLL".to_string()));
assert_eq!(fmod.data_size, 161_280);
assert_eq!(file.data(fmod).len(), 161_280);
let zlib = file
.find_any_type(&ResId::Name("ZLIB".to_string()))
.unwrap();
assert_eq!(zlib.data_size, 53_760);
}
}