Skip to main content

moq_loc/
lib.rs

1//! Wire encoding for the Low Overhead Container (LOC) defined in
2//! [draft-ietf-moq-loc-04](https://www.ietf.org/archive/id/draft-ietf-moq-loc-04.html).
3//!
4//! A LOC frame is laid out as:
5//!
6//! ```text
7//! [varint: properties_length]
8//! [properties_block: properties_length bytes of KVPs]
9//! [codec_bitstream: remaining bytes]
10//! ```
11//!
12//! Each KVP starts with a delta-encoded type id, so properties are serialized in
13//! ascending type order. Even types carry a single varint value, odd types carry
14//! length-prefixed bytes. Recognized types:
15//!
16//! | ID   | Name        | Decoded into       |
17//! |------|-------------|--------------------|
18//! | 0x08 | Timescale   | [`Frame::timescale`] (optional, per-frame override) |
19//! | 0x0d | Video Config | Skipped. The hang catalog's `description` is authoritative. |
20//! | 0x10 | Timestamp   | [`Frame::timestamp`] (required) |
21//!
22//! Any other property is silently skipped on decode and never emitted on
23//! encode. Public properties are not handled here. They belong in the MoQ
24//! object header and are stripped by the transport layer.
25//!
26//! Varint encoding is QUIC-style throughout via [`moq_net::VarInt`].
27
28use bytes::{Buf, Bytes, BytesMut};
29use moq_net::{BoundsExceeded, DecodeError, EncodeError, VarInt};
30
31/// Property IDs recognized by this implementation.
32const PROP_TIMESCALE: u64 = 0x08;
33const PROP_TIMESTAMP: u64 = 0x10;
34
35/// The Timestamp id from draft-03, accepted on decode so frames from an older
36/// peer (including our own releases) still carry a timestamp.
37///
38/// Only the value from draft-03's IANA table is honored. Draft-03's body text
39/// disagreed with its own table and said 0x0A, which draft-04 assigns to Secure
40/// Objects private properties, so decoding 0x0A as a timestamp would misread
41/// somebody else's property.
42const PROP_TIMESTAMP_DRAFT03: u64 = 0x06;
43
44/// A decoded LOC frame.
45#[derive(Clone, Debug)]
46pub struct Frame {
47	/// Presentation timestamp, in units determined by the active timescale.
48	pub timestamp: u64,
49
50	/// Per-frame timescale override (property 0x08).
51	///
52	/// `Some` when the frame carried an explicit timescale, `None` when it
53	/// relies on the catalog's default.
54	pub timescale: Option<u64>,
55
56	/// Codec bitstream payload (the bytes after the properties block).
57	pub payload: Bytes,
58}
59
60/// Errors from LOC frame encode/decode.
61#[derive(Debug, Clone, thiserror::Error)]
62#[non_exhaustive]
63pub enum Error {
64	/// The frame's property block did not contain a 0x10 (Timestamp) entry.
65	#[error("loc frame missing required timestamp property")]
66	MissingTimestamp,
67
68	/// The property block ran past `properties_length` or was otherwise malformed.
69	#[error("malformed loc properties")]
70	MalformedProperties,
71
72	/// A varint did not fit in the buffer.
73	#[error("short buffer")]
74	ShortBuffer,
75
76	/// A value exceeds the 62-bit varint range.
77	#[error("value out of range: {0}")]
78	OutOfRange(#[from] BoundsExceeded),
79}
80
81// DecodeError / EncodeError intentionally collapse into ShortBuffer vs the
82// caller's catch-all variant, so they stay as manual From impls; #[from] can't
83// express that mapping.
84impl From<DecodeError> for Error {
85	fn from(err: DecodeError) -> Self {
86		match err {
87			DecodeError::Short => Self::ShortBuffer,
88			_ => Self::MalformedProperties,
89		}
90	}
91}
92
93impl From<EncodeError> for Error {
94	fn from(err: EncodeError) -> Self {
95		match err {
96			EncodeError::Short => Self::ShortBuffer,
97			_ => Self::OutOfRange(BoundsExceeded),
98		}
99	}
100}
101
102/// Decode a LOC frame.
103///
104/// Consumes the properties_length prefix, walks the bounded property block,
105/// and returns the remainder as `payload`.
106pub fn decode(mut buf: Bytes) -> Result<Frame, Error> {
107	let properties_length: u64 = VarInt::decode_quic(&mut buf)?.into();
108	let properties_length: usize = properties_length.try_into().map_err(|_| Error::MalformedProperties)?;
109
110	if properties_length > buf.remaining() {
111		return Err(Error::MalformedProperties);
112	}
113
114	let mut props = buf.split_to(properties_length);
115
116	let mut timestamp: Option<u64> = None;
117	let mut timescale: Option<u64> = None;
118	let mut prev_type: u64 = 0;
119	let mut first = true;
120
121	while props.has_remaining() {
122		let delta: u64 = VarInt::decode_quic(&mut props)?.into();
123		let abs = if first {
124			first = false;
125			delta
126		} else {
127			prev_type.checked_add(delta).ok_or(Error::MalformedProperties)?
128		};
129		prev_type = abs;
130
131		if abs % 2 == 0 {
132			let value: u64 = VarInt::decode_quic(&mut props)?.into();
133			match abs {
134				PROP_TIMESTAMP | PROP_TIMESTAMP_DRAFT03 => timestamp = Some(value),
135				PROP_TIMESCALE => {
136					if value == 0 {
137						return Err(Error::MalformedProperties);
138					}
139					timescale = Some(value);
140				}
141				_ => {}
142			}
143		} else {
144			let len: u64 = VarInt::decode_quic(&mut props)?.into();
145			let len: usize = len.try_into().map_err(|_| Error::MalformedProperties)?;
146			if len > props.remaining() {
147				return Err(Error::MalformedProperties);
148			}
149			// We don't care about any odd-typed property today; PROP_VIDEO_CONFIG
150			// (0x0d) and any unknown ID are skipped.
151			props.advance(len);
152		}
153	}
154
155	let timestamp = timestamp.ok_or(Error::MissingTimestamp)?;
156
157	Ok(Frame {
158		timestamp,
159		timescale,
160		payload: buf,
161	})
162}
163
164/// Encode a LOC frame with a single 0x10 Timestamp property.
165///
166/// Per-frame 0x08 timescale is never emitted. The encoder relies on the
167/// catalog timescale to interpret `timestamp`.
168pub fn encode(timestamp: u64, payload: &[u8]) -> Result<Bytes, Error> {
169	let mut props = BytesMut::with_capacity(16);
170	VarInt::try_from(PROP_TIMESTAMP)?.encode_quic(&mut props)?;
171	VarInt::try_from(timestamp)?.encode_quic(&mut props)?;
172
173	let mut out = BytesMut::with_capacity(props.len() + payload.len() + 8);
174	VarInt::try_from(props.len() as u64)?.encode_quic(&mut out)?;
175	out.extend_from_slice(&props);
176	out.extend_from_slice(payload);
177
178	Ok(out.freeze())
179}
180
181#[cfg(test)]
182mod tests {
183	use super::*;
184
185	/// Test helper: write a u64 as a QUIC varint into `buf`.
186	fn write_varint(buf: &mut BytesMut, value: u64) {
187		VarInt::try_from(value).unwrap().encode_quic(buf).unwrap();
188	}
189
190	#[test]
191	fn roundtrip() {
192		let payload = Bytes::from_static(b"hello world");
193		let encoded = encode(12345, &payload).unwrap();
194
195		let frame = decode(encoded).unwrap();
196		assert_eq!(frame.timestamp, 12345);
197		assert_eq!(frame.timescale, None);
198		assert_eq!(frame.payload, payload);
199	}
200
201	#[test]
202	fn decode_per_frame_timescale() {
203		// Manually craft: properties = [delta=0x08 timescale=48000, delta=0x08 (abs=0x10) timestamp=96000]
204		let mut props = BytesMut::new();
205		write_varint(&mut props, PROP_TIMESCALE);
206		write_varint(&mut props, 48_000);
207		write_varint(&mut props, PROP_TIMESTAMP - PROP_TIMESCALE); // delta = 8
208		write_varint(&mut props, 96_000);
209
210		let mut frame = BytesMut::new();
211		write_varint(&mut frame, props.len() as u64);
212		frame.extend_from_slice(&props);
213		frame.extend_from_slice(b"payload");
214
215		let decoded = decode(frame.freeze()).unwrap();
216		assert_eq!(decoded.timestamp, 96_000);
217		assert_eq!(decoded.timescale, Some(48_000));
218		assert_eq!(decoded.payload, Bytes::from_static(b"payload"));
219	}
220
221	#[test]
222	fn decode_skips_video_config() {
223		// properties = [delta=0x0d (video config) bytes=[1,2,3], delta=0x03 (abs=0x10) timestamp=10]
224		let mut props = BytesMut::new();
225		write_varint(&mut props, 0x0d);
226		write_varint(&mut props, 3); // length
227		props.extend_from_slice(&[0x01, 0x02, 0x03]);
228		write_varint(&mut props, PROP_TIMESTAMP - 0x0d); // delta = 3
229		write_varint(&mut props, 10);
230
231		let mut frame = BytesMut::new();
232		write_varint(&mut frame, props.len() as u64);
233		frame.extend_from_slice(&props);
234		frame.extend_from_slice(b"data");
235
236		let decoded = decode(frame.freeze()).unwrap();
237		assert_eq!(decoded.timestamp, 10);
238		assert_eq!(decoded.timescale, None);
239		assert_eq!(decoded.payload, Bytes::from_static(b"data"));
240	}
241
242	#[test]
243	fn decode_missing_timestamp_errors() {
244		// properties = [delta=0x08 timescale=1000], no timestamp
245		let mut props = BytesMut::new();
246		write_varint(&mut props, PROP_TIMESCALE);
247		write_varint(&mut props, 1000);
248
249		let mut frame = BytesMut::new();
250		write_varint(&mut frame, props.len() as u64);
251		frame.extend_from_slice(&props);
252		frame.extend_from_slice(b"x");
253
254		assert!(matches!(decode(frame.freeze()), Err(Error::MissingTimestamp)));
255	}
256
257	#[test]
258	fn decode_empty_properties_errors() {
259		let mut frame = BytesMut::new();
260		write_varint(&mut frame, 0);
261		frame.extend_from_slice(b"payload");
262
263		assert!(matches!(decode(frame.freeze()), Err(Error::MissingTimestamp)));
264	}
265
266	#[test]
267	fn decode_rejects_zero_timescale() {
268		// Per-frame 0x08 timescale of 0 is invalid (would divide by zero).
269		let mut props = BytesMut::new();
270		write_varint(&mut props, PROP_TIMESCALE);
271		write_varint(&mut props, 0);
272		write_varint(&mut props, PROP_TIMESTAMP - PROP_TIMESCALE);
273		write_varint(&mut props, 10);
274
275		let mut frame = BytesMut::new();
276		write_varint(&mut frame, props.len() as u64);
277		frame.extend_from_slice(&props);
278		frame.extend_from_slice(b"x");
279
280		assert!(matches!(decode(frame.freeze()), Err(Error::MalformedProperties)));
281	}
282
283	#[test]
284	fn decode_overflowing_properties_length_errors() {
285		let mut frame = BytesMut::new();
286		write_varint(&mut frame, 100); // claims 100 bytes of properties
287		frame.extend_from_slice(&[0x10]); // only 1 byte follows
288
289		assert!(matches!(decode(frame.freeze()), Err(Error::MalformedProperties)));
290	}
291
292	#[test]
293	fn decode_accepts_draft03_timestamp() {
294		// A draft-03 peer wrote the Timestamp at 0x06 instead of 0x10.
295		let mut props = BytesMut::new();
296		write_varint(&mut props, PROP_TIMESTAMP_DRAFT03);
297		write_varint(&mut props, 4242);
298
299		let mut frame = BytesMut::new();
300		write_varint(&mut frame, props.len() as u64);
301		frame.extend_from_slice(&props);
302		frame.extend_from_slice(b"payload");
303
304		let decoded = decode(frame.freeze()).unwrap();
305		assert_eq!(decoded.timestamp, 4242);
306		assert_eq!(decoded.payload, Bytes::from_static(b"payload"));
307	}
308
309	#[test]
310	fn encode_uses_draft04_timestamp() {
311		// The encoder emits 0x10, never the draft-03 id: the compat is decode-only.
312		let encoded = encode(7, b"x").unwrap();
313		let props_len = encoded[0] as usize;
314		assert_eq!(encoded[1..=props_len][0], PROP_TIMESTAMP as u8);
315	}
316}