#![cfg(feature = "envelope")]
use jomini::{
Encoding, Utf8Encoding,
binary::{
BinaryDeserializerBuilder, BinaryFlavor, Token, TokenResolver, de::BinaryReaderDeserializer,
},
envelope::{
BinaryEncoding, EnvelopeErrorKind, JominiFile, JominiFileKind, SaveContent,
SaveContentKind, SaveData, SaveDataKind, SaveHeaderKind, SaveMetadata, SaveMetadataKind,
},
};
use rawzip::ReaderAt;
use serde::Deserialize;
use std::{collections::HashMap, io::Read};
#[derive(Debug, PartialEq, Deserialize)]
struct Meta {
meta: bool,
}
#[derive(Debug, PartialEq, Deserialize)]
struct Gamestate {
meta: bool,
gamestate: String,
}
#[derive(Debug, PartialEq, Deserialize)]
struct BinaryMeta {
meta: u32,
}
#[derive(Debug, PartialEq, Deserialize)]
struct BinaryGamestate {
meta: u32,
gamestate: u32,
}
const EXPECTED_TEXT_META: &str = "meta=yes\n";
const EXPECTED_TEXT_GAMESTATE: &str = "meta=yes\ngamestate=\"hi\"\n";
const EXPECTED_BINARY_META_TOKENS: &[Token] =
&[Token::Id(0x1000), Token::Equal, Token::U32(0xdddd)];
const EXPECTED_BINARY_GAMESTATE_TOKENS: &[Token] = &[
Token::Id(0x1000),
Token::Equal,
Token::U32(0xdddd),
Token::Id(0x2000),
Token::Equal,
Token::U32(0xffff),
];
const EXPECTED_BINARY_META: BinaryMeta = BinaryMeta { meta: 0xdddd };
const EXPECTED_BINARY_GAMESTATE: BinaryGamestate = BinaryGamestate {
meta: 0xdddd,
gamestate: 0xffff,
};
fn plaintext_uncompressed_assertions(file: JominiFile<impl ReaderAt>) {
let JominiFileKind::Uncompressed(SaveDataKind::Text(data_txt)) = file.kind() else {
panic!("expected text uncompressed envelope");
};
let expected_gamestate = Gamestate {
meta: true,
gamestate: "hi".to_string(),
};
let mut meta = data_txt.meta();
let mut buf = String::new();
meta.read_to_string(&mut buf).unwrap();
assert_eq!(&buf, EXPECTED_TEXT_META);
let meta: Meta = data_txt.meta().deserializer().deserialize().unwrap();
assert_eq!(meta, Meta { meta: true });
let mut gamestate = data_txt.body().cursor();
let mut buf = String::new();
gamestate.read_to_string(&mut buf).unwrap();
assert_eq!(&buf, EXPECTED_TEXT_GAMESTATE);
let gamestate: Gamestate = data_txt.deserializer().deserialize().unwrap();
assert_eq!(gamestate, expected_gamestate);
let mut meta = file.meta().unwrap();
let mut buf = String::new();
meta.read_to_string(&mut buf).unwrap();
assert_eq!(&buf, EXPECTED_TEXT_META);
let meta = file.meta().unwrap();
let SaveMetadataKind::Text(mut meta_txt) = meta else {
panic!("expected text meta");
};
let meta: Meta = meta_txt.deserializer().deserialize().unwrap();
assert_eq!(meta, Meta { meta: true });
let mut gamestate = file.gamestate().unwrap();
let mut buf = String::new();
gamestate.read_to_string(&mut buf).unwrap();
assert_eq!(&buf, EXPECTED_TEXT_GAMESTATE);
let gamestate = file.gamestate().unwrap();
let SaveContentKind::Text(mut gamestate_txt) = gamestate else {
panic!("expected text gamestate");
};
let gamestate: Gamestate = gamestate_txt.deserializer().deserialize().unwrap();
assert_eq!(gamestate, expected_gamestate);
}
fn binary_uncompressed_assertions(file: JominiFile<impl ReaderAt>) {
let JominiFileKind::Uncompressed(SaveDataKind::Binary(data_bin)) = file.kind() else {
panic!("expected binary uncompressed envelope");
};
let resolver = HashMap::<u16, &str>::from([(0x1000, "meta"), (0x2000, "gamestate")]);
let meta = data_bin.meta();
expected_tokens(EXPECTED_BINARY_META_TOKENS, meta);
let mut meta = data_bin.meta();
let actual: BinaryMeta = meta.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_META);
let gamestate = data_bin.body().cursor();
expected_tokens(EXPECTED_BINARY_GAMESTATE_TOKENS, gamestate);
let mut gamestate = data_bin.body().cursor();
let actual: BinaryGamestate = BinaryDeserializerBuilder::with_flavor(TestFlavor)
.from_reader(&mut gamestate, &resolver)
.deserialize()
.unwrap();
assert_eq!(actual, EXPECTED_BINARY_GAMESTATE);
let meta = file.meta().unwrap();
expected_tokens(EXPECTED_BINARY_META_TOKENS, meta);
let SaveMetadataKind::Binary(mut meta) = file.meta().unwrap() else {
panic!("expected binary meta");
};
let actual: BinaryMeta = meta.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_META);
let gamestate = file.gamestate().unwrap();
expected_tokens(EXPECTED_BINARY_GAMESTATE_TOKENS, gamestate);
let SaveContentKind::Binary(mut gamestate) = file.gamestate().unwrap() else {
panic!("expected binary gamestate");
};
let actual: BinaryGamestate = gamestate.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_GAMESTATE);
}
#[test]
fn plaintext_extraction() {
let file = std::fs::File::open("tests/fixtures/envelopes/text.txt").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::Text);
plaintext_uncompressed_assertions(file);
let file = std::fs::read("tests/fixtures/envelopes/text.txt").unwrap();
let file = JominiFile::from_slice(&file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::Text);
plaintext_uncompressed_assertions(file);
}
fn expected_tokens(expected: &[Token], reader: impl std::io::Read) {
let mut reader = jomini::binary::TokenReader::new(reader);
let mut i = 0;
while let Some(token) = reader.next().unwrap() {
let expected_token = &expected[i];
assert_eq!(&token, expected_token);
i += 1;
}
assert_eq!(i, expected.len());
}
fn zip_binary_assertions(file: &JominiFile<impl ReaderAt>) {
let JominiFileKind::Zip(kind) = file.kind() else {
panic!("expected zip binary envelope");
};
let resolver = HashMap::<u16, &str>::from([(0x1000, "meta"), (0x2000, "gamestate")]);
let meta = kind.meta().unwrap();
expected_tokens(EXPECTED_BINARY_META_TOKENS, meta);
let SaveMetadataKind::Binary(meta) = kind.meta().unwrap() else {
panic!("expected binary meta");
};
expected_tokens(EXPECTED_BINARY_META_TOKENS, meta);
let SaveMetadataKind::Binary(mut meta) = kind.meta().unwrap() else {
panic!("expected binary meta");
};
let actual: BinaryMeta = meta.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_META);
let gamestate = kind.gamestate().unwrap();
expected_tokens(EXPECTED_BINARY_GAMESTATE_TOKENS, gamestate);
let SaveContentKind::Binary(mut gamestate) = kind.gamestate().unwrap() else {
panic!("expected binary gamestate");
};
let actual: BinaryGamestate = gamestate.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_GAMESTATE);
let meta = file.meta().unwrap();
expected_tokens(EXPECTED_BINARY_META_TOKENS, meta);
let SaveMetadataKind::Binary(mut meta) = file.meta().unwrap() else {
panic!("expected binary meta");
};
let actual: BinaryMeta = meta.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_META);
let gamestate = file.gamestate().unwrap();
expected_tokens(EXPECTED_BINARY_GAMESTATE_TOKENS, gamestate);
let SaveContentKind::Binary(mut gamestate) = file.gamestate().unwrap() else {
panic!("expected binary gamestate");
};
let actual: BinaryGamestate = gamestate.deserializer(&resolver).deserialize().unwrap();
assert_eq!(actual, EXPECTED_BINARY_GAMESTATE);
}
fn zip_text_assertions(file: JominiFile<impl ReaderAt>) {
let JominiFileKind::Zip(kind) = file.kind() else {
panic!("expected zip text envelope");
};
let SaveMetadataKind::Text(mut meta) = kind.meta().unwrap() else {
panic!("expected text meta");
};
let mut buf = String::new();
let _ = meta.read_to_string(&mut buf);
let mut gamestate = kind.gamestate().unwrap();
let mut buf = String::new();
gamestate.read_to_string(&mut buf).unwrap();
assert_eq!(&buf, EXPECTED_TEXT_GAMESTATE);
let SaveContentKind::Text(mut gamestate) = kind.gamestate().unwrap() else {
panic!("expected text gamestate");
};
let expected_gamestate = Gamestate {
meta: true,
gamestate: "hi".to_string(),
};
let actual: Gamestate = gamestate.deserializer().deserialize().unwrap();
assert_eq!(actual, expected_gamestate);
let SaveMetadataKind::Text(_meta) = file.meta().unwrap() else {
panic!("expected text meta");
};
let SaveMetadataKind::Text(mut meta_txt) = file.meta().unwrap() else {
panic!("expected text meta");
};
let meta: Meta = meta_txt.deserializer().deserialize().unwrap();
assert_eq!(meta, Meta { meta: true });
let mut gamestate = file.gamestate().unwrap();
let mut buf = String::new();
gamestate.read_to_string(&mut buf).unwrap();
assert_eq!(&buf, EXPECTED_TEXT_GAMESTATE);
let SaveContentKind::Text(mut gamestate) = file.gamestate().unwrap() else {
panic!("expected text gamestate");
};
let gamestate: Gamestate = gamestate.deserializer().deserialize().unwrap();
assert_eq!(gamestate, expected_gamestate);
}
#[test]
fn zip_bin_compressed_meta() {
let file = std::fs::File::open("tests/fixtures/envelopes/split.zip").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::SplitBinary);
zip_binary_assertions(&file);
let file = std::fs::read("tests/fixtures/envelopes/split.zip").unwrap();
let file = JominiFile::from_slice(&file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::SplitBinary);
zip_binary_assertions(&file);
}
#[test]
fn zip_bin_uncompressed_meta() {
let file = std::fs::File::open("tests/fixtures/envelopes/header.zip").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::UnifiedBinary);
zip_binary_assertions(&file);
let file = std::fs::read("tests/fixtures/envelopes/header.zip").unwrap();
let file = JominiFile::from_slice(&file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::UnifiedBinary);
zip_binary_assertions(&file);
}
#[test]
fn bin_uncompressed() {
let file = std::fs::File::open("tests/fixtures/envelopes/autosave.bin").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::Binary);
binary_uncompressed_assertions(file);
let file = std::fs::read("tests/fixtures/envelopes/autosave.bin").unwrap();
let file = JominiFile::from_slice(&file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::Binary);
binary_uncompressed_assertions(file);
}
#[test]
fn zip_txt() {
let file = std::fs::File::open("tests/fixtures/envelopes/text.zip").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::UnifiedText);
zip_text_assertions(file);
let file = std::fs::read("tests/fixtures/envelopes/text.zip").unwrap();
let file = JominiFile::from_slice(&file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::UnifiedText);
zip_text_assertions(file);
}
#[test]
fn envelope_vec() {
let file = std::fs::read("tests/fixtures/envelopes/text.zip").unwrap();
let file = JominiFile::from_slice(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::UnifiedText);
zip_text_assertions(file);
}
#[test]
fn malformed_zip_doesnt_panic() {
let data = std::fs::read("tests/fixtures/envelopes/malformed_zip.bin").unwrap();
let file = JominiFile::from_slice(&data).unwrap();
let header = file.header();
assert_eq!(header.kind(), SaveHeaderKind::UnifiedText);
if let Ok(mut meta) = file.meta() {
let _bytes_read = std::io::copy(&mut meta, &mut std::io::sink());
}
if let Ok(mut gamestate) = file.gamestate() {
let _bytes_read = std::io::copy(&mut gamestate, &mut std::io::sink());
}
}
#[test]
fn txt_eu5_sav02() {
let file = std::fs::File::open("tests/fixtures/envelopes/sav02.txt").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::Text);
plaintext_uncompressed_assertions(file);
}
#[test]
fn zip_eu5_sav02() {
let file = std::fs::File::open("tests/fixtures/envelopes/lookup.zip").unwrap();
let file = JominiFile::from_file(file).unwrap();
assert_eq!(file.header().kind(), SaveHeaderKind::UnifiedBinary);
zip_binary_assertions(&file);
let JominiFileKind::Zip(zip) = file.kind() else {
panic!("expected zip binary envelope");
};
let mut lookup_file = zip.read_entry("string-lookup").unwrap();
let mut lookup_data = String::new();
lookup_file.read_to_string(&mut lookup_data).unwrap();
assert_eq!(lookup_data, "hello-world");
let Err(err) = zip.read_entry("nonexistent-file") else {
panic!("expected missing entry error");
};
assert!(err.is_missing_entry());
}
fn find_central_directory_header(data: &[u8], name: &[u8]) -> usize {
let archive = rawzip::ZipArchive::from_slice(data).unwrap();
let mut entries = archive.entries();
while let Some(entry) = entries.next_entry().unwrap() {
if entry.file_path().as_ref() == name {
return entry.central_directory_offset() as usize;
}
}
panic!("central directory header not found for {:?}", name);
}
#[test]
fn zip_gamestate_verified_matches_plain() {
let data = std::fs::read("tests/fixtures/envelopes/text.zip").unwrap();
let file = JominiFile::from_slice(&data).unwrap();
let JominiFileKind::Zip(zip) = file.kind() else {
panic!("expected zip text envelope");
};
let mut verified = String::new();
zip.gamestate_verified()
.unwrap()
.read_to_string(&mut verified)
.unwrap();
assert_eq!(verified, EXPECTED_TEXT_GAMESTATE);
let mut plain = String::new();
zip.gamestate().unwrap().read_to_string(&mut plain).unwrap();
assert_eq!(verified, plain);
let SaveContentKind::Text(content) = zip.gamestate_verified().unwrap() else {
panic!("expected text gamestate");
};
let mut reader = content.into_inner();
let copied = std::io::copy(&mut reader, &mut std::io::sink()).unwrap();
assert_eq!(copied as usize, EXPECTED_TEXT_GAMESTATE.len());
reader.finish().unwrap();
}
#[test]
fn zip_gamestate_verified_detects_bad_crc() {
let mut data = std::fs::read("tests/fixtures/envelopes/text.zip").unwrap();
let cd = find_central_directory_header(&data, b"gamestate");
let orig = u32::from_le_bytes(data[cd + 16..cd + 20].try_into().unwrap());
data[cd + 16..cd + 20].copy_from_slice(&(orig ^ 0xFFFF_FFFF).to_le_bytes());
let file = JominiFile::from_slice(&data).unwrap();
let JominiFileKind::Zip(zip) = file.kind() else {
panic!("expected zip text envelope");
};
let mut plain = String::new();
zip.gamestate().unwrap().read_to_string(&mut plain).unwrap();
assert_eq!(plain, EXPECTED_TEXT_GAMESTATE);
let SaveContentKind::Text(content) = zip.gamestate_verified().unwrap() else {
panic!("expected text gamestate");
};
let err = content.into_inner().finish().unwrap_err();
assert!(
matches!(err.kind(), EnvelopeErrorKind::ChecksumMismatch { expected, .. } if *expected == (orig ^ 0xFFFF_FFFF)),
"unexpected error: {err:?}"
);
let mut buf = String::new();
let io_err = zip
.gamestate_verified()
.unwrap()
.read_to_string(&mut buf)
.unwrap_err();
assert_eq!(io_err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn zip_read_entry_verified() {
let data = std::fs::read("tests/fixtures/envelopes/lookup.zip").unwrap();
let file = JominiFile::from_slice(&data).unwrap();
let JominiFileKind::Zip(zip) = file.kind() else {
panic!("expected zip binary envelope");
};
let mut lookup = String::new();
zip.read_entry_verified("string-lookup")
.unwrap()
.read_to_string(&mut lookup)
.unwrap();
assert_eq!(lookup, "hello-world");
let err = zip.read_entry_verified("nonexistent-file").unwrap_err();
assert!(err.is_missing_entry());
}
#[test]
fn zip_gamestate_verified_detects_bad_size() {
let mut data = std::fs::read("tests/fixtures/envelopes/text.zip").unwrap();
let cd = find_central_directory_header(&data, b"gamestate");
let orig = u32::from_le_bytes(data[cd + 24..cd + 28].try_into().unwrap());
data[cd + 24..cd + 28].copy_from_slice(&(orig + 1000).to_le_bytes());
let file = JominiFile::from_slice(&data).unwrap();
let JominiFileKind::Zip(zip) = file.kind() else {
panic!("expected zip text envelope");
};
let SaveContentKind::Text(content) = zip.gamestate_verified().unwrap() else {
panic!("expected text gamestate");
};
let err = content.into_inner().finish().unwrap_err();
assert!(
matches!(err.kind(), EnvelopeErrorKind::SizeMismatch { expected, actual } if *expected == u64::from(orig) + 1000 && *actual == u64::from(orig)),
"unexpected error: {err:?}"
);
}
struct TestFlavor;
impl Encoding for TestFlavor {
fn decode<'a>(&self, data: &'a [u8]) -> std::borrow::Cow<'a, str> {
Utf8Encoding::decode(data)
}
}
impl BinaryFlavor for TestFlavor {
fn visit_f32(&self, data: [u8; 4]) -> f32 {
f32::from_le_bytes(data)
}
fn visit_f64(&self, data: [u8; 8]) -> f64 {
f64::from_le_bytes(data)
}
}
type BinaryDeserializer<'r, 'res, RES> = BinaryReaderDeserializer<'r, 'res, RES, TestFlavor>;
trait DeserializeBinary {
fn deserializer<'r, 'res, RES: TokenResolver>(
&'r mut self,
resolver: &'res RES,
) -> BinaryDeserializer<'r, 'res, RES>
where
Self: 'r;
}
impl<R: ReaderAt> DeserializeBinary for SaveData<BinaryEncoding, R> {
fn deserializer<'r, 'res, RES: TokenResolver>(
&'r mut self,
resolver: &'res RES,
) -> BinaryDeserializer<'r, 'res, RES>
where
Self: 'r,
{
BinaryDeserializerBuilder::with_flavor(TestFlavor)
.from_reader(self.body().cursor(), resolver)
}
}
impl<R: Read> DeserializeBinary for SaveContent<BinaryEncoding, R> {
fn deserializer<'r, 'res, RES: TokenResolver>(
&'r mut self,
resolver: &'res RES,
) -> BinaryDeserializer<'r, 'res, RES>
where
Self: 'r,
{
BinaryDeserializerBuilder::with_flavor(TestFlavor).from_reader(self, resolver)
}
}
impl<R: Read> DeserializeBinary for SaveMetadata<BinaryEncoding, R> {
fn deserializer<'r, 'res, RES: TokenResolver>(
&'r mut self,
resolver: &'res RES,
) -> BinaryDeserializer<'r, 'res, RES>
where
Self: 'r,
{
BinaryDeserializerBuilder::with_flavor(TestFlavor).from_reader(self, resolver)
}
}
#[test]
fn body_consistency_between_file_and_slice() {
test_body_consistency("tests/fixtures/envelopes/text.txt");
test_body_consistency("tests/fixtures/envelopes/autosave.bin");
test_body_consistency("tests/fixtures/envelopes/sav02.txt");
}
fn test_body_consistency(file_path: &str) {
let data = std::fs::read(file_path).unwrap();
let file_from_slice = JominiFile::from_slice(&data).unwrap();
let file_from_file = JominiFile::from_file(std::fs::File::open(file_path).unwrap()).unwrap();
let mut buf_from_slice = Vec::new();
match file_from_slice.kind() {
JominiFileKind::Uncompressed(SaveDataKind::Text(data)) => {
data.body()
.cursor()
.read_to_end(&mut buf_from_slice)
.unwrap();
}
JominiFileKind::Uncompressed(SaveDataKind::Binary(data)) => {
data.body()
.cursor()
.read_to_end(&mut buf_from_slice)
.unwrap();
}
JominiFileKind::Zip(_) => panic!("expected uncompressed data from slice"),
};
let mut buf_from_file = Vec::new();
match file_from_file.kind() {
JominiFileKind::Uncompressed(SaveDataKind::Text(data)) => {
data.body()
.cursor()
.read_to_end(&mut buf_from_file)
.unwrap();
}
JominiFileKind::Uncompressed(SaveDataKind::Binary(data)) => {
data.body()
.cursor()
.read_to_end(&mut buf_from_file)
.unwrap();
}
JominiFileKind::Zip(_) => panic!("expected uncompressed data from file"),
};
assert_eq!(
buf_from_slice, buf_from_file,
"Body content should match between from_slice and from_file"
);
assert!(
!buf_from_slice.starts_with(b"SAV"),
"Body should not include header"
);
}
#[test]
fn body_slice_via_offset_excludes_header() {
let data = std::fs::read("tests/fixtures/envelopes/text.txt").unwrap();
let file = JominiFile::from_slice(&data).unwrap();
let JominiFileKind::Uncompressed(SaveDataKind::Text(save_data)) = file.kind() else {
panic!("expected text uncompressed");
};
let body = save_data.body();
let source = body.get_ref().get_ref().as_slice();
let body_slice = &source[body.content_offset() as usize..];
let mut body_from_cursor = Vec::new();
body.cursor().read_to_end(&mut body_from_cursor).unwrap();
assert_eq!(body_slice, body_from_cursor.as_slice());
assert!(
!body_slice.starts_with(b"SAV"),
"Body slice should not include header, but got: {:?}",
std::str::from_utf8(&body_slice[..3.min(body_slice.len())])
);
}