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