use crate::error::{LupinError, Result};
use crate::SteganographyEngine;
use log::debug;
pub struct JpegEngine;
impl JpegEngine {
pub fn new() -> Self {
Self
}
const SOI_MARKER: u16 = 0xFFD8;
const EOI_MARKER: u16 = 0xFFD9;
const APP13_MARKER: u16 = 0xFFED;
const SOS_MARKER: u16 = 0xFFDA;
const LUPIN_SIGNATURE: &'static [u8] = b"Lupin\0";
fn read_u16_be(data: &[u8]) -> u16 {
((data[0] as u16) << 8) | (data[1] as u16)
}
fn write_u16_be(value: u16) -> [u8; 2] {
[(value >> 8) as u8, value as u8]
}
fn find_insert_position(&self, jpeg_data: &[u8]) -> Result<usize> {
if jpeg_data.len() < 2 {
return Err(LupinError::JpegInvalidFormat {
reason: "File too short".to_string(),
});
}
let soi = Self::read_u16_be(&jpeg_data[0..2]);
if soi != Self::SOI_MARKER {
return Err(LupinError::JpegInvalidFormat {
reason: format!("Missing SOI marker, found 0x{:04X}", soi),
});
}
let mut pos = 2;
while pos + 4 <= jpeg_data.len() {
if jpeg_data[pos] != 0xFF {
break;
}
let marker = Self::read_u16_be(&jpeg_data[pos..pos + 2]);
if !(0xFFE0..=0xFFEF).contains(&marker) {
break; }
let length = Self::read_u16_be(&jpeg_data[pos + 2..pos + 4]) as usize;
if length < 2 || pos + 2 + length > jpeg_data.len() {
break; }
pos += 2 + length;
}
Ok(pos)
}
fn find_lupin_segments(&self, jpeg_data: &[u8]) -> Vec<(usize, usize)> {
let mut chunks = Vec::new();
let mut pos = 2;
while pos + 4 <= jpeg_data.len() {
if jpeg_data[pos] != 0xFF {
break; }
let marker = Self::read_u16_be(&jpeg_data[pos..pos + 2]);
if marker == Self::SOS_MARKER || marker == Self::EOI_MARKER {
break;
}
if marker == Self::SOI_MARKER || (0xFFD0..=0xFFD7).contains(&marker) {
pos += 2;
continue;
}
let length = Self::read_u16_be(&jpeg_data[pos + 2..pos + 4]) as usize;
if length < 2 || pos + 2 + length > jpeg_data.len() {
break;
}
let segment_end = pos + 2 + length;
if marker == Self::APP13_MARKER {
let data = &jpeg_data[pos + 4..segment_end];
if data.starts_with(Self::LUPIN_SIGNATURE) {
let chunk_start = pos + 4 + Self::LUPIN_SIGNATURE.len();
chunks.push((chunk_start, segment_end));
}
}
pos = segment_end;
}
chunks
}
}
impl Default for JpegEngine {
fn default() -> Self {
Self::new()
}
}
impl SteganographyEngine for JpegEngine {
fn magic_bytes(&self) -> &[u8] {
b"\xFF\xD8\xFF" }
fn format_name(&self) -> &str {
"JPEG"
}
fn format_ext(&self) -> &str {
".jpg"
}
fn embed(&self, source_data: &[u8], payload: &[u8]) -> Result<Vec<u8>> {
if payload.is_empty() {
return Err(LupinError::EmptyPayload);
}
if let Some(&(start, end)) = self.find_lupin_segments(source_data).first() {
debug!(
"JPEG: Found existing Lupin APP13 segment at {}-{}",
start, end
);
return Err(LupinError::EmbedCollision {
source: std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"JPEG already contains a Lupin APP13 segment",
),
});
}
let insert_pos = self.find_insert_position(source_data)?;
let max_chunk = 0xFFFF - 2 - Self::LUPIN_SIGNATURE.len();
let mut segments = Vec::new();
let mut chunks = payload.chunks(max_chunk);
loop {
let chunk: &[u8] = chunks.next().unwrap_or(&[]);
let segment_data_length = 2 + Self::LUPIN_SIGNATURE.len() + chunk.len();
segments.extend_from_slice(&Self::write_u16_be(Self::APP13_MARKER)); segments.extend_from_slice(&Self::write_u16_be(segment_data_length as u16)); segments.extend_from_slice(Self::LUPIN_SIGNATURE); segments.extend_from_slice(chunk);
if chunk.len() < max_chunk {
break;
}
}
debug!(
"JPEG: Inserting {} bytes of APP13 segment(s) at position {}",
segments.len(),
insert_pos
);
let mut result = Vec::with_capacity(source_data.len() + segments.len());
result.extend_from_slice(&source_data[..insert_pos]);
result.extend_from_slice(&segments);
result.extend_from_slice(&source_data[insert_pos..]);
Ok(result)
}
fn extract(&self, source_data: &[u8]) -> Result<Vec<u8>> {
let chunks = self.find_lupin_segments(source_data);
if chunks.is_empty() {
return Err(LupinError::JpegNoHiddenData);
}
debug!("JPEG: Found {} Lupin APP13 segment(s)", chunks.len());
let mut payload = Vec::new();
for (start, end) in chunks {
payload.extend_from_slice(&source_data[start..end]);
}
Ok(payload)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_JPEG: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69,
56, 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81,
104, 113, 92, 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99, 0xFF,
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xFF, 0xD9, ];
#[test]
fn test_jpeg_magic_bytes() {
let engine = JpegEngine::new();
assert_eq!(engine.magic_bytes(), b"\xFF\xD8\xFF");
}
#[test]
fn test_jpeg_format_info() {
let engine = JpegEngine::new();
assert_eq!(engine.format_name(), "JPEG");
assert_eq!(engine.format_ext(), ".jpg");
}
#[test]
fn test_embed_and_extract() {
let engine = JpegEngine::new();
let payload = b"Secret message hidden in JPEG!";
let embedded = engine.embed(MINIMAL_JPEG, payload).unwrap();
assert!(embedded.len() > MINIMAL_JPEG.len());
assert_eq!(&embedded[0..2], &[0xFF, 0xD8]);
let extracted = engine.extract(&embedded).unwrap();
assert_eq!(extracted, payload);
}
#[test]
fn test_embed_collision() {
let engine = JpegEngine::new();
let payload1 = b"First payload";
let payload2 = b"Second payload";
let embedded_once = engine.embed(MINIMAL_JPEG, payload1).unwrap();
let result = engine.embed(&embedded_once, payload2);
assert!(matches!(result, Err(LupinError::EmbedCollision { .. })));
}
#[test]
fn test_extract_without_data() {
let engine = JpegEngine::new();
let result = engine.extract(MINIMAL_JPEG);
assert!(matches!(result, Err(LupinError::JpegNoHiddenData)));
}
#[test]
fn test_invalid_jpeg() {
let engine = JpegEngine::new();
let not_jpeg = b"This is not a JPEG file";
let result = engine.embed(not_jpeg, b"payload");
assert!(matches!(result, Err(LupinError::JpegInvalidFormat { .. })));
}
#[test]
fn test_empty_payload_rejected() {
let engine = JpegEngine::new();
let payload = b"";
assert!(matches!(
engine.embed(MINIMAL_JPEG, payload),
Err(LupinError::EmptyPayload)
));
}
#[test]
fn test_large_payload() {
let engine = JpegEngine::new();
let payload = vec![42u8; 10000];
let embedded = engine.embed(MINIMAL_JPEG, &payload).unwrap();
let extracted = engine.extract(&embedded).unwrap();
assert_eq!(extracted, payload);
}
#[test]
fn test_multi_segment_payload() {
let engine = JpegEngine::new();
let payload: Vec<u8> = (0..250_000).map(|i| (i % 251) as u8).collect();
let embedded = engine.embed(MINIMAL_JPEG, &payload).unwrap();
assert!(
engine.find_lupin_segments(&embedded).len() > 1,
"large payload should span multiple APP13 segments"
);
let extracted = engine.extract(&embedded).unwrap();
assert_eq!(extracted, payload);
}
#[test]
fn test_payload_at_segment_boundary() {
let engine = JpegEngine::new();
let max_chunk = 0xFFFF - 2 - JpegEngine::LUPIN_SIGNATURE.len();
for len in [max_chunk - 1, max_chunk, max_chunk + 1] {
let payload = vec![7u8; len];
let embedded = engine.embed(MINIMAL_JPEG, &payload).unwrap();
let extracted = engine.extract(&embedded).unwrap();
assert_eq!(extracted, payload, "round trip failed for len {}", len);
}
}
#[test]
fn test_unicode_payload() {
let engine = JpegEngine::new();
let payload = "Hello, 世界! 🕵️ Lupin steganography".as_bytes();
let embedded = engine.embed(MINIMAL_JPEG, payload).unwrap();
let extracted = engine.extract(&embedded).unwrap();
assert_eq!(extracted, payload);
}
fn jpeg_with_foreign_app13() -> Vec<u8> {
let foreign_data = b"Photoshop 3.0\x008BIM"; let length = (2 + foreign_data.len()) as u16;
let mut jpeg = vec![0xFF, 0xD8]; jpeg.extend_from_slice(&[0xFF, 0xED]); jpeg.extend_from_slice(&[(length >> 8) as u8, length as u8]); jpeg.extend_from_slice(foreign_data);
jpeg.extend_from_slice(&[0xFF, 0xD9]); jpeg
}
#[test]
fn test_foreign_app13_not_treated_as_lupin() {
let engine = JpegEngine::new();
let jpeg = jpeg_with_foreign_app13();
assert!(matches!(
engine.extract(&jpeg),
Err(LupinError::JpegNoHiddenData)
));
let embedded = engine.embed(&jpeg, b"real secret").unwrap();
assert_eq!(engine.extract(&embedded).unwrap(), b"real secret");
}
#[test]
fn test_extract_coexists_with_foreign_app13() {
let engine = JpegEngine::new();
let jpeg = jpeg_with_foreign_app13();
let embedded = engine.embed(&jpeg, b"payload").unwrap();
assert!(embedded.windows(9).any(|w| w == b"Photoshop"));
assert_eq!(engine.extract(&embedded).unwrap(), b"payload");
}
#[test]
fn test_truncated_segment_does_not_panic() {
let engine = JpegEngine::new();
let jpeg = vec![
0xFF, 0xD8, 0xFF, 0xED, 0xFF, 0xFF, ];
assert!(matches!(
engine.extract(&jpeg),
Err(LupinError::JpegNoHiddenData)
));
}
#[test]
fn test_undersized_length_does_not_panic() {
let engine = JpegEngine::new();
let jpeg = vec![
0xFF, 0xD8, 0xFF, 0xED, 0x00, 0x00, 0xFF, 0xD9, ];
assert!(matches!(
engine.extract(&jpeg),
Err(LupinError::JpegNoHiddenData)
));
}
#[test]
fn test_insert_preserves_jfif_ordering() {
let engine = JpegEngine::new();
let embedded = engine.embed(MINIMAL_JPEG, b"hi").unwrap();
assert_eq!(&embedded[0..2], &[0xFF, 0xD8]); assert_eq!(&embedded[2..4], &[0xFF, 0xE0]); }
}