Skip to main content

moq_native/
client.rs

1use crate::{Backoff, Error, QuicBackend, Reconnect};
2#[cfg(feature = "websocket")]
3use std::future::Future;
4use std::net;
5use url::Url;
6
7/// Configuration for the MoQ client.
8#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
9#[serde(deny_unknown_fields, default)]
10#[non_exhaustive]
11pub struct ClientConfig {
12	/// The URL to dial.
13	///
14	/// Supports WebTransport (`https`/`http`), WebSocket (`ws`/`wss`), raw QUIC
15	/// (`moqt`/`moql`), qmux over `tcp`/`unix`, and `iroh`. The URL path is the
16	/// request/auth path (e.g. `/anon` for a public relay) and `?jwt=` supplies a
17	/// token. `http://` first fetches `/certificate.sha256` for the (insecure)
18	/// self-signed fingerprint; `https://` connects directly.
19	#[serde(skip_serializing_if = "Option::is_none")]
20	#[arg(id = "client-connect", long = "client-connect", env = "MOQ_CLIENT_CONNECT")]
21	pub connect: Option<Url>,
22
23	/// Listen for UDP packets on the given address.
24	#[arg(
25		id = "client-bind",
26		long = "client-bind",
27		default_value = "[::]:0",
28		env = "MOQ_CLIENT_BIND"
29	)]
30	pub bind: net::SocketAddr,
31
32	/// The QUIC backend to use.
33	/// Auto-detected from compiled features if not specified.
34	#[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
35	pub backend: Option<QuicBackend>,
36
37	/// QUIC transport tuning (`--client-quic-*`): stream limits, GSO, timeouts.
38	#[command(flatten)]
39	#[serde(default)]
40	pub quic: crate::quic::Client,
41
42	/// Restrict the client to specific MoQ protocol version(s).
43	///
44	/// By default, the client offers all supported versions and lets the server choose.
45	/// Use this to force a specific version, e.g. `--client-version moq-lite-02`.
46	/// Can be specified multiple times to offer a subset of versions.
47	///
48	/// Valid values: moq-lite-01, moq-lite-02, moq-lite-03, moq-transport-14, moq-transport-15, moq-transport-16, moq-transport-17
49	#[serde(default, skip_serializing_if = "Vec::is_empty")]
50	#[arg(id = "client-version", long = "client-version", env = "MOQ_CLIENT_VERSION")]
51	pub version: Vec<moq_net::Version>,
52
53	/// TLS trust and client-certificate settings (`--client-tls-*`).
54	#[command(flatten)]
55	#[serde(default)]
56	pub tls: crate::tls::Client,
57
58	/// Retry pacing for [`Client::reconnect`] (`--client-backoff-*`).
59	#[command(flatten)]
60	#[serde(default)]
61	pub backoff: Backoff,
62
63	/// WebSocket fallback settings (`--client-websocket-*`), used when QUIC is
64	/// blocked.
65	#[cfg(feature = "websocket")]
66	#[command(flatten)]
67	#[serde(default)]
68	pub websocket: crate::websocket::Client,
69}
70
71impl ClientConfig {
72	/// Build the [`Client`] this config describes.
73	pub fn init(self) -> crate::Result<Client> {
74		Client::new(self)
75	}
76
77	/// Returns the configured versions, defaulting to all if none specified.
78	pub fn versions(&self) -> moq_net::Versions {
79		if self.version.is_empty() {
80			moq_net::Versions::all()
81		} else {
82			moq_net::Versions::from(self.version.clone())
83		}
84	}
85}
86
87impl Default for ClientConfig {
88	fn default() -> Self {
89		Self {
90			connect: None,
91			bind: "[::]:0".parse().unwrap(),
92			backend: None,
93			quic: crate::quic::Client::default(),
94			version: Vec::new(),
95			tls: crate::tls::Client::default(),
96			backoff: Backoff::default(),
97			#[cfg(feature = "websocket")]
98			websocket: crate::websocket::Client::default(),
99		}
100	}
101}
102
103/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
104///
105/// Create via [`ClientConfig::init`] or [`Client::new`].
106#[derive(Clone)]
107pub struct Client {
108	moq: moq_net::Client,
109	/// The single resolved set of protocol versions, used to advertise moq ALPNs across
110	/// every transport (passed into the QUIC backends' `connect` and used directly for
111	/// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list
112	/// can't diverge between transports.
113	versions: moq_net::Versions,
114	/// The URL from [`ClientConfig::connect`], dialed by [`Client::publish`] / [`Client::consume`].
115	connect: Option<Url>,
116	backoff: Backoff,
117	#[cfg(feature = "websocket")]
118	websocket: crate::websocket::Client,
119	tls: rustls::ClientConfig,
120	#[cfg(feature = "noq")]
121	noq: Option<crate::noq::NoqClient>,
122	#[cfg(feature = "quinn")]
123	quinn: Option<crate::quinn::QuinnClient>,
124	#[cfg(feature = "quiche")]
125	quiche: Option<crate::quiche::QuicheClient>,
126	#[cfg(feature = "iroh")]
127	iroh: Option<crate::iroh::Endpoint>,
128	#[cfg(feature = "iroh")]
129	iroh_addrs: Vec<std::net::SocketAddr>,
130}
131
132impl Client {
133	/// Build a client from its config.
134	///
135	/// Errors if no transport feature is compiled in.
136	#[cfg(not(any(
137		feature = "noq",
138		feature = "quinn",
139		feature = "quiche",
140		feature = "websocket",
141		feature = "tcp",
142		feature = "uds"
143	)))]
144	pub fn new(_config: ClientConfig) -> crate::Result<Self> {
145		Err(Error::NoBackend(
146			"no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
147		))
148	}
149
150	/// Build a client from its config, binding the QUIC socket up front.
151	#[cfg(any(
152		feature = "noq",
153		feature = "quinn",
154		feature = "quiche",
155		feature = "websocket",
156		feature = "tcp",
157		feature = "uds"
158	))]
159	pub fn new(config: ClientConfig) -> crate::Result<Self> {
160		#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
161		let backend = config.backend.clone().unwrap_or_else(crate::default_quic_backend);
162
163		config.quic.validate()?;
164
165		let tls = config.tls.build()?;
166
167		#[cfg(feature = "noq")]
168		#[allow(unreachable_patterns)]
169		let noq = match backend {
170			QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
171			_ => None,
172		};
173
174		#[cfg(feature = "quinn")]
175		#[allow(unreachable_patterns)]
176		let quinn = match backend {
177			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
178			_ => None,
179		};
180
181		#[cfg(feature = "quiche")]
182		let quiche = match backend {
183			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
184			_ => None,
185		};
186
187		let versions = config.versions();
188		Ok(Self {
189			moq: moq_net::Client::new().with_versions(versions.clone()),
190			versions,
191			connect: config.connect,
192			backoff: config.backoff,
193			#[cfg(feature = "websocket")]
194			websocket: config.websocket,
195			tls,
196			#[cfg(feature = "noq")]
197			noq,
198			#[cfg(feature = "quinn")]
199			quinn,
200			#[cfg(feature = "quiche")]
201			quiche,
202			#[cfg(feature = "iroh")]
203			iroh: None,
204			#[cfg(feature = "iroh")]
205			iroh_addrs: Vec::new(),
206		})
207	}
208
209	/// Dial `iroh://` URLs through the given Iroh endpoint.
210	///
211	/// Required before [`connect`](Self::connect) can serve an `iroh://` URL;
212	/// without it those dials fail with [`crate::Error::IrohDisabled`].
213	#[cfg(feature = "iroh")]
214	pub fn with_iroh(mut self, iroh: crate::iroh::Endpoint) -> Self {
215		self.iroh = Some(iroh);
216		self
217	}
218
219	/// Set direct IP addresses for connecting to iroh peers.
220	///
221	/// This is useful when the peer's IP addresses are known ahead of time,
222	/// bypassing the need for peer discovery (e.g. in tests or local networks).
223	#[cfg(feature = "iroh")]
224	pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
225		self.iroh_addrs = addrs;
226		self
227	}
228
229	/// Publish the given origin to every session this client opens.
230	pub fn with_publisher(mut self, publish: impl moq_net::Consume<moq_net::origin::Consumer>) -> Self {
231		self.moq = self.moq.with_publisher(publish);
232		self
233	}
234
235	/// Subscribe to the peer's broadcasts, ingesting them into the given origin.
236	pub fn with_subscriber(mut self, subscribe: moq_net::origin::Producer) -> Self {
237		self.moq = self.moq.with_subscriber(subscribe);
238		self
239	}
240
241	/// Attach a per-connection [`moq_net::stats::Session`] context to all sessions
242	/// opened by this client.
243	pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self {
244		self.moq = self.moq.with_stats(stats);
245		self
246	}
247
248	/// Price the links this client dials; see [`moq_net::Client::with_cost`].
249	pub fn with_cost(mut self, cost: u64) -> Self {
250		self.moq = self.moq.with_cost(cost);
251		self
252	}
253
254	/// Assign an origin (hop) id to the peers this client dials, used whenever a
255	/// peer doesn't declare one itself; see [`moq_net::Client::with_peer_origin`].
256	pub fn with_peer_origin(mut self, origin: moq_net::Origin) -> Self {
257		self.moq = self.moq.with_peer_origin(origin);
258		self
259	}
260
261	/// Start a background reconnect loop that connects to the given URL,
262	/// waits for the session to close, then reconnects with exponential backoff.
263	///
264	/// Returns a [`Reconnect`] handle; drop the last handle to stop the loop.
265	pub fn reconnect(&self, url: Url) -> Reconnect {
266		Reconnect::new(self.clone(), url, self.backoff.clone())
267	}
268
269	/// Dial the configured [`ClientConfig::connect`] URL, publishing `origin` to it
270	/// and reconnecting with backoff until the returned handle is dropped.
271	///
272	/// Returns `None` when no `--client-connect` URL was configured, so a caller
273	/// that may run server-only doesn't have to branch on the URL itself.
274	pub fn publish(self, origin: moq_net::origin::Consumer) -> Option<Reconnect> {
275		let url = self.connect.clone()?;
276		Some(self.with_publisher(origin).reconnect(url))
277	}
278
279	/// Dial the configured [`ClientConfig::connect`] URL, consuming its broadcasts
280	/// into `origin` and reconnecting with backoff until the returned handle is
281	/// dropped.
282	///
283	/// Broadcasts fed by these sessions linger across a session drop for as long
284	/// as the reconnect loop keeps retrying ([`Backoff::linger`]): a relay restart
285	/// is a bounded gap the reconnect splices over, not a teardown. When the loop
286	/// gives up, its error surfaces (via [`Reconnect::closed`]) just before the
287	/// broadcasts abort.
288	///
289	/// Returns `None` when no `--client-connect` URL was configured.
290	pub fn consume(self, origin: moq_net::origin::Producer) -> Option<Reconnect> {
291		let url = self.connect.clone()?;
292		let origin = origin.with_linger(self.backoff.linger());
293		Some(self.with_subscriber(origin).reconnect(url))
294	}
295
296	/// Dial the given URL and complete the MoQ handshake.
297	///
298	/// Errors if no transport feature is compiled in.
299	#[cfg(not(any(
300		feature = "noq",
301		feature = "quinn",
302		feature = "quiche",
303		feature = "iroh",
304		feature = "websocket",
305		feature = "tcp",
306		feature = "uds"
307	)))]
308	pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
309		Err(Error::NoBackend(
310			"no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
311		))
312	}
313
314	/// Dial the given URL and complete the MoQ handshake.
315	///
316	/// The scheme picks the transport, and `https://` races QUIC against the
317	/// WebSocket fallback so a blocked UDP path still connects. The session's
318	/// protocol driver is spawned on the current tokio runtime; the session
319	/// closes once the last returned handle drops.
320	#[cfg(any(
321		feature = "noq",
322		feature = "quinn",
323		feature = "quiche",
324		feature = "iroh",
325		feature = "websocket",
326		feature = "tcp",
327		feature = "uds"
328	))]
329	pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
330		// Each compiled backend adds state to this dispatch future. Keep it off the
331		// caller's stack so all-feature builds remain safe on standard 2 MiB threads.
332		let pair = Box::pin(self.connect_inner(url)).await?;
333		tracing::info!(version = %pair.0.version(), "connected");
334		Ok(crate::spawn_session(pair))
335	}
336
337	/// The moq client builder, advertising `path` in the SETUP when there is one.
338	#[cfg(any(
339		feature = "noq",
340		feature = "quinn",
341		feature = "quiche",
342		feature = "iroh",
343		feature = "websocket",
344		feature = "tcp",
345		feature = "uds"
346	))]
347	fn moq_with_path(&self, path: Option<String>) -> moq_net::Client {
348		match path {
349			Some(path) => self.moq.clone().with_path(path),
350			None => self.moq.clone(),
351		}
352	}
353
354	#[cfg(any(
355		feature = "noq",
356		feature = "quinn",
357		feature = "quiche",
358		feature = "iroh",
359		feature = "websocket",
360		feature = "tcp",
361		feature = "uds"
362	))]
363	async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> {
364		// Transports with no request URI of their own advertise the request target in the
365		// SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where
366		// sending it again is a protocol violation.
367		let moq = self.moq_with_path(setup_path(&url));
368
369		// Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against
370		// QUIC, which can't speak it. Use only on a trusted network.
371		#[cfg(feature = "tcp")]
372		if url.scheme() == "tcp" {
373			let session = crate::tcp::connect(url, &self.versions.alpns()).await?;
374			return Ok(moq.connect(session).await?);
375		}
376
377		// Unix domain socket (qmux, no TLS). Same-host only; the server can
378		// authenticate us by uid/gid via SO_PEERCRED.
379		#[cfg(all(feature = "uds", unix))]
380		if url.scheme() == "unix" {
381			let session = crate::unix::connect(url, &self.versions.alpns()).await?;
382			return Ok(moq.connect(session).await?);
383		}
384
385		// iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw
386		// QUIC, which carries no request URI. The scheme can't tell us which we got, so the
387		// request target waits on the negotiated binding: the SETUP for raw QUIC, the
388		// CONNECT URL for H3 (where a SETUP path would be a protocol violation).
389		#[cfg(feature = "iroh")]
390		if url.scheme() == "iroh" {
391			let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
392			let target = request_target(&url);
393			let (session, binding) = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
394
395			let moq = match binding {
396				crate::iroh::Binding::Raw => self.moq_with_path(target),
397				crate::iroh::Binding::H3 => self.moq.clone(),
398			};
399
400			return Ok(moq.connect(session).await?);
401		}
402
403		#[cfg(feature = "noq")]
404		if let Some(noq) = self.noq.as_ref() {
405			let tls = self.tls.clone();
406			let quic_url = url.clone();
407			let quic_handle = async { noq.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
408
409			#[cfg(feature = "websocket")]
410			{
411				return self.race_moq_connect(&moq, url, quic_handle).await;
412			}
413
414			#[cfg(not(feature = "websocket"))]
415			{
416				let session = quic_handle.await?;
417				return Ok(moq.connect(session).await?);
418			}
419		}
420
421		#[cfg(feature = "quinn")]
422		if let Some(quinn) = self.quinn.as_ref() {
423			let tls = self.tls.clone();
424			let quic_url = url.clone();
425			let quic_handle = async { quinn.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
426
427			#[cfg(feature = "websocket")]
428			{
429				return self.race_moq_connect(&moq, url, quic_handle).await;
430			}
431
432			#[cfg(not(feature = "websocket"))]
433			{
434				let session = quic_handle.await?;
435				return Ok(moq.connect(session).await?);
436			}
437		}
438
439		#[cfg(feature = "quiche")]
440		if let Some(quiche) = self.quiche.as_ref() {
441			let quic_url = url.clone();
442			let quic_handle = async { quiche.connect(quic_url, &self.versions).await.map_err(Error::from) };
443
444			#[cfg(feature = "websocket")]
445			{
446				return self.race_moq_connect(&moq, url, quic_handle).await;
447			}
448
449			#[cfg(not(feature = "websocket"))]
450			{
451				let session = quic_handle.await?;
452				return Ok(moq.connect(session).await?);
453			}
454		}
455
456		#[cfg(feature = "websocket")]
457		{
458			let alpns = self.versions.alpns();
459			let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
460			return Ok(moq.connect(session).await?);
461		}
462
463		#[cfg(not(feature = "websocket"))]
464		return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
465	}
466
467	/// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins.
468	///
469	/// `moq` is the QUIC-side builder, which carries the SETUP path for a raw QUIC dial.
470	/// The WebSocket fallback uses the plain builder: qmux over WebSocket carries the
471	/// path in its request URI, so repeating it in the SETUP is a protocol violation.
472	#[cfg(feature = "websocket")]
473	async fn race_moq_connect<Q, S>(
474		&self,
475		moq: &moq_net::Client,
476		url: Url,
477		quic: Q,
478	) -> crate::Result<(moq_net::Session, moq_net::Driver)>
479	where
480		Q: Future<Output = crate::Result<S>>,
481		S: web_transport_trait::Session,
482	{
483		let alpns = self.versions.alpns();
484		let ws_config = self.websocket.clone();
485		let ws_tls = self.tls.clone();
486		let websocket = async move {
487			crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
488				.await
489				.map(|res| res.map_err(Error::from))
490		};
491
492		match race_transport_connect(quic, websocket).await? {
493			TransportRace::Quic(quic) => Ok(moq.connect(quic).await?),
494			TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
495		}
496	}
497}
498
499/// The request target a URI-less transport advertises in its SETUP: the URL path, plus
500/// `?` and the query when there is one (draft-ietf-moq-transport-19, section 10.3.1.2).
501/// That query is how `?jwt=` reaches a relay.
502///
503/// `None` when the result is empty, which means the same as omitting the parameter: the
504/// server's default path. A peer on published lite-05 rejects an empty value outright.
505#[cfg(any(
506	feature = "noq",
507	feature = "quinn",
508	feature = "quiche",
509	feature = "iroh",
510	feature = "websocket",
511	feature = "tcp",
512	feature = "uds"
513))]
514fn request_target(url: &Url) -> Option<String> {
515	// A trailing `?` parses as an empty query, which is not a query: appending it would
516	// spell one target two ways, and `moqt://host?` would yield a bare "?" rather than
517	// the empty value that means the default path.
518	let target = match url.query().filter(|query| !query.is_empty()) {
519		Some(query) => format!("{}?{}", url.path(), query),
520		None => url.path().to_owned(),
521	};
522
523	(!target.is_empty()).then_some(target)
524}
525
526/// The request target to advertise in the SETUP, chosen by the dial URL's scheme.
527///
528/// `None` for the schemes whose transport carries a request URI of its own
529/// (WebTransport, qmux over WebSocket): they convey the target there, and a SETUP path
530/// on top of it is a protocol violation. `iroh` is `None` here because its binding is
531/// picked by ALPN negotiation rather than by the scheme; that dial reads the negotiated
532/// [`crate::iroh::Binding`] and calls [`request_target`] itself.
533#[cfg(any(
534	feature = "noq",
535	feature = "quinn",
536	feature = "quiche",
537	feature = "iroh",
538	feature = "websocket",
539	feature = "tcp",
540	feature = "uds"
541))]
542fn setup_path(url: &Url) -> Option<String> {
543	match url.scheme() {
544		// A Unix socket URL's path is the socket file, so the request target rides in
545		// the `?path=` query, query string and all. It is one form-encoded value, so a
546		// target that carries its own `?query` percent-encodes it.
547		"unix" => url
548			.query_pairs()
549			.find(|(k, _)| k == "path")
550			.map(|(_, v)| v.into_owned())
551			.filter(|path| !path.is_empty()),
552		// Raw QUIC and qmux over TCP negotiate an ALPN and nothing else, so the whole
553		// request target travels in the SETUP.
554		"moqt" | "moql" | "tcp" => request_target(url),
555		_ => None,
556	}
557}
558
559#[cfg(feature = "websocket")]
560#[derive(Debug, PartialEq, Eq)]
561enum TransportRace<Q, W> {
562	Quic(Q),
563	WebSocket(W),
564}
565
566#[cfg(feature = "websocket")]
567async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
568where
569	Q: Future<Output = crate::Result<QT>>,
570	W: Future<Output = Option<crate::Result<WT>>>,
571{
572	tokio::pin!(quic);
573	tokio::pin!(websocket);
574
575	let mut quic_err = None;
576	let mut websocket_err = None;
577	let mut quic_done = false;
578	let mut websocket_done = false;
579
580	loop {
581		tokio::select! {
582			res = &mut quic, if !quic_done => {
583				match res {
584					Ok(session) => return Ok(TransportRace::Quic(session)),
585					Err(err) if err.is_auth() => return Err(err),
586					Err(err) => {
587						tracing::warn!(%err, "QUIC connection failed");
588						quic_err = Some(err);
589						quic_done = true;
590					}
591				}
592			}
593			res = &mut websocket, if !websocket_done => {
594				match res {
595					Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
596					Some(Err(err)) if err.is_auth() => return Err(err),
597					Some(Err(err)) => {
598						tracing::warn!(%err, "WebSocket connection failed");
599						websocket_err = Some(err);
600						websocket_done = true;
601					}
602					None => {
603						websocket_done = true;
604					}
605				}
606			}
607			else => break,
608		}
609
610		if quic_done && websocket_done {
611			break;
612		}
613	}
614
615	match (quic_err, websocket_err) {
616		(Some(quic), Some(websocket)) => Err(Error::TransportRace {
617			quic: std::sync::Arc::new(quic),
618			websocket: std::sync::Arc::new(websocket),
619		}),
620		(Some(err), None) | (None, Some(err)) => Err(err),
621		(None, None) => Err(Error::ConnectFailed),
622	}
623}
624
625#[cfg(test)]
626mod tests {
627	use super::*;
628	use clap::Parser;
629
630	#[cfg(any(
631		feature = "noq",
632		feature = "quinn",
633		feature = "quiche",
634		feature = "iroh",
635		feature = "websocket",
636		feature = "tcp",
637		feature = "uds"
638	))]
639	#[test]
640	fn setup_path_covers_the_uri_less_transports() {
641		// An empty path and an absent one both mean the server's default, so we send
642		// neither. A peer on published lite-05 rejects an empty value outright.
643		let cases = [
644			("unix:///run/moq.sock?path=/room", Some("/room")),
645			// The whole resource path is one form-encoded value, so a `?query` inside it
646			// arrives percent-encoded and comes back out whole.
647			("unix:///run/moq.sock?path=/room%3Fjwt%3Dabc", Some("/room?jwt=abc")),
648			("unix:///run/moq.sock?path=", None),
649			("unix:///run/moq.sock", None),
650			("tcp://localhost:4443/room", Some("/room")),
651			("tcp://localhost:4443/room?jwt=abc", Some("/room?jwt=abc")),
652			("tcp://localhost:4443", None),
653			// Raw QUIC: the URL is ours alone, so the path and query have to ride the
654			// SETUP or the server never sees them.
655			("moqt://relay.example.com/anon", Some("/anon")),
656			("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
657			("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
658			("moqt://relay.example.com", None),
659			// The fragment is processed by the client and never sent (draft-19 3.1.2).
660			("moqt://relay.example.com/anon?jwt=abc#pos:12", Some("/anon?jwt=abc")),
661			("moqt://relay.example.com/anon#pos:12", Some("/anon")),
662			// A trailing `?` is an empty query, not a query.
663			("moqt://relay.example.com/anon?", Some("/anon")),
664			("moqt://relay.example.com?", None),
665			// The transport's own request URI carries the path, so sending one here
666			// would be a protocol violation.
667			("https://relay.example.com/anon?jwt=abc", None),
668			("http://relay.example.com/anon", None),
669			("wss://relay.example.com/anon?jwt=abc", None),
670			// Decided after the ALPN is negotiated, not here.
671			("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None),
672		];
673
674		for (url, want) in cases {
675			let url = Url::parse(url).unwrap();
676			let got = setup_path(&url);
677			assert_eq!(got.as_deref(), want, "{url}");
678		}
679	}
680
681	/// The iroh dial derives its target here rather than through [`setup_path`], since
682	/// only the negotiated binding says whether to send one.
683	#[cfg(any(
684		feature = "noq",
685		feature = "quinn",
686		feature = "quiche",
687		feature = "iroh",
688		feature = "websocket",
689		feature = "tcp",
690		feature = "uds"
691	))]
692	#[test]
693	fn request_target_joins_the_path_and_query() {
694		const PEER: &str = "k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa";
695
696		let cases = [
697			(format!("iroh://{PEER}/room?jwt=abc"), Some("/room?jwt=abc")),
698			(format!("iroh://{PEER}/room"), Some("/room")),
699			(format!("iroh://{PEER}"), None),
700			(format!("iroh://{PEER}/"), Some("/")),
701		];
702
703		for (url, want) in cases {
704			let url = Url::parse(&url).unwrap();
705			let got = request_target(&url);
706			assert_eq!(got.as_deref(), want, "{url}");
707		}
708	}
709
710	#[test]
711	fn test_toml_disable_verify_survives_update_from() {
712		let toml = r#"
713			tls.disable_verify = true
714		"#;
715
716		let mut config: ClientConfig = toml::from_str(toml).unwrap();
717		assert_eq!(config.tls.disable_verify, Some(true));
718
719		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag).
720		config.update_from(["test"]);
721		assert_eq!(config.tls.disable_verify, Some(true));
722	}
723
724	#[test]
725	fn test_cli_disable_verify_flag() {
726		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
727		assert_eq!(config.tls.disable_verify, Some(true));
728	}
729
730	#[test]
731	fn test_cli_disable_verify_explicit_false() {
732		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
733		assert_eq!(config.tls.disable_verify, Some(false));
734	}
735
736	#[test]
737	fn test_cli_disable_verify_explicit_true() {
738		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
739		assert_eq!(config.tls.disable_verify, Some(true));
740	}
741
742	#[test]
743	fn test_cli_deprecated_tls_flags_fold_into_canonical() {
744		// The bare --tls-* forms are deprecated. They parse into a hidden field and
745		// fold into the canonical values via the effective_* accessors build() uses,
746		// so they keep working without touching the public Client fields.
747		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
748		assert_eq!(
749			config.tls.disable_verify, None,
750			"deprecated flag must not set the canonical field"
751		);
752		assert_eq!(config.tls.effective_disable_verify(), Some(true));
753		assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
754	}
755
756	#[test]
757	fn test_canonical_tls_flag_wins_over_deprecated() {
758		// Both spellings given: canonical wins for scalar options, vecs concatenate.
759		let config = ClientConfig::parse_from([
760			"test",
761			"--client-tls-disable-verify=false",
762			"--tls-disable-verify=true",
763			"--client-tls-fingerprint",
764			"aaaa",
765			"--tls-fingerprint",
766			"bbbb",
767		]);
768		assert_eq!(config.tls.effective_disable_verify(), Some(false));
769		assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
770	}
771
772	#[test]
773	fn test_cli_no_disable_verify() {
774		let config = ClientConfig::parse_from(["test"]);
775		assert_eq!(config.tls.disable_verify, None);
776	}
777
778	#[test]
779	fn test_toml_fingerprint_survives_update_from() {
780		let toml = r#"
781			tls.fingerprint = ["abcd1234", "ef567890"]
782		"#;
783
784		let mut config: ClientConfig = toml::from_str(toml).unwrap();
785		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
786
787		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag).
788		config.update_from(["test"]);
789		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
790	}
791
792	#[test]
793	fn test_toml_fingerprint_accepts_single_string() {
794		let toml = r#"
795			tls.fingerprint = "abcd1234"
796		"#;
797
798		let config: ClientConfig = toml::from_str(toml).unwrap();
799		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
800	}
801
802	#[test]
803	fn test_cli_fingerprint() {
804		let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
805		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
806	}
807
808	#[test]
809	fn test_toml_version_survives_update_from() {
810		let toml = r#"
811			version = ["moq-lite-02"]
812		"#;
813
814		let mut config: ClientConfig = toml::from_str(toml).unwrap();
815		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
816
817		// Simulate: TOML loaded, then CLI args re-applied (no --client-version flag).
818		config.update_from(["test"]);
819		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
820	}
821
822	#[test]
823	fn test_cli_version() {
824		let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
825		assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
826	}
827
828	#[test]
829	fn test_toml_connect_survives_update_from() {
830		let toml = r#"
831			connect = "https://relay.example.com/anon"
832		"#;
833
834		let mut config: ClientConfig = toml::from_str(toml).unwrap();
835		assert_eq!(
836			config.connect.as_ref().unwrap().as_str(),
837			"https://relay.example.com/anon"
838		);
839
840		// Simulate: TOML loaded, then CLI args re-applied (no --client-connect flag).
841		config.update_from(["test"]);
842		assert_eq!(
843			config.connect.as_ref().unwrap().as_str(),
844			"https://relay.example.com/anon"
845		);
846	}
847
848	#[test]
849	fn test_cli_connect() {
850		let config = ClientConfig::parse_from(["test", "--client-connect", "https://relay.example.com/anon"]);
851		assert_eq!(
852			config.connect.as_ref().unwrap().as_str(),
853			"https://relay.example.com/anon"
854		);
855	}
856
857	#[test]
858	fn test_toml_host_name_survives_update_from() {
859		let toml = r#"
860			tls.host_name = "example.host"
861		"#;
862
863		let mut config: ClientConfig = toml::from_str(toml).unwrap();
864		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
865
866		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-host-name flag).
867		config.update_from(["test"]);
868		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
869	}
870
871	#[test]
872	fn test_cli_host_name() {
873		let config = ClientConfig::parse_from(["test", "--client-tls-host-name", "override.example"]);
874		assert_eq!(config.tls.host_name.as_deref(), Some("override.example"));
875	}
876
877	#[test]
878	fn test_cli_no_version_defaults_to_all() {
879		let config = ClientConfig::parse_from(["test"]);
880		assert!(config.version.is_empty());
881		// versions() helper returns all when none specified
882		assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
883	}
884
885	#[cfg(feature = "websocket")]
886	#[tokio::test]
887	async fn race_transport_connect_stops_on_quic_auth_error() {
888		let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
889		let websocket = async {
890			// This only needs to complete later than the immediately ready QUIC auth error.
891			tokio::task::yield_now().await;
892			Some(Ok(1usize))
893		};
894
895		let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
896		assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
897	}
898
899	#[cfg(feature = "websocket")]
900	#[tokio::test]
901	async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
902		let quic = async { Err::<usize, _>(Error::ConnectFailed) };
903		let websocket = async { Some(Ok(7usize)) };
904
905		let value = super::race_transport_connect(quic, websocket).await.unwrap();
906		assert_eq!(value, super::TransportRace::WebSocket(7));
907	}
908
909	#[cfg(feature = "websocket")]
910	#[tokio::test]
911	async fn race_transport_connect_returns_when_quic_transport_connects() {
912		let quic = async { Ok("quic") };
913		let websocket = std::future::pending::<Option<crate::Result<&str>>>();
914
915		let value = tokio::time::timeout(
916			std::time::Duration::from_secs(1),
917			super::race_transport_connect(quic, websocket),
918		)
919		.await
920		.expect("race waited for WebSocket after QUIC transport connected")
921		.unwrap();
922		assert_eq!(value, super::TransportRace::Quic("quic"));
923	}
924}