use crate::stego::error::StegoError;
use std::io::{Read, Write};
const COMPRESS_NONE: u8 = 0b00;
const COMPRESS_BROTLI: u8 = 0b01;
const COMPRESS_MASK: u8 = 0b11;
const BROTLI_QUALITY: u32 = 11;
const BROTLI_LG_WINDOW_SIZE: u32 = 22;
pub const MAX_RAW_FILE_SIZE: usize = 2 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
pub filename: String,
pub content: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayloadData {
pub text: String,
pub files: Vec<FileEntry>,
}
pub fn encode_payload(text: &str, files: &[FileEntry]) -> Result<Vec<u8>, StegoError> {
for file in files {
if file.content.len() > MAX_RAW_FILE_SIZE {
return Err(StegoError::MessageTooLarge);
}
if file.filename.len() > 255 {
return Err(StegoError::MessageTooLarge);
}
}
let inner = serialize_inner(text, files);
Ok(try_compress(&inner))
}
pub fn compressed_payload_size(text: &str, files: &[FileEntry]) -> usize {
encode_payload(text, files).map_or_else(|_| text.len() + 1, |v| v.len())
}
pub fn decode_payload(data: &[u8]) -> Result<PayloadData, StegoError> {
if data.is_empty() {
return Err(StegoError::FrameCorrupted);
}
let flags = data[0];
let compressed_data = &data[1..];
let inner = match flags & COMPRESS_MASK {
COMPRESS_NONE => compressed_data.to_vec(),
COMPRESS_BROTLI => decompress_brotli(compressed_data)?,
_ => return Err(StegoError::FrameCorrupted),
};
parse_inner(&inner)
}
fn serialize_inner(text: &str, files: &[FileEntry]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(text.as_bytes());
if !files.is_empty() {
buf.push(0x00); for file in files {
let name_bytes = file.filename.as_bytes();
let name_len = name_bytes.len().min(255) as u8;
buf.push(name_len);
buf.extend_from_slice(&name_bytes[..name_len as usize]);
buf.extend_from_slice(&(file.content.len() as u32).to_be_bytes());
buf.extend_from_slice(&file.content);
}
}
buf
}
fn parse_inner(data: &[u8]) -> Result<PayloadData, StegoError> {
let separator_pos = data.iter().position(|&b| b == 0x00);
match separator_pos {
None => {
let text = std::str::from_utf8(data)
.map_err(|_| StegoError::InvalidUtf8)?
.to_string();
Ok(PayloadData { text, files: vec![] })
}
Some(pos) => {
let text = std::str::from_utf8(&data[..pos])
.map_err(|_| StegoError::InvalidUtf8)?
.to_string();
let mut files = Vec::new();
let mut cursor = pos + 1;
while cursor < data.len() {
let name_len = data[cursor] as usize;
cursor += 1;
if name_len == 0 || cursor + name_len > data.len() {
return Err(StegoError::FrameCorrupted);
}
let filename = std::str::from_utf8(&data[cursor..cursor + name_len])
.map_err(|_| StegoError::InvalidUtf8)?
.to_string();
cursor += name_len;
if cursor + 4 > data.len() {
return Err(StegoError::FrameCorrupted);
}
let content_len = u32::from_be_bytes([
data[cursor],
data[cursor + 1],
data[cursor + 2],
data[cursor + 3],
]) as usize;
cursor += 4;
if cursor + content_len > data.len() {
return Err(StegoError::FrameCorrupted);
}
let content = data[cursor..cursor + content_len].to_vec();
cursor += content_len;
files.push(FileEntry { filename, content });
}
Ok(PayloadData { text, files })
}
}
}
fn try_compress(inner: &[u8]) -> Vec<u8> {
if inner.len() < 32 {
let mut result = Vec::with_capacity(1 + inner.len());
result.push(COMPRESS_NONE);
result.extend_from_slice(inner);
return result;
}
let compressed = compress_brotli(inner);
if compressed.len() < inner.len() {
let mut result = Vec::with_capacity(1 + compressed.len());
result.push(COMPRESS_BROTLI);
result.extend_from_slice(&compressed);
result
} else {
let mut result = Vec::with_capacity(1 + inner.len());
result.push(COMPRESS_NONE);
result.extend_from_slice(inner);
result
}
}
fn compress_brotli(data: &[u8]) -> Vec<u8> {
let mut output = Vec::new();
{
let mut compressor = brotli::CompressorWriter::new(
&mut output,
4096, BROTLI_QUALITY,
BROTLI_LG_WINDOW_SIZE,
);
compressor.write_all(data).expect("Brotli compression should not fail");
}
output
}
fn decompress_brotli(data: &[u8]) -> Result<Vec<u8>, StegoError> {
let mut output = Vec::new();
let decompressor = brotli::Decompressor::new(data, 4096);
let limit = 128 * 1024; decompressor.take(limit as u64).read_to_end(&mut output)
.map_err(|_| StegoError::FrameCorrupted)?;
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_only_roundtrip() {
let encoded = encode_payload("Hello, world!", &[]).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, "Hello, world!");
assert!(decoded.files.is_empty());
}
#[test]
fn empty_text_roundtrip() {
let encoded = encode_payload("", &[]).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, "");
assert!(decoded.files.is_empty());
}
#[test]
fn text_with_one_file() {
let files = vec![FileEntry {
filename: "test.txt".to_string(),
content: b"file content here".to_vec(),
}];
let encoded = encode_payload("hello", &files).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, "hello");
assert_eq!(decoded.files.len(), 1);
assert_eq!(decoded.files[0].filename, "test.txt");
assert_eq!(decoded.files[0].content, b"file content here");
}
#[test]
fn text_with_multiple_files() {
let files = vec![
FileEntry {
filename: "a.bin".to_string(),
content: vec![0xDE, 0xAD, 0xBE, 0xEF],
},
FileEntry {
filename: "readme.md".to_string(),
content: b"# Hello\nWorld".to_vec(),
},
];
let encoded = encode_payload("msg", &files).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, "msg");
assert_eq!(decoded.files.len(), 2);
assert_eq!(decoded.files[0].filename, "a.bin");
assert_eq!(decoded.files[0].content, vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(decoded.files[1].filename, "readme.md");
assert_eq!(decoded.files[1].content, b"# Hello\nWorld");
}
#[test]
fn empty_text_with_files() {
let files = vec![FileEntry {
filename: "data.bin".to_string(),
content: vec![1, 2, 3],
}];
let encoded = encode_payload("", &files).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, "");
assert_eq!(decoded.files.len(), 1);
}
#[test]
fn short_message_not_compressed() {
let encoded = encode_payload("hi", &[]).unwrap();
assert_eq!(encoded[0] & COMPRESS_MASK, COMPRESS_NONE);
}
#[test]
fn long_repetitive_text_compressed() {
let long_text = "abcdefghij".repeat(100); let encoded = encode_payload(&long_text, &[]).unwrap();
assert_eq!(encoded[0] & COMPRESS_MASK, COMPRESS_BROTLI);
assert!(encoded.len() < long_text.len());
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, long_text);
}
#[test]
fn large_compressible_file() {
let files = vec![FileEntry {
filename: "big.txt".to_string(),
content: b"Hello World! ".repeat(1000),
}];
let encoded = encode_payload("", &files).unwrap();
assert_eq!(encoded[0] & COMPRESS_MASK, COMPRESS_BROTLI);
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.files[0].content.len(), 13000);
}
#[test]
fn incompressible_data_stays_raw() {
let mut data = Vec::new();
for i in 0u16..200 {
data.push((i.wrapping_mul(7919) % 256) as u8);
}
let files = vec![FileEntry {
filename: "rand.bin".to_string(),
content: data.clone(),
}];
let encoded = encode_payload("", &files).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.files[0].content, data);
}
#[test]
fn unicode_text_and_filename() {
let files = vec![FileEntry {
filename: "daten-übersicht.pdf".to_string(),
content: vec![0xFF],
}];
let encoded = encode_payload("Ünïcödé 🎉", &files).unwrap();
let decoded = decode_payload(&encoded).unwrap();
assert_eq!(decoded.text, "Ünïcödé 🎉");
assert_eq!(decoded.files[0].filename, "daten-übersicht.pdf");
}
#[test]
fn empty_payload_error() {
assert!(decode_payload(&[]).is_err());
}
#[test]
fn truncated_file_entry_error() {
let data = vec![COMPRESS_NONE, b'A', 0x00, 5]; assert!(decode_payload(&data).is_err());
}
#[test]
fn zero_length_filename_error() {
let data = vec![COMPRESS_NONE, 0x00, 0]; assert!(decode_payload(&data).is_err());
}
}