use oxideav_core::{Error, Result};
pub const EMSG_UNKNOWN_DURATION: u32 = 0xFFFF_FFFF;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmsgTime {
Delta(u32),
Absolute(u64),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EmsgBox {
pub scheme_id_uri: String,
pub value: String,
pub timescale: u32,
pub presentation: EmsgTime,
pub event_duration: u32,
pub id: u32,
pub message_data: Vec<u8>,
}
impl EmsgBox {
pub fn version(&self) -> u8 {
match self.presentation {
EmsgTime::Delta(_) => 0,
EmsgTime::Absolute(_) => 1,
}
}
pub fn presentation_time(&self) -> Option<u64> {
match self.presentation {
EmsgTime::Absolute(t) => Some(t),
EmsgTime::Delta(_) => None,
}
}
pub fn presentation_time_delta(&self) -> Option<u32> {
match self.presentation {
EmsgTime::Delta(d) => Some(d),
EmsgTime::Absolute(_) => None,
}
}
pub fn event_duration_unknown(&self) -> bool {
self.event_duration == EMSG_UNKNOWN_DURATION
}
}
fn read_cstring(body: &[u8], cursor: usize, what: &str) -> Result<(String, usize)> {
let rest = body
.get(cursor..)
.ok_or_else(|| Error::invalid(format!("MP4 emsg: truncated before {what}")))?;
let nul = rest
.iter()
.position(|&b| b == 0)
.ok_or_else(|| Error::invalid(format!("MP4 emsg: unterminated {what}")))?;
let s = std::str::from_utf8(&rest[..nul])
.map_err(|_| Error::invalid(format!("MP4 emsg: {what} is not valid UTF-8")))?;
Ok((s.to_string(), cursor + nul + 1))
}
fn read_u32(body: &[u8], cursor: usize, what: &str) -> Result<u32> {
let b = body
.get(cursor..cursor + 4)
.ok_or_else(|| Error::invalid(format!("MP4 emsg: truncated {what}")))?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_u64(body: &[u8], cursor: usize, what: &str) -> Result<u64> {
let b = body
.get(cursor..cursor + 8)
.ok_or_else(|| Error::invalid(format!("MP4 emsg: truncated {what}")))?;
Ok(u64::from_be_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
pub fn parse_emsg_box(body: &[u8]) -> Result<EmsgBox> {
if body.len() < 4 {
return Err(Error::invalid("MP4 emsg: missing FullBox header"));
}
let version = body[0];
match version {
0 => {
let (scheme_id_uri, cursor) = read_cstring(body, 4, "scheme_id_uri")?;
let (value, cursor) = read_cstring(body, cursor, "value")?;
let timescale = read_u32(body, cursor, "timescale")?;
let delta = read_u32(body, cursor + 4, "presentation_time_delta")?;
let event_duration = read_u32(body, cursor + 8, "event_duration")?;
let id = read_u32(body, cursor + 12, "id")?;
Ok(EmsgBox {
scheme_id_uri,
value,
timescale,
presentation: EmsgTime::Delta(delta),
event_duration,
id,
message_data: body[cursor + 16..].to_vec(),
})
}
1 => {
let timescale = read_u32(body, 4, "timescale")?;
let presentation_time = read_u64(body, 8, "presentation_time")?;
let event_duration = read_u32(body, 16, "event_duration")?;
let id = read_u32(body, 20, "id")?;
let (scheme_id_uri, cursor) = read_cstring(body, 24, "scheme_id_uri")?;
let (value, cursor) = read_cstring(body, cursor, "value")?;
Ok(EmsgBox {
scheme_id_uri,
value,
timescale,
presentation: EmsgTime::Absolute(presentation_time),
event_duration,
id,
message_data: body[cursor..].to_vec(),
})
}
v => Err(Error::invalid(format!(
"MP4 emsg: undefined version {v} (field order unknown)"
))),
}
}
pub fn build_emsg_box(e: &EmsgBox) -> Result<Vec<u8>> {
for (name, s) in [("scheme_id_uri", &e.scheme_id_uri), ("value", &e.value)] {
if s.as_bytes().contains(&0) {
return Err(Error::invalid(format!(
"MP4 emsg build: {name} contains an interior NUL byte"
)));
}
}
let mut body = Vec::with_capacity(
4 + e.scheme_id_uri.len() + e.value.len() + 2 + 20 + e.message_data.len(),
);
match e.presentation {
EmsgTime::Delta(delta) => {
body.extend_from_slice(&[0, 0, 0, 0]); body.extend_from_slice(e.scheme_id_uri.as_bytes());
body.push(0);
body.extend_from_slice(e.value.as_bytes());
body.push(0);
body.extend_from_slice(&e.timescale.to_be_bytes());
body.extend_from_slice(&delta.to_be_bytes());
body.extend_from_slice(&e.event_duration.to_be_bytes());
body.extend_from_slice(&e.id.to_be_bytes());
}
EmsgTime::Absolute(pt) => {
body.extend_from_slice(&[1, 0, 0, 0]); body.extend_from_slice(&e.timescale.to_be_bytes());
body.extend_from_slice(&pt.to_be_bytes());
body.extend_from_slice(&e.event_duration.to_be_bytes());
body.extend_from_slice(&e.id.to_be_bytes());
body.extend_from_slice(e.scheme_id_uri.as_bytes());
body.push(0);
body.extend_from_slice(e.value.as_bytes());
body.push(0);
}
}
body.extend_from_slice(&e.message_data);
let total = u32::try_from(8 + body.len())
.map_err(|_| Error::invalid("MP4 emsg build: box size exceeds u32"))?;
let mut out = Vec::with_capacity(total as usize);
out.extend_from_slice(&total.to_be_bytes());
out.extend_from_slice(b"emsg");
out.extend_from_slice(&body);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_v0() -> EmsgBox {
EmsgBox {
scheme_id_uri: "urn:scte:scte35:2013:bin".to_string(),
value: "1001".to_string(),
timescale: 90_000,
presentation: EmsgTime::Delta(45_000),
event_duration: 270_000,
id: 7,
message_data: vec![0xFC, 0x30, 0x11, 0x00],
}
}
fn sample_v1() -> EmsgBox {
EmsgBox {
scheme_id_uri: "urn:example:events:2024".to_string(),
value: String::new(),
timescale: 1_000,
presentation: EmsgTime::Absolute(0x0001_2345_6789_ABCD),
event_duration: EMSG_UNKNOWN_DURATION,
id: 0xDEAD_BEEF,
message_data: b"payload".to_vec(),
}
}
#[test]
fn v0_round_trip() {
let e = sample_v0();
let bytes = build_emsg_box(&e).unwrap();
assert_eq!(&bytes[4..8], b"emsg");
assert_eq!(bytes[8], 0, "version 0");
let back = parse_emsg_box(&bytes[8..]).unwrap();
assert_eq!(back, e);
assert_eq!(back.version(), 0);
assert_eq!(back.presentation_time_delta(), Some(45_000));
assert_eq!(back.presentation_time(), None);
assert!(!back.event_duration_unknown());
}
#[test]
fn v1_round_trip_with_empty_value_and_unknown_duration() {
let e = sample_v1();
let bytes = build_emsg_box(&e).unwrap();
assert_eq!(bytes[8], 1, "version 1");
let back = parse_emsg_box(&bytes[8..]).unwrap();
assert_eq!(back, e);
assert_eq!(back.version(), 1);
assert_eq!(back.presentation_time(), Some(0x0001_2345_6789_ABCD));
assert_eq!(back.presentation_time_delta(), None);
assert!(back.event_duration_unknown());
}
#[test]
fn v0_field_order_is_strings_first() {
let e = EmsgBox {
scheme_id_uri: "a".to_string(),
value: "b".to_string(),
timescale: 1,
presentation: EmsgTime::Delta(2),
event_duration: 3,
id: 4,
message_data: vec![0xAA],
};
let bytes = build_emsg_box(&e).unwrap();
let body = &bytes[8..];
assert_eq!(&body[4..6], b"a\0");
assert_eq!(&body[6..8], b"b\0");
assert_eq!(&body[8..12], &1u32.to_be_bytes());
assert_eq!(&body[12..16], &2u32.to_be_bytes());
assert_eq!(&body[16..20], &3u32.to_be_bytes());
assert_eq!(&body[20..24], &4u32.to_be_bytes());
assert_eq!(&body[24..], &[0xAA]);
}
#[test]
fn v1_field_order_is_integers_first() {
let e = EmsgBox {
scheme_id_uri: "a".to_string(),
value: "b".to_string(),
timescale: 1,
presentation: EmsgTime::Absolute(2),
event_duration: 3,
id: 4,
message_data: vec![0xAA],
};
let bytes = build_emsg_box(&e).unwrap();
let body = &bytes[8..];
assert_eq!(&body[4..8], &1u32.to_be_bytes());
assert_eq!(&body[8..16], &2u64.to_be_bytes());
assert_eq!(&body[16..20], &3u32.to_be_bytes());
assert_eq!(&body[20..24], &4u32.to_be_bytes());
assert_eq!(&body[24..26], b"a\0");
assert_eq!(&body[26..28], b"b\0");
assert_eq!(&body[28..], &[0xAA]);
}
#[test]
fn empty_message_data_round_trips() {
let mut e = sample_v0();
e.message_data.clear();
let bytes = build_emsg_box(&e).unwrap();
let back = parse_emsg_box(&bytes[8..]).unwrap();
assert!(back.message_data.is_empty());
assert_eq!(back, e);
}
#[test]
fn undefined_version_is_rejected() {
for v in [2u8, 3, 0xFF] {
let body = [v, 0, 0, 0, 0, 0, 0, 0];
let err = parse_emsg_box(&body).expect_err("undefined version must fail");
assert!(format!("{err}").contains("version"), "{err}");
}
}
#[test]
fn truncated_bodies_are_rejected_not_panic() {
for sample in [sample_v0(), sample_v1()] {
let bytes = build_emsg_box(&sample).unwrap();
let body = &bytes[8..];
let msg_len = sample.message_data.len();
for cut in 0..body.len() {
match parse_emsg_box(&body[..cut]) {
Ok(parsed) => {
assert!(cut >= body.len() - msg_len, "cut {cut} of {}", body.len());
assert!(parsed.message_data.len() < msg_len);
}
Err(e) => {
assert!(format!("{e}").contains("MP4 emsg"), "{e}");
}
}
}
}
}
#[test]
fn unterminated_string_is_rejected() {
let mut body = vec![0u8, 0, 0, 0];
body.extend_from_slice(b"urn:no-terminator-here");
let err = parse_emsg_box(&body).expect_err("unterminated string must fail");
assert!(format!("{err}").contains("unterminated"), "{err}");
}
#[test]
fn invalid_utf8_is_rejected() {
let mut body = vec![0u8, 0, 0, 0];
body.extend_from_slice(&[0xFF, 0xFE, 0x00]); body.extend_from_slice(b"\0"); body.extend_from_slice(&[0u8; 16]);
let err = parse_emsg_box(&body).expect_err("invalid UTF-8 must fail");
assert!(format!("{err}").contains("UTF-8"), "{err}");
}
#[test]
fn interior_nul_in_string_is_rejected_on_build() {
let mut e = sample_v0();
e.value = "a\0b".to_string();
let err = build_emsg_box(&e).expect_err("interior NUL must fail");
assert!(format!("{err}").contains("NUL"), "{err}");
}
#[test]
fn nonzero_flags_are_tolerated_on_parse() {
let e = sample_v0();
let mut bytes = build_emsg_box(&e).unwrap();
bytes[9] = 0x12; let back = parse_emsg_box(&bytes[8..]).unwrap();
assert_eq!(back, e);
}
}