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	#[command(flatten)]
54	#[serde(default)]
55	pub tls: crate::tls::Client,
56
57	#[command(flatten)]
58	#[serde(default)]
59	pub backoff: Backoff,
60
61	#[cfg(feature = "websocket")]
62	#[command(flatten)]
63	#[serde(default)]
64	pub websocket: crate::websocket::Client,
65}
66
67impl ClientConfig {
68	pub fn init(self) -> crate::Result<Client> {
69		Client::new(self)
70	}
71
72	/// Returns the configured versions, defaulting to all if none specified.
73	pub fn versions(&self) -> moq_net::Versions {
74		if self.version.is_empty() {
75			moq_net::Versions::all()
76		} else {
77			moq_net::Versions::from(self.version.clone())
78		}
79	}
80}
81
82impl Default for ClientConfig {
83	fn default() -> Self {
84		Self {
85			connect: None,
86			bind: "[::]:0".parse().unwrap(),
87			backend: None,
88			quic: crate::quic::Client::default(),
89			version: Vec::new(),
90			tls: crate::tls::Client::default(),
91			backoff: Backoff::default(),
92			#[cfg(feature = "websocket")]
93			websocket: crate::websocket::Client::default(),
94		}
95	}
96}
97
98/// Client for establishing MoQ connections over QUIC, WebTransport, or WebSocket.
99///
100/// Create via [`ClientConfig::init`] or [`Client::new`].
101#[derive(Clone)]
102pub struct Client {
103	moq: moq_net::Client,
104	/// The single resolved set of protocol versions, used to advertise moq ALPNs across
105	/// every transport (passed into the QUIC backends' `connect` and used directly for
106	/// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list
107	/// can't diverge between transports.
108	versions: moq_net::Versions,
109	/// The URL from [`ClientConfig::connect`], dialed by [`Client::publish`] / [`Client::consume`].
110	connect: Option<Url>,
111	backoff: Backoff,
112	#[cfg(feature = "websocket")]
113	websocket: crate::websocket::Client,
114	tls: rustls::ClientConfig,
115	#[cfg(feature = "noq")]
116	noq: Option<crate::noq::NoqClient>,
117	#[cfg(feature = "quinn")]
118	quinn: Option<crate::quinn::QuinnClient>,
119	#[cfg(feature = "quiche")]
120	quiche: Option<crate::quiche::QuicheClient>,
121	#[cfg(feature = "iroh")]
122	iroh: Option<web_transport_iroh::iroh::Endpoint>,
123	#[cfg(feature = "iroh")]
124	iroh_addrs: Vec<std::net::SocketAddr>,
125}
126
127impl Client {
128	#[cfg(not(any(
129		feature = "noq",
130		feature = "quinn",
131		feature = "quiche",
132		feature = "websocket",
133		feature = "tcp",
134		feature = "uds"
135	)))]
136	pub fn new(_config: ClientConfig) -> crate::Result<Self> {
137		Err(Error::NoBackend(
138			"no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
139		))
140	}
141
142	/// Create a new client
143	#[cfg(any(
144		feature = "noq",
145		feature = "quinn",
146		feature = "quiche",
147		feature = "websocket",
148		feature = "tcp",
149		feature = "uds"
150	))]
151	pub fn new(config: ClientConfig) -> crate::Result<Self> {
152		#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
153		let backend = config.backend.clone().unwrap_or({
154			#[cfg(feature = "quinn")]
155			{
156				QuicBackend::Quinn
157			}
158			#[cfg(all(feature = "noq", not(feature = "quinn")))]
159			{
160				QuicBackend::Noq
161			}
162			#[cfg(all(feature = "quiche", not(feature = "quinn"), not(feature = "noq")))]
163			{
164				QuicBackend::Quiche
165			}
166			#[cfg(all(not(feature = "quiche"), not(feature = "quinn"), not(feature = "noq")))]
167			panic!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
168		});
169
170		let tls = config.tls.build()?;
171
172		#[cfg(feature = "noq")]
173		#[allow(unreachable_patterns)]
174		let noq = match backend {
175			QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
176			_ => None,
177		};
178
179		#[cfg(feature = "quinn")]
180		#[allow(unreachable_patterns)]
181		let quinn = match backend {
182			QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
183			_ => None,
184		};
185
186		#[cfg(feature = "quiche")]
187		let quiche = match backend {
188			QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
189			_ => None,
190		};
191
192		let versions = config.versions();
193		Ok(Self {
194			moq: moq_net::Client::new().with_versions(versions.clone()),
195			versions,
196			connect: config.connect,
197			backoff: config.backoff,
198			#[cfg(feature = "websocket")]
199			websocket: config.websocket,
200			tls,
201			#[cfg(feature = "noq")]
202			noq,
203			#[cfg(feature = "quinn")]
204			quinn,
205			#[cfg(feature = "quiche")]
206			quiche,
207			#[cfg(feature = "iroh")]
208			iroh: None,
209			#[cfg(feature = "iroh")]
210			iroh_addrs: Vec::new(),
211		})
212	}
213
214	#[cfg(feature = "iroh")]
215	pub fn with_iroh(mut self, iroh: Option<web_transport_iroh::iroh::Endpoint>) -> Self {
216		self.iroh = iroh;
217		self
218	}
219
220	/// Set direct IP addresses for connecting to iroh peers.
221	///
222	/// This is useful when the peer's IP addresses are known ahead of time,
223	/// bypassing the need for peer discovery (e.g. in tests or local networks).
224	#[cfg(feature = "iroh")]
225	pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
226		self.iroh_addrs = addrs;
227		self
228	}
229
230	pub fn with_publish(mut self, publish: impl Into<Option<moq_net::OriginConsumer>>) -> Self {
231		self.moq = self.moq.with_publish(publish);
232		self
233	}
234
235	pub fn with_consume(mut self, consume: impl Into<Option<moq_net::OriginProducer>>) -> Self {
236		self.moq = self.moq.with_consume(consume);
237		self
238	}
239
240	/// Attach a tier-scoped [`moq_net::StatsHandle`] to all sessions opened by this client.
241	pub fn with_stats(mut self, stats: moq_net::StatsHandle) -> Self {
242		self.moq = self.moq.with_stats(stats);
243		self
244	}
245
246	/// Start a background reconnect loop that connects to the given URL,
247	/// waits for the session to close, then reconnects with exponential backoff.
248	///
249	/// Returns a [`Reconnect`] handle; drop the last handle to stop the loop.
250	pub fn reconnect(&self, url: Url) -> Reconnect {
251		Reconnect::new(self.clone(), url, self.backoff.clone())
252	}
253
254	/// Dial the configured [`ClientConfig::connect`] URL, publishing `origin` to it
255	/// and reconnecting with backoff until the returned handle is dropped.
256	///
257	/// Returns `None` when no `--client-connect` URL was configured, so a caller
258	/// that may run server-only doesn't have to branch on the URL itself.
259	pub fn publish(self, origin: moq_net::OriginConsumer) -> Option<Reconnect> {
260		let url = self.connect.clone()?;
261		Some(self.with_publish(origin).reconnect(url))
262	}
263
264	/// Dial the configured [`ClientConfig::connect`] URL, consuming its broadcasts
265	/// into `origin` and reconnecting with backoff until the returned handle is
266	/// dropped.
267	///
268	/// Returns `None` when no `--client-connect` URL was configured.
269	pub fn consume(self, origin: moq_net::OriginProducer) -> Option<Reconnect> {
270		let url = self.connect.clone()?;
271		Some(self.with_consume(origin).reconnect(url))
272	}
273
274	#[cfg(not(any(
275		feature = "noq",
276		feature = "quinn",
277		feature = "quiche",
278		feature = "iroh",
279		feature = "websocket",
280		feature = "tcp",
281		feature = "uds"
282	)))]
283	pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
284		Err(Error::NoBackend(
285			"no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
286		))
287	}
288
289	#[cfg(any(
290		feature = "noq",
291		feature = "quinn",
292		feature = "quiche",
293		feature = "iroh",
294		feature = "websocket",
295		feature = "tcp",
296		feature = "uds"
297	))]
298	pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
299		let session = self.connect_inner(url).await?;
300		tracing::info!(version = %session.version(), "connected");
301		Ok(session)
302	}
303
304	/// Build the per-connection moq client, advertising the request path in the SETUP
305	/// for transports that carry no request URI of their own (raw QUIC, qmux over
306	/// TCP/UDS, raw iroh). WebTransport and WebSocket already convey the path in their
307	/// own request, so we omit it there to avoid duplicating it.
308	#[cfg(any(
309		feature = "noq",
310		feature = "quinn",
311		feature = "quiche",
312		feature = "iroh",
313		feature = "websocket",
314		feature = "tcp",
315		feature = "uds"
316	))]
317	fn connect_client(&self, url: &Url) -> moq_net::Client {
318		if transport_carries_path(url) {
319			return self.moq.clone();
320		}
321
322		match request_path(url) {
323			Some(path) => self.moq.clone().with_path(path),
324			None => self.moq.clone(),
325		}
326	}
327
328	#[cfg(any(
329		feature = "noq",
330		feature = "quinn",
331		feature = "quiche",
332		feature = "iroh",
333		feature = "websocket",
334		feature = "tcp",
335		feature = "uds"
336	))]
337	async fn connect_inner(&self, url: Url) -> crate::Result<moq_net::Session> {
338		// Advertise the request path in the moq SETUP for transports that carry no
339		// request URI of their own; WebTransport and WebSocket already convey it.
340		let moq = self.connect_client(&url);
341
342		// Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against
343		// QUIC, which can't speak it. Use only on a trusted network.
344		#[cfg(feature = "tcp")]
345		if url.scheme() == "tcp" {
346			let session = crate::tcp::connect(url, &self.versions.alpns()).await?;
347			return Ok(moq.connect(session).await?);
348		}
349
350		// Unix domain socket (qmux, no TLS). Same-host only; the server can
351		// authenticate us by uid/gid via SO_PEERCRED.
352		#[cfg(all(feature = "uds", unix))]
353		if url.scheme() == "unix" {
354			let session = crate::unix::connect(url, &self.versions.alpns()).await?;
355			return Ok(moq.connect(session).await?);
356		}
357
358		#[cfg(feature = "iroh")]
359		if url.scheme() == "iroh" {
360			let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
361			let session = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
362			let session = moq.connect(session).await?;
363			return Ok(session);
364		}
365
366		#[cfg(feature = "noq")]
367		if let Some(noq) = self.noq.as_ref() {
368			let tls = self.tls.clone();
369			let quic_url = url.clone();
370			let quic_handle = async { noq.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
371
372			#[cfg(feature = "websocket")]
373			{
374				return self.race_moq_connect(&moq, url, quic_handle).await;
375			}
376
377			#[cfg(not(feature = "websocket"))]
378			{
379				let session = quic_handle.await?;
380				return Ok(moq.connect(session).await?);
381			}
382		}
383
384		#[cfg(feature = "quinn")]
385		if let Some(quinn) = self.quinn.as_ref() {
386			let tls = self.tls.clone();
387			let quic_url = url.clone();
388			let quic_handle = async { quinn.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
389
390			#[cfg(feature = "websocket")]
391			{
392				return self.race_moq_connect(&moq, url, quic_handle).await;
393			}
394
395			#[cfg(not(feature = "websocket"))]
396			{
397				let session = quic_handle.await?;
398				return Ok(moq.connect(session).await?);
399			}
400		}
401
402		#[cfg(feature = "quiche")]
403		if let Some(quiche) = self.quiche.as_ref() {
404			let quic_url = url.clone();
405			let quic_handle = async { quiche.connect(quic_url, &self.versions).await.map_err(Error::from) };
406
407			#[cfg(feature = "websocket")]
408			{
409				return self.race_moq_connect(&moq, url, quic_handle).await;
410			}
411
412			#[cfg(not(feature = "websocket"))]
413			{
414				let session = quic_handle.await?;
415				return Ok(moq.connect(session).await?);
416			}
417		}
418
419		#[cfg(feature = "websocket")]
420		{
421			let alpns = self.versions.alpns();
422			let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
423			return Ok(moq.connect(session).await?);
424		}
425
426		#[cfg(not(feature = "websocket"))]
427		return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
428	}
429
430	#[cfg(feature = "websocket")]
431	async fn race_moq_connect<Q, S>(&self, moq: &moq_net::Client, url: Url, quic: Q) -> crate::Result<moq_net::Session>
432	where
433		Q: Future<Output = crate::Result<S>>,
434		S: web_transport_trait::Session,
435	{
436		let alpns = self.versions.alpns();
437		let ws_config = self.websocket.clone();
438		let ws_tls = self.tls.clone();
439		let websocket = async move {
440			crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
441				.await
442				.map(|res| res.map_err(Error::from))
443		};
444
445		match race_transport_connect(quic, websocket).await? {
446			TransportRace::Quic(quic) => Ok(moq.connect(quic).await?),
447			TransportRace::WebSocket(websocket) => Ok(moq.connect(websocket).await?),
448		}
449	}
450}
451
452/// Whether the transport for this URL always conveys the request path itself.
453///
454/// WebTransport (`https`/`http`) and WebSocket (`ws`/`wss`) carry the path in their
455/// request, so it must not be duplicated in the moq SETUP. Everything else advertises
456/// it in the SETUP: `moqt`/`moql` raw QUIC and qmux over `tcp`/`unix` have no request
457/// URI, and `iroh` only carries one in its HTTP/3 mode (a raw iroh session does not),
458/// so iroh always sends it in band to be safe.
459#[cfg(any(
460	feature = "noq",
461	feature = "quinn",
462	feature = "quiche",
463	feature = "iroh",
464	feature = "websocket",
465	feature = "tcp",
466	feature = "uds"
467))]
468fn transport_carries_path(url: &Url) -> bool {
469	matches!(url.scheme(), "https" | "http" | "ws" | "wss")
470}
471
472/// The request path to advertise in the moq SETUP, derived from the dial URL.
473///
474/// A `?path=` query overrides everything; it is the only way to set a path on a
475/// `unix://` URL, whose URL path is the socket file rather than a namespace. Other
476/// schemes (`tcp`, raw QUIC, `iroh`) use the URL path component. Returns `None` for a
477/// `unix://` URL with no `?path=`, leaving the namespace at the root.
478#[cfg(any(
479	feature = "noq",
480	feature = "quinn",
481	feature = "quiche",
482	feature = "iroh",
483	feature = "websocket",
484	feature = "tcp",
485	feature = "uds"
486))]
487fn request_path(url: &Url) -> Option<String> {
488	if let Some((_, path)) = url.query_pairs().find(|(key, _)| key == "path") {
489		return Some(path.into_owned());
490	}
491	match url.scheme() {
492		"unix" => None,
493		_ => Some(url.path().to_string()),
494	}
495}
496
497#[cfg(feature = "websocket")]
498#[derive(Debug, PartialEq, Eq)]
499enum TransportRace<Q, W> {
500	Quic(Q),
501	WebSocket(W),
502}
503
504#[cfg(feature = "websocket")]
505async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
506where
507	Q: Future<Output = crate::Result<QT>>,
508	W: Future<Output = Option<crate::Result<WT>>>,
509{
510	tokio::pin!(quic);
511	tokio::pin!(websocket);
512
513	let mut quic_err = None;
514	let mut websocket_err = None;
515	let mut quic_done = false;
516	let mut websocket_done = false;
517
518	loop {
519		tokio::select! {
520			res = &mut quic, if !quic_done => {
521				match res {
522					Ok(session) => return Ok(TransportRace::Quic(session)),
523					Err(err) if err.is_auth() => return Err(err),
524					Err(err) => {
525						tracing::warn!(%err, "QUIC connection failed");
526						quic_err = Some(err);
527						quic_done = true;
528					}
529				}
530			}
531			res = &mut websocket, if !websocket_done => {
532				match res {
533					Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
534					Some(Err(err)) if err.is_auth() => return Err(err),
535					Some(Err(err)) => {
536						tracing::warn!(%err, "WebSocket connection failed");
537						websocket_err = Some(err);
538						websocket_done = true;
539					}
540					None => {
541						websocket_done = true;
542					}
543				}
544			}
545			else => break,
546		}
547
548		if quic_done && websocket_done {
549			break;
550		}
551	}
552
553	match (quic_err, websocket_err) {
554		(Some(quic), Some(websocket)) => Err(Error::TransportRace {
555			quic: std::sync::Arc::new(quic),
556			websocket: std::sync::Arc::new(websocket),
557		}),
558		(Some(err), None) | (None, Some(err)) => Err(err),
559		(None, None) => Err(Error::ConnectFailed),
560	}
561}
562
563#[cfg(test)]
564mod tests {
565	use super::*;
566	use clap::Parser;
567
568	#[cfg(any(
569		feature = "noq",
570		feature = "quinn",
571		feature = "quiche",
572		feature = "iroh",
573		feature = "websocket",
574		feature = "tcp",
575		feature = "uds"
576	))]
577	#[test]
578	fn classifies_transport_path() {
579		for url in ["https://h/p", "http://h/p", "wss://h/p", "ws://h/p"] {
580			assert!(
581				transport_carries_path(&Url::parse(url).unwrap()),
582				"{url} carries its own path"
583			);
584		}
585		// iroh is URL-less here: its raw mode carries no request URI, so it always
586		// advertises the path in the SETUP.
587		for url in [
588			"tcp://h:1/p",
589			"unix:///run/s.sock",
590			"moqt://h/p",
591			"moql://h/p",
592			"iroh://node/p",
593		] {
594			assert!(
595				!transport_carries_path(&Url::parse(url).unwrap()),
596				"{url} advertises in SETUP"
597			);
598		}
599	}
600
601	#[cfg(any(
602		feature = "noq",
603		feature = "quinn",
604		feature = "quiche",
605		feature = "iroh",
606		feature = "websocket",
607		feature = "tcp",
608		feature = "uds"
609	))]
610	#[test]
611	fn request_path_from_url() {
612		// Schemes with a free path component use it directly.
613		for url in ["tcp://h:1/anycast", "moqt://h/anycast", "iroh://node/anycast"] {
614			assert_eq!(
615				request_path(&Url::parse(url).unwrap()).as_deref(),
616				Some("/anycast"),
617				"{url}"
618			);
619		}
620		// A unix:// URL path is the socket file, so it has no namespace by default.
621		assert_eq!(request_path(&Url::parse("unix:///run/s.sock").unwrap()), None);
622		// ...but a ?path= query supplies one, leaving the socket path intact.
623		let uds = Url::parse("unix:///run/s.sock?path=/anycast").unwrap();
624		assert_eq!(uds.path(), "/run/s.sock");
625		assert_eq!(request_path(&uds).as_deref(), Some("/anycast"));
626		// ?path= overrides the URL path on any scheme.
627		assert_eq!(
628			request_path(&Url::parse("tcp://h:1/ignored?path=/win").unwrap()).as_deref(),
629			Some("/win")
630		);
631	}
632
633	#[test]
634	fn test_toml_disable_verify_survives_update_from() {
635		let toml = r#"
636			tls.disable_verify = true
637		"#;
638
639		let mut config: ClientConfig = toml::from_str(toml).unwrap();
640		assert_eq!(config.tls.disable_verify, Some(true));
641
642		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag).
643		config.update_from(["test"]);
644		assert_eq!(config.tls.disable_verify, Some(true));
645	}
646
647	#[test]
648	fn test_cli_disable_verify_flag() {
649		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
650		assert_eq!(config.tls.disable_verify, Some(true));
651	}
652
653	#[test]
654	fn test_cli_disable_verify_explicit_false() {
655		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
656		assert_eq!(config.tls.disable_verify, Some(false));
657	}
658
659	#[test]
660	fn test_cli_disable_verify_explicit_true() {
661		let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
662		assert_eq!(config.tls.disable_verify, Some(true));
663	}
664
665	#[test]
666	fn test_cli_deprecated_tls_flags_fold_into_canonical() {
667		// The bare --tls-* forms are deprecated. They parse into a hidden field and
668		// fold into the canonical values via the effective_* accessors build() uses,
669		// so they keep working without touching the public Client fields.
670		let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
671		assert_eq!(
672			config.tls.disable_verify, None,
673			"deprecated flag must not set the canonical field"
674		);
675		assert_eq!(config.tls.effective_disable_verify(), Some(true));
676		assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
677	}
678
679	#[test]
680	fn test_canonical_tls_flag_wins_over_deprecated() {
681		// Both spellings given: canonical wins for scalar options, vecs concatenate.
682		let config = ClientConfig::parse_from([
683			"test",
684			"--client-tls-disable-verify=false",
685			"--tls-disable-verify=true",
686			"--client-tls-fingerprint",
687			"aaaa",
688			"--tls-fingerprint",
689			"bbbb",
690		]);
691		assert_eq!(config.tls.effective_disable_verify(), Some(false));
692		assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
693	}
694
695	#[test]
696	fn test_cli_no_disable_verify() {
697		let config = ClientConfig::parse_from(["test"]);
698		assert_eq!(config.tls.disable_verify, None);
699	}
700
701	#[test]
702	fn test_toml_fingerprint_survives_update_from() {
703		let toml = r#"
704			tls.fingerprint = ["abcd1234", "ef567890"]
705		"#;
706
707		let mut config: ClientConfig = toml::from_str(toml).unwrap();
708		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
709
710		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag).
711		config.update_from(["test"]);
712		assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
713	}
714
715	#[test]
716	fn test_toml_fingerprint_accepts_single_string() {
717		let toml = r#"
718			tls.fingerprint = "abcd1234"
719		"#;
720
721		let config: ClientConfig = toml::from_str(toml).unwrap();
722		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
723	}
724
725	#[test]
726	fn test_cli_fingerprint() {
727		let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
728		assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
729	}
730
731	#[test]
732	fn test_toml_version_survives_update_from() {
733		let toml = r#"
734			version = ["moq-lite-02"]
735		"#;
736
737		let mut config: ClientConfig = toml::from_str(toml).unwrap();
738		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
739
740		// Simulate: TOML loaded, then CLI args re-applied (no --client-version flag).
741		config.update_from(["test"]);
742		assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
743	}
744
745	#[test]
746	fn test_cli_version() {
747		let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
748		assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
749	}
750
751	#[test]
752	fn test_toml_connect_survives_update_from() {
753		let toml = r#"
754			connect = "https://relay.example.com/anon"
755		"#;
756
757		let mut config: ClientConfig = toml::from_str(toml).unwrap();
758		assert_eq!(
759			config.connect.as_ref().unwrap().as_str(),
760			"https://relay.example.com/anon"
761		);
762
763		// Simulate: TOML loaded, then CLI args re-applied (no --client-connect flag).
764		config.update_from(["test"]);
765		assert_eq!(
766			config.connect.as_ref().unwrap().as_str(),
767			"https://relay.example.com/anon"
768		);
769	}
770
771	#[test]
772	fn test_cli_connect() {
773		let config = ClientConfig::parse_from(["test", "--client-connect", "https://relay.example.com/anon"]);
774		assert_eq!(
775			config.connect.as_ref().unwrap().as_str(),
776			"https://relay.example.com/anon"
777		);
778	}
779
780	#[test]
781	fn test_toml_host_name_survives_update_from() {
782		let toml = r#"
783			tls.host_name = "example.host"
784		"#;
785
786		let mut config: ClientConfig = toml::from_str(toml).unwrap();
787		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
788
789		// Simulate: TOML loaded, then CLI args re-applied (no --client-tls-host-name flag).
790		config.update_from(["test"]);
791		assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
792	}
793
794	#[test]
795	fn test_cli_host_name() {
796		let config = ClientConfig::parse_from(["test", "--client-tls-host-name", "override.example"]);
797		assert_eq!(config.tls.host_name.as_deref(), Some("override.example"));
798	}
799
800	#[test]
801	fn test_cli_no_version_defaults_to_all() {
802		let config = ClientConfig::parse_from(["test"]);
803		assert!(config.version.is_empty());
804		// versions() helper returns all when none specified
805		assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
806	}
807
808	#[cfg(feature = "websocket")]
809	#[tokio::test]
810	async fn race_transport_connect_stops_on_quic_auth_error() {
811		let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
812		let websocket = async {
813			// This only needs to complete later than the immediately ready QUIC auth error.
814			tokio::task::yield_now().await;
815			Some(Ok(1usize))
816		};
817
818		let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
819		assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
820	}
821
822	#[cfg(feature = "websocket")]
823	#[tokio::test]
824	async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
825		let quic = async { Err::<usize, _>(Error::ConnectFailed) };
826		let websocket = async { Some(Ok(7usize)) };
827
828		let value = super::race_transport_connect(quic, websocket).await.unwrap();
829		assert_eq!(value, super::TransportRace::WebSocket(7));
830	}
831
832	#[cfg(feature = "websocket")]
833	#[tokio::test]
834	async fn race_transport_connect_returns_when_quic_transport_connects() {
835		let quic = async { Ok("quic") };
836		let websocket = std::future::pending::<Option<crate::Result<&str>>>();
837
838		let value = tokio::time::timeout(
839			std::time::Duration::from_secs(1),
840			super::race_transport_connect(quic, websocket),
841		)
842		.await
843		.expect("race waited for WebSocket after QUIC transport connected")
844		.unwrap();
845		assert_eq!(value, super::TransportRace::Quic("quic"));
846	}
847}