1use std::collections::{BTreeMap, HashMap};
12use std::sync::Mutex;
13use std::time::Duration;
14
15use rightsize::backend::{FollowHandle, SandboxBackend, SandboxHandle};
16use rightsize::error::{Result, RightsizeError};
17use rightsize::model::{ContainerSpec, ExecResult};
18
19use crate::client::DockerClient;
20use crate::frames::{BodyReader, LineAssembler, StreamType, demux_into, read_frame};
21use crate::json::{
22 ConnectNetworkBody, CreateContainerBody, CreateExecBody, CreateNetworkBody, EmptyObject,
23 EndpointConfig, ExecInspect, HostConfig, IdResponse, LabelFilter, ListedEntry, NameFilter,
24 PortBinding as JsonPortBinding, StartExecBody,
25};
26
27const STOP_TIMEOUT_SECS: u64 = 10;
30
31const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
36
37struct Handle {
41 id: String,
42 spec: ContainerSpec,
43}
44
45impl SandboxHandle for Handle {
46 fn id(&self) -> &str {
47 &self.id
48 }
49 fn spec(&self) -> &ContainerSpec {
50 &self.spec
51 }
52}
53
54pub struct DockerBackend {
56 client: DockerClient,
57 network_ids: Mutex<HashMap<String, String>>,
61}
62
63impl DockerBackend {
64 pub(crate) fn new(client: DockerClient) -> Self {
70 DockerBackend {
71 client,
72 network_ids: Mutex::new(HashMap::new()),
73 }
74 }
75
76 pub fn connecting_to_env() -> Self {
81 Self::new(DockerClient::from_env())
82 }
83
84 #[cfg(test)]
88 pub(crate) fn socket_path_for_test(&self) -> std::path::PathBuf {
89 self.client.socket_path().to_path_buf()
90 }
91
92 async fn pull_if_missing(&self, image: &str) -> Result<()> {
99 let inspect_path = format!("/images/{}/json", encode_path_segment(image));
100 let inspect = self.client.request("GET", &inspect_path, None).await?;
101 if inspect.status == 200 {
102 return Ok(());
103 }
104 let (repo, tag) = split_repo_tag(image);
105 let pull_path = format!(
106 "/images/create?fromImage={}&tag={}",
107 encode_query_value(&repo),
108 encode_query_value(&tag)
109 );
110 let resp = self.client.request("POST", &pull_path, None).await?;
111 if resp.status >= 400 {
112 return Err(RightsizeError::Backend(format!(
113 "docker could not pull image '{image}' (HTTP {}): {}",
114 resp.status,
115 String::from_utf8_lossy(&resp.body)
116 )));
117 }
118 Ok(())
119 }
120
121 async fn connect_network(
124 &self,
125 container_id: &str,
126 network_id: &str,
127 aliases: &[String],
128 ) -> Result<()> {
129 let body = ConnectNetworkBody {
130 container: container_id.to_string(),
131 endpoint_config: EndpointConfig {
132 aliases: aliases.to_vec(),
133 },
134 };
135 let body = serde_json::to_string(&body)
136 .expect("ConnectNetworkBody has no non-serializable fields");
137 let path = format!("/networks/{network_id}/connect");
138 let resp = self.client.request("POST", &path, Some(&body)).await?;
139 if resp.status >= 400 {
140 return Err(RightsizeError::Backend(format!(
141 "docker could not connect container {container_id} to network {network_id} \
142 (HTTP {}): {}",
143 resp.status,
144 String::from_utf8_lossy(&resp.body)
145 )));
146 }
147 Ok(())
148 }
149
150 async fn ensure_network_get_id(&self, network_id: &str) -> Result<String> {
154 if let Some(id) = self
155 .network_ids
156 .lock()
157 .expect("network_ids mutex poisoned")
158 .get(network_id)
159 {
160 return Ok(id.clone());
161 }
162
163 let filters = NameFilter {
164 name: vec![network_id.to_string()],
165 };
166 let filters =
167 serde_json::to_string(&filters).expect("NameFilter has no non-serializable fields");
168 let list_path = format!("/networks?filters={}", encode_query_value(&filters));
169 let list = self.client.request("GET", &list_path, None).await?;
170 if list.status == 200 {
171 let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).unwrap_or_default();
172 if let Some(entry) = entries.into_iter().next() {
173 self.network_ids
174 .lock()
175 .expect("network_ids mutex poisoned")
176 .insert(network_id.to_string(), entry.id.clone());
177 return Ok(entry.id);
178 }
179 }
180
181 let create_body = CreateNetworkBody {
182 name: network_id.to_string(),
183 };
184 let create_body = serde_json::to_string(&create_body)
185 .expect("CreateNetworkBody has no non-serializable fields");
186 let created = self
187 .client
188 .request("POST", "/networks/create", Some(&create_body))
189 .await?;
190 if created.status >= 400 {
191 return Err(RightsizeError::Backend(format!(
192 "docker could not create network '{network_id}' (HTTP {}): {}",
193 created.status,
194 String::from_utf8_lossy(&created.body)
195 )));
196 }
197 let id = serde_json::from_slice::<IdResponse>(&created.body)
198 .map_err(|e| {
199 RightsizeError::Backend(format!(
200 "docker's network-create response for '{network_id}' had no Id field: {e} \
201 (body: {})",
202 String::from_utf8_lossy(&created.body)
203 ))
204 })?
205 .id;
206 self.network_ids
207 .lock()
208 .expect("network_ids mutex poisoned")
209 .insert(network_id.to_string(), id.clone());
210 Ok(id)
211 }
212}
213
214fn build_create_body(spec: &ContainerSpec) -> CreateContainerBody {
221 let env = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
222
223 let exposed_ports = spec
224 .ports
225 .iter()
226 .map(|p| (format!("{}/tcp", p.guest_port), EmptyObject {}))
227 .collect();
228
229 let port_bindings = spec
230 .ports
231 .iter()
232 .map(|p| {
233 (
234 format!("{}/tcp", p.guest_port),
235 vec![JsonPortBinding {
236 host_ip: "127.0.0.1".to_string(),
237 host_port: p.host_port.to_string(),
238 }],
239 )
240 })
241 .collect();
242
243 let binds = spec
244 .mounts
245 .iter()
246 .map(|m| {
247 let mode = if m.read_only { "ro" } else { "rw" };
248 format!("{}:{}:{}", m.host_path.display(), m.guest_path, mode)
249 })
250 .collect();
251
252 CreateContainerBody {
253 image: spec.image.clone(),
254 env,
255 cmd: spec.command.clone(),
256 exposed_ports,
257 labels: BTreeMap::from([(RUN_ID_LABEL_KEY.to_string(), spec.run_id.clone())]),
258 host_config: HostConfig {
259 port_bindings,
260 binds,
261 extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
262 memory: spec.memory_limit_mb.map(|mb| mb * 1024 * 1024),
263 },
264 }
265}
266
267fn is_port_bind_conflict_message(message: &str) -> bool {
272 let m = message.to_lowercase();
273 m.contains("already in use") || m.contains("already allocated")
274}
275
276fn encode_path_segment(s: &str) -> String {
280 s.to_string()
285}
286
287fn encode_query_value(s: &str) -> String {
291 let mut out = String::with_capacity(s.len());
292 for b in s.bytes() {
293 match b {
294 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
295 out.push(b as char)
296 }
297 _ => out.push_str(&format!("%{b:02X}")),
298 }
299 }
300 out
301}
302
303fn split_repo_tag(image: &str) -> (String, String) {
312 if image.contains('@') {
313 return (image.to_string(), String::new());
314 }
315 let slash_idx = image.rfind('/').map(|i| i + 1).unwrap_or(0);
319 match image[slash_idx..].rfind(':') {
320 Some(rel_colon) => {
321 let colon = slash_idx + rel_colon;
322 (image[..colon].to_string(), image[colon + 1..].to_string())
323 }
324 None => (image.to_string(), "latest".to_string()),
325 }
326}
327
328#[async_trait::async_trait]
329impl SandboxBackend for DockerBackend {
330 fn name(&self) -> &str {
331 "docker"
332 }
333
334 fn supports_native_networks(&self) -> bool {
335 true
336 }
337
338 async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
339 self.pull_if_missing(&spec.image).await?;
340
341 let body = build_create_body(&spec);
342 let body = serde_json::to_string(&body)
343 .expect("CreateContainerBody has no non-serializable fields");
344 let path = format!("/containers/create?name={}", encode_query_value(&spec.name));
345 let resp = self.client.request("POST", &path, Some(&body)).await?;
346 if resp.status >= 400 {
347 let message = String::from_utf8_lossy(&resp.body).into_owned();
348 return Err(RightsizeError::Backend(format!(
349 "docker could not create container '{}' (HTTP {}): {message}",
350 spec.name, resp.status
351 )));
352 }
353 let id = serde_json::from_slice::<IdResponse>(&resp.body)
354 .map_err(|e| {
355 RightsizeError::Backend(format!(
356 "docker's container-create response for '{}' had no Id field: {e} (body: {})",
357 spec.name,
358 String::from_utf8_lossy(&resp.body)
359 ))
360 })?
361 .id;
362
363 if let Some(network_id) = &spec.network_id {
364 let daemon_network_id = self.ensure_network_get_id(network_id).await?;
365 self.connect_network(&id, &daemon_network_id, &spec.aliases)
366 .await?;
367 }
368
369 Ok(Box::new(Handle { id, spec }))
370 }
371
372 async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
373 let path = format!("/containers/{}/start", handle.id());
374 let resp = self.client.request("POST", &path, None).await?;
375 if resp.status == 204 || resp.status == 304 {
376 return Ok(()); }
378 let message = String::from_utf8_lossy(&resp.body).into_owned();
379 if resp.status == 500 && is_port_bind_conflict_message(&message) {
380 return Err(RightsizeError::PortBindConflict {
381 message: format!(
382 "docker could not bind a host port for {}: {message}",
383 handle.id()
384 ),
385 source: None,
386 });
387 }
388 Err(RightsizeError::Backend(format!(
389 "docker could not start container {} (HTTP {}): {message}",
390 handle.id(),
391 resp.status
392 )))
393 }
394
395 async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
396 let path = format!("/containers/{}/stop?t={STOP_TIMEOUT_SECS}", handle.id());
397 let _ = self.client.request("POST", &path, None).await; Ok(())
399 }
400
401 async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()> {
402 let path = format!("/containers/{}?force=true", handle.id());
403 let _ = self.client.request("DELETE", &path, None).await; Ok(())
405 }
406
407 async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
408 let create_body = CreateExecBody {
409 attach_stdout: true,
410 attach_stderr: true,
411 cmd: cmd.to_vec(),
412 };
413 let create_body = serde_json::to_string(&create_body)
414 .expect("CreateExecBody has no non-serializable fields");
415 let create_path = format!("/containers/{}/exec", handle.id());
416 let created = self
417 .client
418 .request("POST", &create_path, Some(&create_body))
419 .await?;
420 if created.status >= 400 {
421 return Err(RightsizeError::Backend(format!(
422 "docker could not create an exec for container {} (HTTP {}): {}",
423 handle.id(),
424 created.status,
425 String::from_utf8_lossy(&created.body)
426 )));
427 }
428 let exec_id = serde_json::from_slice::<IdResponse>(&created.body)
429 .map_err(|e| {
430 RightsizeError::Backend(format!(
431 "docker's exec-create response for container {} had no Id field: {e} \
432 (body: {})",
433 handle.id(),
434 String::from_utf8_lossy(&created.body)
435 ))
436 })?
437 .id;
438
439 let start_path = format!("/exec/{exec_id}/start");
440 let start_body = serde_json::to_string(&StartExecBody { detach: false })
441 .expect("StartExecBody has no non-serializable fields");
442 let (headers, mut stream) = self
443 .client
444 .request_stream("POST", &start_path, Some(&start_body))
445 .await?;
446 if headers.status >= 400 {
447 return Err(RightsizeError::Backend(format!(
448 "docker could not start exec {exec_id} for container {} (HTTP {})",
449 handle.id(),
450 headers.status
451 )));
452 }
453 let mut body = BodyReader::new(&mut stream, &headers);
454 let mut stdout = Vec::new();
455 let mut stderr = Vec::new();
456 demux_into(
457 &mut body,
458 |b| stdout.extend_from_slice(b),
459 |b| stderr.extend_from_slice(b),
460 )
461 .await?;
462
463 let inspect_path = format!("/exec/{exec_id}/json");
464 let inspected = self.client.request("GET", &inspect_path, None).await?;
465 let exit_code = serde_json::from_slice::<ExecInspect>(&inspected.body)
466 .ok()
467 .and_then(|r| r.exit_code)
468 .unwrap_or(-1);
469
470 Ok(ExecResult {
471 exit_code: exit_code as i32,
472 stdout: String::from_utf8_lossy(&stdout).into_owned(),
473 stderr: String::from_utf8_lossy(&stderr).into_owned(),
474 })
475 }
476
477 async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String> {
478 let path = format!(
479 "/containers/{}/logs?stdout=1&stderr=1&tail=1000",
480 handle.id()
481 );
482 let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
483 if headers.status >= 400 {
484 return Err(RightsizeError::Backend(format!(
485 "docker could not fetch logs for container {} (HTTP {})",
486 handle.id(),
487 headers.status
488 )));
489 }
490 let mut body = BodyReader::new(&mut stream, &headers);
491 let mut assembler = LineAssembler::new();
492 let mut out = String::new();
493 while let Some(frame) = read_frame(&mut body).await? {
494 if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
495 continue;
496 }
497 let text = String::from_utf8_lossy(&frame.payload).into_owned();
498 for line in assembler.feed(&text) {
499 out.push_str(&line);
500 out.push('\n');
501 }
502 }
503 if let Some(tail) = assembler.flush() {
504 out.push_str(&tail);
505 out.push('\n');
506 }
507 Ok(out)
508 }
509
510 async fn follow_logs(
511 &self,
512 handle: &dyn SandboxHandle,
513 consumer: Box<dyn Fn(String) + Send + Sync>,
514 ) -> Result<FollowHandle> {
515 let path = format!(
516 "/containers/{}/logs?stdout=1&stderr=1&follow=1&tail=all",
517 handle.id()
518 );
519 let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
520 if headers.status >= 400 {
521 return Err(RightsizeError::Backend(format!(
522 "docker could not follow logs for container {} (HTTP {})",
523 handle.id(),
524 headers.status
525 )));
526 }
527
528 let close_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
529 let task_close = close_requested.clone();
530 let task = tokio::spawn(async move {
531 let mut body = BodyReader::new(&mut stream, &headers);
532 let mut assembler = LineAssembler::new();
533 loop {
534 if task_close.load(std::sync::atomic::Ordering::SeqCst) {
541 return;
542 }
543 let frame = match read_frame(&mut body).await {
544 Ok(Some(f)) => f,
545 Ok(None) => break, Err(_) => break, };
548 if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
549 continue;
550 }
551 let text = String::from_utf8_lossy(&frame.payload).into_owned();
552 for line in assembler.feed(&text) {
553 if task_close.load(std::sync::atomic::Ordering::SeqCst) {
554 return;
555 }
556 consumer(line);
557 }
558 }
559 if task_close.load(std::sync::atomic::Ordering::SeqCst) {
560 return; }
562 if let Some(tail) = assembler.flush() {
563 consumer(tail);
564 }
565 });
566
567 Ok(FollowHandle::from_task(close_requested, task))
568 }
569
570 async fn ensure_network(&self, network_id: &str) -> Result<()> {
571 self.ensure_network_get_id(network_id).await?;
572 Ok(())
573 }
574
575 async fn remove_network(&self, network_id: &str) -> Result<()> {
576 let daemon_id = self
577 .network_ids
578 .lock()
579 .expect("network_ids mutex poisoned")
580 .remove(network_id);
581 if let Some(id) = daemon_id {
582 let path = format!("/networks/{id}");
583 let _ = self.client.request("DELETE", &path, None).await; }
585 Ok(())
586 }
587
588 async fn close(&self) -> Result<()> {
589 let run_id = rightsize::RunId::value();
590 let filters = LabelFilter {
591 label: vec![format!("{RUN_ID_LABEL_KEY}={run_id}")],
592 };
593 let filters =
594 serde_json::to_string(&filters).expect("LabelFilter has no non-serializable fields");
595 let path = format!(
596 "/containers/json?all=true&filters={}",
597 encode_query_value(&filters)
598 );
599 let listed = self.client.request("GET", &path, None).await?;
600 if listed.status != 200 {
601 return Ok(()); }
603 let entries: Vec<ListedEntry> = serde_json::from_slice(&listed.body).unwrap_or_default();
604 for entry in entries {
605 let remove_path = format!("/containers/{}?force=true", entry.id);
606 let _ = self.client.request("DELETE", &remove_path, None).await;
607 }
608 Ok(())
609 }
610
611 fn cleanup_sync(&self, container_id: &str) {
612 let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
615 }
616}
617
618fn blocking_force_remove(
625 socket_path: &std::path::Path,
626 container_id: &str,
627 stop_timeout_secs: u64,
628) -> std::io::Result<()> {
629 let _ = blocking_request(
630 socket_path,
631 "POST",
632 &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
633 );
634 blocking_request(
635 socket_path,
636 "DELETE",
637 &format!("/containers/{container_id}?force=true"),
638 )
639 .map(|_| ())
640}
641
642fn blocking_request(
651 socket_path: &std::path::Path,
652 method: &str,
653 path: &str,
654) -> std::io::Result<()> {
655 use std::io::{Read, Write};
656 use std::os::unix::net::UnixStream;
657
658 let mut stream = UnixStream::connect(socket_path)?;
659 stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
660 let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
661 stream.write_all(request.as_bytes())?;
662 let mut discard = Vec::new();
663 let _ = stream.read_to_end(&mut discard);
664 Ok(())
665}
666
667fn stop_timeout_budget() -> u64 {
672 STOP_TIMEOUT_SECS + 20
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn is_port_bind_conflict_message_matches_known_phrasings() {
681 assert!(is_port_bind_conflict_message(
682 "driver failed programming external connectivity: address already in use"
683 ));
684 assert!(is_port_bind_conflict_message(
685 "Bind for 0.0.0.0:6379 failed: port is already allocated"
686 ));
687 assert!(is_port_bind_conflict_message(
688 "ALREADY ALLOCATED (case-insensitive)"
689 ));
690 }
691
692 #[test]
693 fn is_port_bind_conflict_message_negative_cases_do_not_match() {
694 assert!(!is_port_bind_conflict_message("no such image"));
695 assert!(!is_port_bind_conflict_message("container already stopped"));
696 assert!(!is_port_bind_conflict_message(""));
697 }
698
699 #[test]
700 fn split_repo_tag_defaults_to_latest_when_untagged() {
701 assert_eq!(
702 split_repo_tag("redis"),
703 ("redis".to_string(), "latest".to_string())
704 );
705 assert_eq!(
706 split_repo_tag("redis:8.6-alpine"),
707 ("redis".to_string(), "8.6-alpine".to_string())
708 );
709 }
710
711 #[test]
712 fn split_repo_tag_handles_a_registry_host_with_a_port() {
713 assert_eq!(
714 split_repo_tag("localhost:5000/redis"),
715 ("localhost:5000/redis".to_string(), "latest".to_string())
716 );
717 assert_eq!(
718 split_repo_tag("localhost:5000/redis:8.6-alpine"),
719 ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
720 );
721 }
722
723 #[test]
724 fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
725 assert_eq!(
726 split_repo_tag("redis@sha256:abcdef"),
727 ("redis@sha256:abcdef".to_string(), String::new())
728 );
729 }
730
731 fn serialize(spec: &ContainerSpec) -> String {
737 serde_json::to_string(&build_create_body(spec)).unwrap()
738 }
739
740 #[test]
741 fn build_create_body_includes_port_bindings_on_loopback() {
742 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
743 spec.ports.push(rightsize::model::PortBinding {
744 host_port: 32768,
745 guest_port: 6379,
746 });
747 let body = serialize(&spec);
748 assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
749 assert!(body.contains("\"HostPort\":\"32768\""));
750 assert!(body.contains("\"6379/tcp\""));
751 }
752
753 #[test]
754 fn build_create_body_includes_the_run_id_label_and_extra_host() {
755 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
756 let body = serialize(&spec);
757 assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
758 assert!(body.contains("host.docker.internal:host-gateway"));
759 }
760
761 #[test]
762 fn build_create_body_honors_mount_read_only_flag() {
763 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
764 spec.mounts
765 .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
766 spec.mounts
767 .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
768 let body = serialize(&spec);
769 assert!(body.contains("/host/a:/guest/a:ro"));
770 assert!(body.contains("/host/b:/guest/b:rw"));
771 }
772
773 #[test]
774 fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
775 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
776 assert!(!serialize(&spec).contains("Memory"));
777
778 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
779 spec.memory_limit_mb = Some(512);
780 assert!(serialize(&spec).contains("\"Memory\":536870912"));
781 }
782
783 #[test]
784 fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
785 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
786 assert!(!serialize(&spec).contains("\"Cmd\""));
787
788 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
789 spec.command = Some(vec![
790 "redis-server".to_string(),
791 "--port".to_string(),
792 "6379".to_string(),
793 ]);
794 let body = serialize(&spec);
795 assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
796 }
797
798 #[test]
799 fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
800 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
805 let path = backend.socket_path_for_test();
806 assert!(
807 path.is_absolute(),
808 "expected an absolute unix socket path, got {path:?}"
809 );
810 let path_str = path.to_string_lossy();
811 assert!(
812 !path_str.contains(':'),
813 "a unix socket path must not look like a host:port TCP address — got {path_str}"
814 );
815
816 let backend_env =
817 DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
818 let fallback_path = backend_env.socket_path_for_test();
819 assert!(
820 !fallback_path.to_string_lossy().contains("2375"),
821 "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
822 got {fallback_path:?}"
823 );
824 }
825}