moq-mux 0.10.13

Media muxers and demuxers for MoQ
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
use std::task::{Poll, ready};

use moq_net::PathOwned;

use super::{Catalog, CatalogExt};
use crate::Result;

/// A catalog consumer, used to receive catalog updates and discover tracks.
///
/// This wraps a [`moq_json::snapshot::Consumer`], reconstructing the JSON catalog from the latest
/// group's snapshot (plus any future deltas) to discover available audio and video tracks.
///
/// Generic over the application extension `E` (defaulting to `()`); yields a
/// [`Catalog<E>`](super::Catalog).
///
/// A rendition's `broadcast` field is relative to the path of the broadcast serving the
/// catalog, taken from the track's [`broadcast::Info::path`](moq_net::broadcast::Info::path).
/// A catalog that misuses that reference is rejected outright (see [`Self::poll_next`]).
pub struct Consumer<E: CatalogExt = ()> {
	inner: moq_json::snapshot::Consumer<Catalog<E>>,
	/// Path of the broadcast serving this catalog, which every rendition's `broadcast`
	/// reference resolves against.
	base: PathOwned,
}

impl<E: CatalogExt> Consumer<E> {
	/// Create a new catalog consumer from a MoQ track subscriber (uncompressed `catalog.json`).
	pub fn new(track: moq_net::track::Subscriber) -> Self {
		Self {
			base: track.broadcast().path.clone(),
			inner: moq_json::snapshot::Consumer::new(track, moq_json::snapshot::consumer::Config::default()),
		}
	}

	/// Create a consumer for the DEFLATE-compressed catalog track (`catalog.json.z`).
	///
	/// The track must be the compressed one (see [`hang::Catalog::COMPRESSED_NAME`]).
	pub fn compressed(track: moq_net::track::Subscriber) -> Self {
		let mut config = moq_json::snapshot::consumer::Config::default();
		config.compression = moq_json::Compression::Deflate;
		Self {
			base: track.broadcast().path.clone(),
			inner: moq_json::snapshot::Consumer::new(track, config),
		}
	}

	/// Poll for the next catalog update.
	///
	/// Fails with [`Error::EscapingBroadcast`](crate::Error::EscapingBroadcast) if any rendition's
	/// `broadcast` reference walks above the root. Such a reference names no broadcast, and the
	/// root is the consumer's authorized subtree, so it is an attempt to reference content the
	/// consumer cannot name. The whole catalog is rejected rather than the one rendition: a
	/// publisher that emits one has a bug (or is hostile), and silently serving the rest hides
	/// that from the operator while the missing rendition surfaces much later as a track that
	/// never fills.
	pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<Catalog<E>>>> {
		let catalog = ready!(self.inner.poll_next(waiter))?;
		if let Some(catalog) = catalog.as_ref() {
			catalog.check_renditions()?;
			check_resolvable(&self.base, catalog)?;
		}
		Poll::Ready(Ok(catalog))
	}

	/// Get the next catalog update.
	///
	/// This method waits for the next catalog publication and returns the
	/// catalog data. If there are no more updates, `None` is returned.
	pub async fn next(&mut self) -> Result<Option<Catalog<E>>>
	where
		Catalog<E>: Unpin,
	{
		kio::wait(|waiter| self.poll_next(waiter)).await
	}
}

impl<E: CatalogExt> From<moq_net::track::Subscriber> for Consumer<E> {
	fn from(inner: moq_net::track::Subscriber) -> Self {
		Self::new(inner)
	}
}

/// Reject the catalog if any track's `broadcast` reference walks above the root.
///
/// Every section carrying a `broadcast` reference must be chained here; a section left out
/// silently exempts its tracks from the containment check.
fn check_resolvable<E: CatalogExt>(base: &PathOwned, catalog: &Catalog<E>) -> Result<()> {
	let video = catalog.video.renditions.iter();
	let audio = catalog.audio.renditions.iter();
	let text = catalog.text.renditions.iter();
	let json = catalog.json.tracks.iter();
	let binary = catalog.binary.tracks.iter();

	for (rendition, rel) in video
		.map(|(name, config)| (name, config.broadcast.as_ref()))
		.chain(audio.map(|(name, config)| (name, config.broadcast.as_ref())))
		.chain(text.map(|(name, config)| (name, config.broadcast.as_ref())))
		.chain(json.map(|(name, config)| (name, config.broadcast.as_ref())))
		.chain(binary.map(|(name, config)| (name, config.broadcast.as_ref())))
	{
		let Some(rel) = rel.filter(|rel| !rel.is_empty()) else {
			continue;
		};

		if base.try_resolve(rel).is_none() {
			tracing::error!(rendition, %rel, catalog = %base, "rejecting catalog: broadcast reference escapes the root");
			return Err(crate::Error::EscapingBroadcast(rel.to_string()));
		}
	}

	Ok(())
}

#[cfg(test)]
mod test {
	use std::task::Poll;

	use super::*;

	/// Mint a standalone track for tests via a throwaway broadcast, since tracks are
	/// born from their broadcast (no public `track::Producer::new`).
	fn track_producer(
		name: impl Into<std::sync::Arc<str>>,
		info: impl Into<Option<moq_net::track::Info>>,
	) -> moq_net::track::Producer {
		moq_net::broadcast::Info::new()
			.produce()
			.create_track(name, info)
			.unwrap()
	}

	// Build a base catalog distinguished by an audio rendition named `name`, plus its JSON payload.
	fn catalog_payload(name: &str) -> (Catalog, String) {
		let mut catalog = Catalog::default();
		catalog.audio.renditions.insert(
			name.to_string(),
			hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2),
		);
		let payload = serde_json::to_string(&catalog).expect("catalog should serialize");
		(catalog, payload)
	}

	fn expect_catalog(result: Poll<Result<Option<Catalog>>>) -> Catalog {
		match result {
			Poll::Ready(Ok(Some(decoded))) => decoded,
			other => panic!("expected catalog payload, got {other:?}"),
		}
	}

	fn opus() -> hang::catalog::AudioConfig {
		hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2)
	}

	fn referencing(rel: &str) -> hang::catalog::AudioConfig {
		let mut config = opus();
		config.broadcast = Some(moq_net::path::Relative::new(rel).into_owned());
		config
	}

	/// Publish `catalog` as one snapshot on a broadcast at `a/pub` (two segments, so a
	/// third `..` walks above the root) and return what the stream yields. The broadcast is
	/// minted through an origin because that is what stamps
	/// [`broadcast::Info::path`](moq_net::broadcast::Info::path), the base the consumer
	/// resolves against.
	fn publish_catalog(published: Catalog<()>) -> Result<Option<Catalog>> {
		let origin = crate::source::produce_origin();
		let broadcast = origin.create_broadcast("a/pub").expect("broadcast should create");
		let track = broadcast
			.create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info())
			.expect("catalog track should create");
		let mut consumer = Consumer::new(track.subscribe(None));
		let mut group = track.append_group().expect("catalog group should append");

		let payload = serde_json::to_string(&published).expect("catalog should serialize");
		group
			.write_frame(moq_net::Timestamp::ZERO, payload)
			.expect("catalog frame should write");
		group.finish().expect("catalog group should finish");

		match consumer.poll_next(&kio::Waiter::noop()) {
			Poll::Ready(result) => result,
			Poll::Pending => panic!("a finished catalog group should be ready"),
		}
	}

	/// [`publish_catalog`] over audio renditions alone.
	fn publish(renditions: Vec<(&str, hang::catalog::AudioConfig)>) -> Result<Option<Catalog>> {
		let mut published = Catalog::<()>::default();
		for (name, config) in renditions {
			published.audio.renditions.insert(name.to_string(), config);
		}
		publish_catalog(published)
	}

	/// [`publish_catalog`] against a *standalone* broadcast (no origin, so its own path is
	/// empty) that a dynamic handler serves at `a/pub`, read back through the requesting
	/// origin cursor. That is the shape an FFI app takes when it answers an origin request
	/// with a broadcast it built itself.
	async fn publish_catalog_served(published: Catalog<()>) -> Result<Option<Catalog>> {
		let broadcast = moq_net::broadcast::Info::new().produce();
		let track = broadcast
			.create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info())
			.expect("catalog track should create");

		let origin = crate::source::produce_origin();
		let dynamic = origin.dynamic("", Default::default()).unwrap();
		let requesting = origin.consume().request_broadcast("a/pub");
		let served = broadcast.consume();
		// The handler hands its route back: dropping it would retract the route and end
		// the broadcast it just served.
		let handler = tokio::spawn(async move {
			dynamic
				.requested_broadcast()
				.await
				.expect("handler should be asked for the path")
				.accept(&served);
			dynamic
		});

		let resolved = requesting.await;
		let _dynamic = handler.await.expect("handler task");
		let subscriber = resolved
			.expect("the handler served it")
			.track(hang::Catalog::DEFAULT_NAME)
			.expect("catalog track should resolve")
			.subscribe(None)
			.await
			.expect("catalog track should subscribe");
		let mut consumer = Consumer::new(subscriber);

		let mut group = track.append_group().expect("catalog group should append");
		let payload = serde_json::to_string(&published).expect("catalog should serialize");
		group
			.write_frame(moq_net::Timestamp::ZERO, payload)
			.expect("catalog frame should write");
		group.finish().expect("catalog group should finish");

		consumer.next().await
	}

	/// [`publish_catalog_served`] over one audio rendition carrying `reference`.
	async fn publish_served(reference: &str) -> Result<Option<Catalog>> {
		let mut published = Catalog::<()>::default();
		published
			.audio
			.renditions
			.insert("sibling".to_string(), referencing(reference));
		publish_catalog_served(published).await
	}

	#[test]
	fn refuses_oversized_catalog_before_reconciling_renditions() {
		let mut published = Catalog::<()>::default();
		for i in 0..=hang::catalog::MAX_RENDITIONS {
			published.audio.renditions.insert(format!("audio{i}"), opus());
		}
		assert!(matches!(
			publish_catalog(published),
			Err(crate::Error::Hang(hang::Error::TooManyRenditions {
				count: 65,
				max: 64
			}))
		));
	}

	/// A reference that stops at or below the root names a broadcast, so the catalog stands.
	/// `a/pub` resolves against `a`, so `..` is what lands on the root itself.
	#[tokio::test]
	async fn accepts_references_within_the_root() {
		let catalog = publish(vec![
			("here", opus()),
			("sibling", referencing("./source")),
			("root", referencing("..")),
		])
		.expect("catalog should be accepted")
		.expect("catalog should decode");

		let mut names: Vec<&str> = catalog.audio.renditions.keys().map(String::as_str).collect();
		names.sort_unstable();
		assert_eq!(names, ["here", "root", "sibling"]);
	}

	/// A rendition whose `broadcast` reference walks above the root names no broadcast, and
	/// the root is the consumer's authorized subtree. The whole catalog is rejected rather
	/// than the one rendition, so a publisher bug is loud instead of surfacing later as a
	/// track that never fills.
	#[tokio::test]
	async fn rejects_a_catalog_whose_broadcast_escapes_the_root() {
		for reference in ["../../../elsewhere", "../../..", "../../../.."] {
			// The escaping rendition is published alongside perfectly good ones, which do
			// not rescue the catalog.
			let result = publish(vec![
				("here", opus()),
				("sibling", referencing("../source")),
				("escaping", referencing(reference)),
			]);

			match result {
				Err(crate::Error::EscapingBroadcast(reported)) => assert_eq!(reported, reference),
				Err(err) => panic!("{reference} failed with the wrong error: {err:?}"),
				Ok(_) => panic!("{reference} should reject the catalog"),
			}
		}
	}

	/// A broadcast is named by where its reader found it, not by where its producer created
	/// it: a standalone broadcast served on demand at `a/pub` resolves `../source` against
	/// `a/pub`. Naming it by the producer's own (empty) path would make its own root the
	/// bound, rejecting a reference the requester can perfectly well reach.
	#[tokio::test]
	async fn accepts_references_within_the_served_path() {
		let catalog = publish_served("../source")
			.await
			.expect("catalog should be accepted")
			.expect("catalog should decode");

		let rendition = catalog
			.audio
			.renditions
			.get("sibling")
			.expect("rendition should survive");
		assert_eq!(rendition.broadcast.as_ref().unwrap().as_str(), "../source");
	}

	/// The bound moves with the served path rather than disappearing: a reference that
	/// walks above `a/pub` still names nothing and still rejects the catalog.
	#[tokio::test]
	async fn rejects_references_escaping_the_served_path() {
		match publish_served("../../../elsewhere").await {
			Err(crate::Error::EscapingBroadcast(reported)) => assert_eq!(reported, "../../../elsewhere"),
			Err(err) => panic!("wrong error: {err:?}"),
			Ok(_) => panic!("an escaping reference should reject the catalog"),
		}
	}

	/// The containment check covers every section carrying renditions, not just audio and
	/// video: an escaping text (caption) reference rejects the catalog the same way.
	#[tokio::test]
	async fn rejects_a_catalog_whose_text_broadcast_escapes_the_root() {
		let mut published = Catalog::<()>::default();
		published.audio.renditions.insert("here".to_string(), opus());

		let mut text = hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt);
		text.broadcast = Some(moq_net::path::Relative::new("../../../elsewhere").into_owned());
		published.text.renditions.insert("captions".to_string(), text);

		match publish_catalog(published) {
			Err(crate::Error::EscapingBroadcast(reported)) => assert_eq!(reported, "../../../elsewhere"),
			Err(err) => panic!("wrong error: {err:?}"),
			Ok(_) => panic!("an escaping text reference should reject the catalog"),
		}
	}

	/// Data tracks carry a `broadcast` reference like any rendition, so the containment check
	/// covers them too. Without this the catalog is accepted and the escape surfaces much later,
	/// when the entry is subscribed.
	#[tokio::test]
	async fn rejects_a_catalog_whose_data_broadcast_escapes_the_root() {
		for section in ["json", "binary"] {
			let mut published = Catalog::<()>::default();
			published.audio.renditions.insert("here".to_string(), opus());

			let escaping = moq_net::path::Relative::new("../../../elsewhere").into_owned();
			match section {
				"json" => {
					let mut config = hang::catalog::JsonConfig::new(hang::catalog::Mode::Stream);
					config.broadcast = Some(escaping);
					published.json.tracks.insert("chat".to_string(), config);
				}
				_ => {
					let mut config = hang::catalog::BinaryConfig::new(hang::catalog::Mode::Stream);
					config.broadcast = Some(escaping);
					published.binary.tracks.insert("blobs".to_string(), config);
				}
			}

			match publish_catalog(published) {
				Err(crate::Error::EscapingBroadcast(reported)) => assert_eq!(reported, "../../../elsewhere"),
				Err(err) => panic!("wrong error for {section}: {err:?}"),
				Ok(_) => panic!("an escaping {section} reference should reject the catalog"),
			}
		}
	}

	#[test]
	fn waits_for_pending_catalog_group_payload() {
		let track = track_producer(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info());
		let mut consumer = Consumer::new(track.subscribe(None));
		let mut group = track.append_group().expect("catalog group should append");

		let waiter = kio::Waiter::noop();
		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));

		let (catalog, payload) = catalog_payload("pending");
		group
			.write_frame(moq_net::Timestamp::ZERO, payload)
			.expect("catalog frame should write");
		group.finish().expect("catalog group should finish");

		assert_eq!(expect_catalog(consumer.poll_next(&waiter)), catalog);
	}

	#[test]
	fn waits_for_pending_catalog_group_payload_after_track_finish() {
		let track = track_producer(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info());
		let mut consumer = Consumer::new(track.subscribe(None));
		let mut group = track.append_group().expect("catalog group should append");

		track.finish().expect("catalog track should finish");

		let waiter = kio::Waiter::noop();
		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));

		let (catalog, payload) = catalog_payload("finished");
		group
			.write_frame(moq_net::Timestamp::ZERO, payload)
			.expect("catalog frame should write");
		group.finish().expect("catalog group should finish");

		assert_eq!(expect_catalog(consumer.poll_next(&waiter)), catalog);
	}

	#[test]
	fn returns_latest_complete_catalog_group() {
		let track = track_producer(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info());
		let mut consumer = Consumer::new(track.subscribe(None));
		let waiter = kio::Waiter::noop();

		let (_old, old_payload) = catalog_payload("old");
		let (latest, latest_payload) = catalog_payload("latest");

		let mut old_group = track.append_group().expect("old catalog group should append");
		old_group
			.write_frame(moq_net::Timestamp::ZERO, old_payload)
			.expect("old catalog frame should write");
		old_group.finish().expect("old catalog group should finish");

		let mut latest_group = track.append_group().expect("latest catalog group should append");
		latest_group
			.write_frame(moq_net::Timestamp::ZERO, latest_payload)
			.expect("latest catalog frame should write");
		latest_group.finish().expect("latest catalog group should finish");
		track.finish().expect("catalog track should finish");

		assert_eq!(expect_catalog(consumer.poll_next(&waiter)), latest);
		assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))));
	}

	#[test]
	fn waits_for_newer_pending_group_instead_of_returning_older_ready_group() {
		let track = track_producer(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info());
		let mut consumer = Consumer::new(track.subscribe(None));
		let waiter = kio::Waiter::noop();

		let (_old, old_payload) = catalog_payload("old");
		let (latest, latest_payload) = catalog_payload("latest");

		let mut old_group = track.append_group().expect("old catalog group should append");
		old_group
			.write_frame(moq_net::Timestamp::ZERO, old_payload)
			.expect("old catalog frame should write");
		old_group.finish().expect("old catalog group should finish");

		let mut latest_group = track.append_group().expect("latest catalog group should append");

		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));

		latest_group
			.write_frame(moq_net::Timestamp::ZERO, latest_payload)
			.expect("latest catalog frame should write");
		latest_group.finish().expect("latest catalog group should finish");

		assert_eq!(expect_catalog(consumer.poll_next(&waiter)), latest);
	}

	#[test]
	fn retained_pending_group_is_superseded_by_newer_group() {
		let track = track_producer(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info());
		let mut consumer = Consumer::new(track.subscribe(None));
		let waiter = kio::Waiter::noop();

		let (_old, old_payload) = catalog_payload("old");
		let (latest, latest_payload) = catalog_payload("latest");

		let mut old_group = track.append_group().expect("old catalog group should append");

		assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));

		let mut latest_group = track.append_group().expect("latest catalog group should append");
		latest_group
			.write_frame(moq_net::Timestamp::ZERO, latest_payload)
			.expect("latest catalog frame should write");
		latest_group.finish().expect("latest catalog group should finish");
		track.finish().expect("catalog track should finish");

		assert_eq!(expect_catalog(consumer.poll_next(&waiter)), latest);

		old_group
			.write_frame(moq_net::Timestamp::ZERO, old_payload)
			.expect("old catalog frame should write");
		old_group.finish().expect("old catalog group should finish");

		assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))));
	}

	#[test]
	fn returns_none_when_empty_track_finishes() {
		let track = track_producer(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info());
		let mut consumer: Consumer = Consumer::new(track.subscribe(None));
		let waiter = kio::Waiter::noop();

		track.finish().expect("catalog track should finish");

		assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))));
	}
}