Skip to main content

fakecloud_core/
container_net.rs

1//! Shared container-to-host networking resolution for service runtimes
2//! that spawn sibling containers (Lambda, ECS, RDS, ElastiCache).
3//!
4//! Captures the issue #1539 fix shape in one place so the four runtimes
5//! that shell out to `docker`/`podman` can't drift apart again:
6//!
7//! - **podman** ships `host.containers.internal` as a built-in container
8//!   DNS entry on every platform and must NOT receive
9//!   `--add-host host.docker.internal:host-gateway` — rootless podman's
10//!   gvproxy leaves the magic alias empty and the `create` fails with
11//!   "host containers internal IP address is empty".
12//! - **bare docker on Linux** has no `host-gateway` magic; the bridge
13//!   gateway IP has to be resolved from the daemon and injected explicitly.
14//! - **Docker Desktop on Mac/Windows** resolves the `host-gateway` magic
15//!   value to the host's IP.
16//! - when fakecloud itself runs in a container (`FAKECLOUD_IN_CONTAINER=1`,
17//!   baked into the published image), the sibling containers it spawns
18//!   publish their ports on the *host's* daemon — reachable from inside
19//!   fakecloud's container as `host.docker.internal:<port>`, not
20//!   `127.0.0.1:<port>`.
21
22/// Actionable remediation appended to every error raised when a container
23/// runtime (Docker/Podman) is required for an operation but none is
24/// available. Kept in one place so RDS, Lambda, ECS, and the server startup
25/// banner all surface the same fix steps and can't drift apart.
26pub const CONTAINER_RUNTIME_HINT: &str = "Install and start Docker or Podman, or set FAKECLOUD_CONTAINER_CLI to your container CLI path.";
27
28/// Auto-detect an available container CLI. Honors `FAKECLOUD_CONTAINER_CLI`
29/// as an explicit override (returns `None` if the override doesn't work),
30/// otherwise prefers `docker` then `podman`. Returns `None` when neither
31/// is usable.
32pub fn detect_container_cli() -> Option<String> {
33    if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
34        return if cli_available(&cli) { Some(cli) } else { None };
35    }
36    if cli_available("docker") {
37        Some("docker".to_string())
38    } else if cli_available("podman") {
39        Some("podman".to_string())
40    } else {
41        None
42    }
43}
44
45/// How long to wait for `<cli> info` before giving up and treating the
46/// runtime as unavailable. A healthy daemon answers in well under a second;
47/// an unreachable or wedged daemon (stale `DOCKER_HOST`, Docker Desktop mid
48/// start, a broken socket) can leave the CLI blocked on connect *forever*,
49/// which would hang fakecloud startup and the test harness. Bounding the
50/// probe turns "daemon wedged" into "no runtime detected" instead of a hang.
51pub const CLI_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
52
53/// Process-global memo of `<cli> info` results, keyed by CLI name/path.
54///
55/// Container-runtime liveness is fixed for the life of a process, but every
56/// service runtime (Lambda, ECS, RDS, ElastiCache, EC2, MQ, MSK, ...) probes
57/// it independently at startup — a dozen-plus `detect_container_cli()` calls.
58/// Without a memo each probe re-runs `docker info`; when the daemon is wedged
59/// (see [`CLI_PROBE_TIMEOUT`]) those probes are serial 10s hangs that stack
60/// into minutes, wedging server startup and the conformance `*_probe` tests.
61/// Caching the first answer collapses that to a single probe.
62static CLI_AVAILABLE_CACHE: std::sync::OnceLock<
63    std::sync::Mutex<std::collections::HashMap<String, bool>>,
64> = std::sync::OnceLock::new();
65
66/// True when the CLI responds to `<cli> info` with success within
67/// [`CLI_PROBE_TIMEOUT`] — the same liveness probe every runtime used before
68/// this module existed, but bounded so an unreachable daemon can't hang the
69/// caller indefinitely (the CLI blocks on connect with no timeout of its own),
70/// and memoized per process so a dozen runtimes probing at startup don't each
71/// pay that bound.
72pub fn cli_available(cli: &str) -> bool {
73    let cache =
74        CLI_AVAILABLE_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
75    if let Some(&cached) = cache.lock().unwrap().get(cli) {
76        return cached;
77    }
78    let result = probe_cli(cli);
79    cache.lock().unwrap().insert(cli.to_string(), result);
80    result
81}
82
83/// Run the bounded `<cli> info` liveness probe once (uncached).
84fn probe_cli(cli: &str) -> bool {
85    let child = spawn_bounded(
86        std::process::Command::new(cli)
87            .arg("info")
88            .stdout(std::process::Stdio::null())
89            .stderr(std::process::Stdio::null()),
90    );
91    let Ok(mut child) = child else {
92        return false;
93    };
94    wait_bounded_group(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false)
95}
96
97/// Spawn a container-CLI command in a process group of its own (Unix), so a
98/// timed-out call can be torn down whole. `FAKECLOUD_CONTAINER_CLI` is
99/// routinely a wrapper -- `sh -c 'exec docker "$@"'`, a `podman-remote` shim --
100/// which makes the real command a *grandchild*: it survives `Child::kill`, goes
101/// on holding whatever pipes we handed it, and keeps running against a wedged
102/// daemon forever. Its own group makes it reachable by a single signal.
103/// Detaching these from terminal job control is fine: their lifetime is managed
104/// by deadline here, not by the shell fakecloud was started from.
105///
106/// stdin is /dev/null, and has to be: a new process group is a *background*
107/// one, so a child that reads the controlling terminal -- a `sudo` or
108/// credential-helper wrapper prompting, exactly the wrapper case above -- takes
109/// SIGTTIN, which stops it rather than ending it. `try_wait` is WNOHANG without
110/// WUNTRACED, so the loop below never sees a stopped child and the call burns
111/// the whole deadline before being killed. These calls are non-interactive
112/// anyway, so an immediate EOF is the right answer for them.
113fn spawn_bounded(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
114    cmd.stdin(std::process::Stdio::null());
115    #[cfg(unix)]
116    {
117        std::os::unix::process::CommandExt::process_group(cmd, 0);
118    }
119    cmd.spawn()
120}
121
122/// Wait for `child` up to [`CLI_PROBE_TIMEOUT`], killing it on expiry. Returns
123/// whether it exited on its own. Every container-CLI call goes through this:
124/// a liveness probe answering does not promise the next call will, and an
125/// unbounded one blocks the caller rather than just that command.
126///
127/// Only for a child from [`spawn_bounded`], which put it in a group of its own:
128/// the expiry kill hits that whole group, so a wrapper CLI's grandchildren die
129/// with it.
130fn wait_bounded_group(child: &mut std::process::Child) -> bool {
131    let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
132    loop {
133        match child.try_wait() {
134            Ok(Some(_)) => return true,
135            Ok(None) => {}
136            Err(_) => return false,
137        }
138        if std::time::Instant::now() >= deadline {
139            // Daemon is wedged: kill the blocked call and report failure.
140            kill_expired(child);
141            let _ = child.wait();
142            return false;
143        }
144        std::thread::sleep(std::time::Duration::from_millis(25));
145    }
146}
147
148/// SIGKILL a timed-out child and its process group. [`spawn_bounded`] made the
149/// child its own group leader, so the group id is the child's pid, and the
150/// child is still unreaped here -- the pid cannot have been recycled and the
151/// signal cannot stray onto an unrelated group.
152#[cfg(unix)]
153fn kill_expired(child: &mut std::process::Child) {
154    // SAFETY: `kill` with a negative pid targets the process group of that
155    // id; any pid value is safe to pass.
156    let _ = unsafe { libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL) };
157    let _ = child.kill();
158}
159
160/// Windows has no process-group signal (a job object would be needed), so the
161/// direct child is as far as the kill reaches; [`run_bounded`] still bounds the
162/// wait on its stdout reader so the caller can't be held by a surviving
163/// grandchild.
164#[cfg(not(unix))]
165fn kill_expired(child: &mut std::process::Child) {
166    let _ = child.kill();
167}
168
169/// Whether the stdout reader thread ended before the call returned.
170#[derive(Debug)]
171enum ReaderState {
172    /// The reader returned; its thread is gone.
173    Finished,
174    /// The reader is still blocked on the pipe because a write end we could not
175    /// close is held outside the child's process group. The thread outlives the
176    /// call; the caller does not wait for it.
177    Abandoned,
178}
179
180/// Floor on how long [`run_bounded`] waits for its stdout reader once the call
181/// is over (it also gets whatever is left of the call's own budget). Both exits
182/// close every write end we control -- the child exited, or its whole process
183/// group was killed -- which ends the blocked `read_to_end` at once, so this
184/// covers scheduling only. It exists so a write end held somewhere we cannot
185/// reach costs the caller a few hundred milliseconds instead of blocking it for
186/// good, which is what an unbounded join did.
187const READER_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
188
189/// Run a container-CLI command and return its stdout, or `None` when it fails
190/// or outruns [`CLI_PROBE_TIMEOUT`].
191pub fn bounded_output(cli: &str, args: &[&str]) -> Option<String> {
192    run_bounded(cli, args).0
193}
194
195/// [`bounded_output`], plus whether its stdout reader finished -- so the
196/// timeout path's "no reader left behind" guarantee is unit-testable instead of
197/// only observable as a thread that never goes away.
198fn run_bounded(cli: &str, args: &[&str]) -> (Option<String>, ReaderState) {
199    let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
200    let child = spawn_bounded(
201        std::process::Command::new(cli)
202            .args(args)
203            .stdout(std::process::Stdio::piped())
204            .stderr(std::process::Stdio::null()),
205    );
206    let Ok(mut child) = child else {
207        return (None, ReaderState::Finished);
208    };
209    let Some(mut stdout) = child.stdout.take() else {
210        kill_expired(&mut child);
211        let _ = child.wait();
212        return (None, ReaderState::Finished);
213    };
214    // Drain stdout while waiting. A child whose output outgrows the pipe
215    // buffer blocks on write until someone reads it, so waiting for exit
216    // first would deadlock until the deadline and then report the sweep as
217    // failed -- `docker ps -a` across a busy host is exactly that much output.
218    //
219    // The channel doubles as the reader's "I'm done" signal: the send is the
220    // last thing the thread does before dropping the pipe's read end, so a
221    // received buffer proves no reader is parked behind us. A `JoinHandle`
222    // can't say that without blocking, which on the timeout path is exactly
223    // what we must not do.
224    let (tx, rx) = std::sync::mpsc::channel();
225    std::thread::spawn(move || {
226        let mut buf = Vec::new();
227        let _ = std::io::Read::read_to_end(&mut stdout, &mut buf);
228        let _ = tx.send(buf);
229    });
230    // On expiry `wait_bounded_group` has killed the whole process group, so a
231    // wrapper CLI's grandchild releases the write end and the reader returns
232    // instead of blocking for the life of the process -- one leaked thread per
233    // call, on precisely the wedged-daemon path these bounds exist for.
234    let exited = wait_bounded_group(&mut child);
235    let status = child.wait().ok();
236    // Whatever is left of the call's own budget, and never less than the grace:
237    // a prompt call can afford to wait out a reader thread the scheduler hasn't
238    // run yet, a timed-out one gets only the grace, and either way the caller is
239    // back within CLI_PROBE_TIMEOUT plus that grace.
240    let grace = deadline
241        .saturating_duration_since(std::time::Instant::now())
242        .max(READER_DRAIN_GRACE);
243    let drained = rx.recv_timeout(grace).ok();
244    let output = match (exited, status, &drained) {
245        (true, Some(status), Some(buf)) if status.success() => {
246            Some(String::from_utf8_lossy(buf).into_owned())
247        }
248        _ => None,
249    };
250    let reader = if drained.is_some() {
251        ReaderState::Finished
252    } else {
253        ReaderState::Abandoned
254    };
255    (output, reader)
256}
257
258/// Run a container-CLI command for its effect only, bounded the same way.
259/// Returns whether it succeeded.
260pub fn bounded_status(cli: &str, args: &[&str]) -> bool {
261    let Ok(mut child) = spawn_bounded(
262        std::process::Command::new(cli)
263            .args(args)
264            .stdout(std::process::Stdio::null())
265            .stderr(std::process::Stdio::null()),
266    ) else {
267        return false;
268    };
269    wait_bounded_group(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false)
270}
271
272/// Why [`output_within`] produced no output.
273#[derive(Debug, thiserror::Error)]
274pub enum CliRunError {
275    /// The command could not be started.
276    #[error("could not run: {0}")]
277    Spawn(std::io::Error),
278    /// Waiting for the command or reading its output failed.
279    #[error("failed while running: {0}")]
280    Wait(std::io::Error),
281    /// The command outran its budget and was killed with its process group.
282    #[error("did not finish within {}s and was killed", .0.as_secs_f64())]
283    TimedOut(std::time::Duration),
284}
285
286/// The async counterpart of [`bounded_output`] for container-CLI calls that
287/// need a budget of their own: run `cmd` to completion and return its full
288/// output, or kill it once it outruns `timeout`.
289///
290/// Like the blocking helpers, the command reads stdin from `/dev/null` and
291/// runs in a process group of its own, and that whole group is SIGKILLed when
292/// the call expires -- and equally when the returned future is dropped before
293/// it finishes (an outer timeout, a `select!` that went the other way, an
294/// aborted task, a cancelled request). A wrapper CLI's grandchild (podman's
295/// build container, a `sudo` shim) dies with it instead of holding the output
296/// pipes, or a stalled registry download, for the life of the process. stdout
297/// and stderr are captured.
298pub async fn output_within(
299    cmd: &mut tokio::process::Command,
300    timeout: std::time::Duration,
301) -> Result<std::process::Output, CliRunError> {
302    cmd.stdin(std::process::Stdio::null())
303        .stdout(std::process::Stdio::piped())
304        .stderr(std::process::Stdio::piped())
305        .kill_on_drop(true);
306    #[cfg(unix)]
307    cmd.process_group(0);
308    let mut child = cmd.spawn().map_err(CliRunError::Spawn)?;
309    // Declared after `child`, so on a drop mid-call it runs first: the group
310    // is signalled while its leader is still unreaped, so the group id (the
311    // child's pid) can't have been recycled. `kill_on_drop` then takes care
312    // of the child itself.
313    let mut group = GroupKill(child.id());
314    let stdout = child.stdout.take();
315    let stderr = child.stderr.take();
316    // Drain both pipes while waiting, as `wait_with_output` does: a child whose
317    // output outgrows the pipe buffer would otherwise block on write and look
318    // wedged. The child stays owned here so the timeout path can still signal
319    // its group before it is reaped.
320    let run = async {
321        let (status, out, err) = tokio::join!(child.wait(), drain(stdout), drain(stderr));
322        Ok(std::process::Output {
323            status: status.map_err(CliRunError::Wait)?,
324            stdout: out.map_err(CliRunError::Wait)?,
325            stderr: err.map_err(CliRunError::Wait)?,
326        })
327    };
328    let finished = tokio::time::timeout(timeout, run).await;
329    match finished {
330        Ok(out) => {
331            // Ran to completion: whatever is left in its group is not ours to
332            // kill (a CLI may legitimately leave a detached helper behind).
333            group.disarm();
334            out
335        }
336        Err(_) => {
337            group.fire();
338            let _ = child.start_kill();
339            let _ = child.wait().await;
340            Err(CliRunError::TimedOut(timeout))
341        }
342    }
343}
344
345/// SIGKILLs the process group led by the held pid when dropped (or fired),
346/// unless disarmed: the group id is the child's pid because
347/// [`output_within`] makes it a group leader, so this reaches the
348/// grandchildren a direct kill of the child can't.
349struct GroupKill(Option<u32>);
350
351impl GroupKill {
352    fn disarm(&mut self) {
353        self.0 = None;
354    }
355
356    fn fire(&mut self) {
357        #[cfg(unix)]
358        if let Some(pid) = self.0.take() {
359            // SAFETY: `kill` with a negative pid targets that process group;
360            // any value is safe to pass.
361            let _ = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
362        }
363        self.0 = None;
364    }
365}
366
367impl Drop for GroupKill {
368    fn drop(&mut self) {
369        self.fire();
370    }
371}
372
373async fn drain<R: tokio::io::AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>> {
374    let mut buf = Vec::new();
375    if let Some(mut pipe) = pipe {
376        tokio::io::AsyncReadExt::read_to_end(&mut pipe, &mut buf).await?;
377    }
378    Ok(buf)
379}
380
381/// True if the given PID is a live process on this host.
382///
383/// On Unix this is `kill(pid, 0)`: it returns 0 if the process exists
384/// (including zombies), or sets `errno` to `ESRCH` if not. On non-Unix
385/// platforms it conservatively returns `true`, so a caller never removes a
386/// resource it can't prove is orphaned.
387#[cfg(unix)]
388pub fn pid_alive(pid: u32) -> bool {
389    // A value that is not a positive pid_t names no process: cast as is, 0
390    // would probe our own process group and anything above i32::MAX a
391    // negative one (`kill(-pgid, 0)`), answering for a group, not a process.
392    let Ok(pid) = libc::pid_t::try_from(pid) else {
393        return false;
394    };
395    if pid <= 0 {
396        return false;
397    }
398    // SAFETY: `kill` with signal 0 is a liveness probe; it does not
399    // actually deliver a signal. Any PID value is safe to pass.
400    let rc = unsafe { libc::kill(pid, 0) };
401    if rc == 0 {
402        return true;
403    }
404    // errno == EPERM means the process exists but we can't signal it —
405    // still alive from our perspective.
406    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
407}
408
409#[cfg(not(unix))]
410pub fn pid_alive(_pid: u32) -> bool {
411    true
412}
413
414/// Whether a container or network labelled `fakecloud-instance=<label>` was
415/// left behind by a fakecloud process that is gone. The label is
416/// `fakecloud-<pid>`; an object is orphaned only when that PID is neither the
417/// current process nor alive. Several fakecloud processes can share one
418/// daemon (parallel test servers, side-by-side installs), so an object owned
419/// by *another live* process is never an orphan. A label that doesn't parse is
420/// not treated as an orphan either -- nothing proves its owner is gone.
421pub fn owned_by_dead_process(label: &str, is_alive: impl Fn(u32) -> bool) -> bool {
422    let Some(pid) = label
423        .strip_prefix("fakecloud-")
424        .and_then(|p| p.parse::<u32>().ok())
425    else {
426        return false;
427    };
428    pid != std::process::id() && !is_alive(pid)
429}
430
431/// Which container engine a CLI actually drives. Decides the podman-only
432/// code paths: `host.containers.internal` without `--add-host`, and
433/// `--tls-verify=false` when pulling from fakecloud's plain-HTTP registry.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum ContainerEngine {
436    Docker,
437    Podman,
438}
439
440/// Process-global memo of [`is_podman`] results, keyed by CLI name/path. The
441/// engine behind a CLI is fixed for the life of the process, and the probe
442/// runs a subprocess, so every runtime constructor and image pull after the
443/// first reads the answer from here.
444static PODMAN_CACHE: std::sync::OnceLock<
445    std::sync::Mutex<std::collections::HashMap<String, bool>>,
446> = std::sync::OnceLock::new();
447
448fn podman_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, bool>> {
449    PODMAN_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
450}
451
452/// True when `cli` drives podman -- including podman installed *as* `docker`.
453///
454/// The file name alone can't answer that: the `podman-docker` package
455/// (Fedora, RHEL, CentOS Stream, ...) ships a `docker` shim that execs podman,
456/// and a user may point `FAKECLOUD_CONTAINER_CLI` at any wrapper. So:
457///
458/// 1. fast path: a name containing `podman` (`podman`, `podman-remote`,
459///    `/opt/homebrew/bin/podman`) is podman without running anything;
460/// 2. otherwise ask the CLI: `<cli> --version` prints `podman version X.Y.Z`
461///    through the shim and `Docker version X.Y.Z, build ...` for Docker;
462/// 3. when that answers with something neither (a custom wrapper), look for
463///    podman-only fields in `<cli> info`.
464///
465/// Every call is bounded by [`CLI_PROBE_TIMEOUT`] (a wedged CLI costs one
466/// bound, then reads as Docker, the historical default) and the answer is
467/// memoized per CLI. Blocking: from async code use [`is_podman_async`].
468pub fn is_podman(cli: &str) -> bool {
469    if name_indicates_podman(cli) {
470        return true;
471    }
472    if let Some(&cached) = podman_cache().lock().unwrap().get(cli) {
473        return cached;
474    }
475    let podman = probe_engine(cli) == Some(ContainerEngine::Podman);
476    podman_cache()
477        .lock()
478        .unwrap()
479        .insert(cli.to_string(), podman);
480    podman
481}
482
483/// [`is_podman`] for async callers: a cached (or name-matched) answer returns
484/// at once, and a first-time probe runs on the blocking pool so it can't stall
485/// a runtime worker for up to [`CLI_PROBE_TIMEOUT`].
486pub async fn is_podman_async(cli: &str) -> bool {
487    if name_indicates_podman(cli) {
488        return true;
489    }
490    if let Some(&cached) = podman_cache().lock().unwrap().get(cli) {
491        return cached;
492    }
493    let owned = cli.to_string();
494    tokio::task::spawn_blocking(move || is_podman(&owned))
495        .await
496        .unwrap_or(false)
497}
498
499/// The name-only fast path: the file name component contains `podman`, so
500/// absolute paths and `podman-remote` register. Docker's CLI and the
501/// `podman-docker` shim are both named `docker` and fall through to the probe.
502fn name_indicates_podman(cli: &str) -> bool {
503    std::path::Path::new(cli)
504        .file_name()
505        .and_then(|n| n.to_str())
506        .map(|n| n.contains("podman"))
507        .unwrap_or(false)
508}
509
510/// Ask the CLI what it is (uncached). `None` when it can't tell -- the CLI
511/// failed, timed out, or answered with neither engine's markers.
512fn probe_engine(cli: &str) -> Option<ContainerEngine> {
513    // A failed or timed-out `--version` means the CLI isn't answering at
514    // all; don't spend a second bound on `info` against it.
515    let version = bounded_output(cli, &["--version"])?;
516    classify_version_output(&version).or_else(|| {
517        bounded_output(cli, &["info", "--format", "{{json .}}"])
518            .and_then(|info| classify_info_output(&info))
519    })
520}
521
522/// Classify `<cli> --version` output. Podman prints `podman version 5.2.0`
523/// (also through the `podman-docker` shim), Docker `Docker version 27.3.1,
524/// build ce12230`. Podman is checked first: the shim is podman whatever else
525/// the output mentions.
526pub fn classify_version_output(stdout: &str) -> Option<ContainerEngine> {
527    let lower = stdout.to_ascii_lowercase();
528    if lower.contains("podman") {
529        Some(ContainerEngine::Podman)
530    } else if lower.contains("docker") {
531        Some(ContainerEngine::Docker)
532    } else {
533        None
534    }
535}
536
537/// Classify `<cli> info --format '{{json .}}'` output by engine-specific
538/// fields: podman's host section carries `buildahVersion` / `ociRuntime`
539/// (camelCase), Docker's top level `ServerVersion`.
540pub fn classify_info_output(stdout: &str) -> Option<ContainerEngine> {
541    if stdout.contains("\"buildahVersion\"") || stdout.contains("\"ociRuntime\"") {
542        Some(ContainerEngine::Podman)
543    } else if stdout.contains("\"ServerVersion\"") {
544        Some(ContainerEngine::Docker)
545    } else {
546        None
547    }
548}
549
550/// Detect the Docker bridge gateway IP on Linux. Returns `None` if
551/// detection fails (caller falls back to the conventional `172.17.0.1`).
552///
553/// Goes through [`bounded_output`] like every other container-CLI call here:
554/// `network inspect` talks to the same daemon as the liveness probe, so a
555/// wedged one blocks it on connect forever. This runs inside runtime
556/// constructors on Linux, where an unbounded call hangs server startup outright
557/// -- the exact failure [`CLI_PROBE_TIMEOUT`] exists to prevent. On timeout the
558/// caller just takes the conventional fallback.
559pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
560    let stdout = bounded_output(
561        cli,
562        &[
563            "network",
564            "inspect",
565            "bridge",
566            "--format",
567            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
568        ],
569    )?;
570    let gateway = stdout.trim().to_string();
571    if gateway.is_empty() || !gateway.contains('.') {
572        return None;
573    }
574    Some(gateway)
575}
576
577/// Resolved container-to-host networking for a given CLI. Built once at
578/// runtime construction and reused for every container spawn.
579#[derive(Debug, Clone)]
580pub struct HostNetworking {
581    /// DNS name a spawned container uses to reach fakecloud on the host.
582    /// `host.containers.internal` for podman, `host.docker.internal` for
583    /// docker.
584    pub host_alias: String,
585    /// `<alias>:<value>` argument for `--add-host`, injected into every
586    /// container `create`/`run`. `None` when the runtime provides the
587    /// alias natively (podman).
588    pub add_host_arg: Option<String>,
589    /// Address fakecloud uses to reach the *sibling* containers it just
590    /// spawned (readiness probes + advertised endpoints). `127.0.0.1`
591    /// when fakecloud runs on the host; `host.docker.internal` when
592    /// fakecloud is itself containerized (`FAKECLOUD_IN_CONTAINER=1`).
593    pub sibling_host: String,
594}
595
596impl HostNetworking {
597    /// Resolve networking for `cli`, reading `FAKECLOUD_IN_CONTAINER` from
598    /// the process environment.
599    pub fn detect(cli: &str) -> Self {
600        let (host_alias, mut add_host_arg) = resolve_host_alias(cli);
601        // A resolving `host.docker.internal` is only trustworthy evidence that
602        // the runtime provides the alias natively (and will inject it into
603        // sibling containers too) when fakecloud is itself containerized:
604        // Docker-Desktop-class runtimes inject the alias into CONTAINERS, never
605        // onto the host. On a bare native-Linux host a resolving alias is
606        // spurious (a hijacking NXDOMAIN resolver, a stray /etc/hosts entry, or
607        // a wildcard search domain), so suppressing the bridge --add-host there
608        // would break the host route sibling containers need. Gate the
609        // suppression on the in-container signal to avoid that regression.
610        let in_container = in_container_mode(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
611        add_host_arg = preserve_native_host_alias(
612            add_host_arg,
613            in_container && host_alias_resolves(&host_alias),
614        );
615        let sibling_host =
616            resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
617        Self {
618            host_alias,
619            add_host_arg,
620            sibling_host,
621        }
622    }
623
624    /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
625    /// growing argv vector when this runtime needs an explicit mapping.
626    /// No-op for podman.
627    pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
628        if let Some(arg) = &self.add_host_arg {
629            argv.push("--add-host".to_string());
630            argv.push(arg.clone());
631        }
632    }
633}
634
635/// How long to wait for the blocking `getaddrinfo` in [`host_alias_resolves`]
636/// before giving up and returning `false`. `getaddrinfo` has no timeout of its
637/// own, and a slow or unreachable DNS server would otherwise block a runtime
638/// thread at startup (this runs inside runtime constructors under
639/// `#[tokio::main]`). Bounding it — same tradeoff as [`CLI_PROBE_TIMEOUT`] —
640/// turns "DNS wedged" into "alias doesn't resolve", the safe default that keeps
641/// the `--add-host` bridge mapping.
642pub const HOST_ALIAS_RESOLVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
643
644/// True when `host_alias` resolves via the process resolver. The `getaddrinfo`
645/// call is blocking with no timeout of its own, so it runs on a spawned thread
646/// bounded by [`HOST_ALIAS_RESOLVE_TIMEOUT`]; on timeout we return `false` (the
647/// safe default that keeps `--add-host`). A leaked resolver thread on timeout
648/// is acceptable — same tradeoff as [`probe_cli`].
649fn host_alias_resolves(host_alias: &str) -> bool {
650    let (tx, rx) = std::sync::mpsc::channel();
651    let alias = host_alias.to_string();
652    std::thread::spawn(move || {
653        let resolves = std::net::ToSocketAddrs::to_socket_addrs(&(alias.as_str(), 0)).is_ok();
654        let _ = tx.send(resolves);
655    });
656    rx.recv_timeout(HOST_ALIAS_RESOLVE_TIMEOUT).unwrap_or(false)
657}
658
659fn preserve_native_host_alias(
660    add_host_arg: Option<String>,
661    should_suppress: bool,
662) -> Option<String> {
663    if add_host_arg.is_some() && should_suppress {
664        // Suppress the injected `--add-host host.docker.internal:<vm-bridge-ip>`
665        // only when fakecloud is containerized AND the alias already resolves
666        // (see the gate in `detect`). In that case a Docker-Desktop-class
667        // runtime provides `host.docker.internal` natively inside every sibling
668        // container, pointing at the real host; injecting the VM bridge-gateway
669        // IP would shadow it and break the host route. On a bare host — where a
670        // hijacking resolver can make the alias resolve spuriously — the caller
671        // passes `false` here so native Linux docker keeps the bridge mapping
672        // it genuinely needs.
673        None
674    } else {
675        add_host_arg
676    }
677}
678
679/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
680/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
681/// podman branches are unit-testable without a daemon.
682pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
683    if is_podman(cli) {
684        // Podman provides `host.containers.internal` natively on every
685        // supported platform; injecting `host-gateway` on macOS fails
686        // because rootless podman's gvproxy doesn't expose the magic alias.
687        ("host.containers.internal".to_string(), None)
688    } else if cfg!(target_os = "linux") {
689        // Bare docker on Linux: resolve the bridge gateway IP and add an
690        // explicit alias. `host.docker.internal:host-gateway` only works
691        // on Docker Desktop; native Linux docker has no such magic.
692        let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
693        (
694            "host.docker.internal".to_string(),
695            Some(format!("host.docker.internal:{ip}")),
696        )
697    } else {
698        // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
699        // that resolves to the host's IP.
700        (
701            "host.docker.internal".to_string(),
702            Some("host.docker.internal:host-gateway".to_string()),
703        )
704    }
705}
706
707/// Decide what address fakecloud uses to reach the sibling containers it
708/// just spawned. Pure helper so the env-var parsing can be tested without
709/// touching the process's real environment.
710///
711/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
712///   container; the siblings publish their ports on the host's daemon and
713///   are reachable at the same host alias the spawned containers use to
714///   reach fakecloud — `host.docker.internal` under docker,
715///   `host.containers.internal` under podman. Hardcoding
716///   `host.docker.internal` here broke podman, whose gvproxy network only
717///   resolves `host.containers.internal` (issue #1539 follow-up).
718/// - anything else, including `None` -> fakecloud runs on the host,
719///   siblings live on `127.0.0.1:<port>`.
720pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
721    if in_container_mode(env_value) {
722        host_alias.to_string()
723    } else {
724        "127.0.0.1".to_string()
725    }
726}
727
728/// Parse the `FAKECLOUD_IN_CONTAINER` signal: `Some("1")` or a case-insensitive
729/// `Some("true")` mean fakecloud is running inside a container; anything else,
730/// including `None`, means it runs on the host. Single source of truth for the
731/// parse so `detect`'s native-alias gate and `resolve_sibling_host` can't drift.
732fn in_container_mode(env_value: Option<String>) -> bool {
733    env_value
734        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
735        .unwrap_or(false)
736}
737
738/// Hostnames fakecloud's bundled ECR/OCI registry can be addressed from a
739/// sibling container or the host, each at `server_port`.
740///
741/// A container-spawning service rewrites the image pull URI to the runtime's
742/// sibling host -- `host.docker.internal` under Docker, `host.containers.internal`
743/// under podman -- or leaves it `localhost` / `127.0.0.1` when fakecloud runs on
744/// the host (`localhost:<port>` is the documented local ECR endpoint, e.g.
745/// `localhost:4566`). The registry enforces auth, and the Docker/Podman CLI only
746/// attaches the `Authorization` header for hosts present in `config.json`, so the
747/// isolated pull config must list *every* alias or the pull gets a 401. The map
748/// previously omitted the podman alias, so image-based Lambda/ECS pulls failed
749/// under podman-in-a-container (bug-audit 2026-06-20, 0.B2). Authorize all of
750/// them with the same credential; centralized here so the two builders can't
751/// drift again.
752///
753/// A `FAKECLOUD_ECR_REGISTRY_HOST` override is the host the pull URI is
754/// rewritten to (see [`ecr_registry_host`]), so it is authorized as well.
755pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
756    registry_auth_hosts_with(
757        server_port,
758        std::env::var("FAKECLOUD_ECR_REGISTRY_HOST").ok(),
759    )
760}
761
762/// Pure half of [`registry_auth_hosts`]: the built-in aliases plus a
763/// non-blank registry-host override.
764pub fn registry_auth_hosts_with(server_port: u16, override_host: Option<String>) -> Vec<String> {
765    let mut hosts: Vec<String> = [
766        "localhost",
767        "127.0.0.1",
768        "host.docker.internal",
769        "host.containers.internal",
770    ]
771    .iter()
772    .map(|h| h.to_string())
773    .collect();
774    if let Some(h) = override_host
775        .map(|v| v.trim().to_string())
776        .filter(|v| !v.is_empty())
777    {
778        if !hosts.contains(&h) {
779            hosts.push(h);
780        }
781    }
782    hosts
783        .iter()
784        .map(|host| format!("{host}:{server_port}"))
785        .collect()
786}
787
788/// Host the container engine pulls fakecloud-ECR images from.
789///
790/// The pull is performed by the engine on the *host*, not by a sibling
791/// container, so the sibling host alias is the wrong address for it: Docker
792/// Desktop and OrbStack only accept a plain-HTTP registry over loopback, and
793/// `host.docker.internal` does not resolve on a Linux host at all. Defaults to
794/// `127.0.0.1`. Podman on macOS / Windows pulls inside its machine VM, where
795/// loopback is the VM and only `host.containers.internal` reaches the host.
796/// `FAKECLOUD_ECR_REGISTRY_HOST` overrides both (e.g. when a containerized
797/// fakecloud's port is published under another address).
798pub fn ecr_registry_host(cli: &str) -> String {
799    resolve_ecr_registry_host(
800        std::env::var("FAKECLOUD_ECR_REGISTRY_HOST").ok(),
801        is_podman(cli),
802        cfg!(target_os = "linux"),
803    )
804}
805
806/// Pure half of [`ecr_registry_host`].
807pub fn resolve_ecr_registry_host(env_value: Option<String>, podman: bool, linux: bool) -> String {
808    if let Some(v) = env_value
809        .map(|v| v.trim().to_string())
810        .filter(|v| !v.is_empty())
811    {
812        return v;
813    }
814    if podman && !linux {
815        "host.containers.internal".to_string()
816    } else {
817        "127.0.0.1".to_string()
818    }
819}
820
821/// Host port from `<cli> port <container> <port>` output. Docker prints one
822/// `<ip>:<port>` line per bound address family (`0.0.0.0:49153`,
823/// `[::]:49153`); podman prints the same shape. The first parseable port wins.
824pub fn parse_published_port(output: &str) -> Option<u16> {
825    output
826        .lines()
827        .filter_map(|l| l.trim().rsplit(':').next())
828        .find_map(|p| p.parse::<u16>().ok())
829}
830
831const LOOPBACK_NAMES: [&str; 2] = ["127.0.0.1", "localhost"];
832
833/// Rewrite loopback references in an environment value a sibling container
834/// will read, so they reach the host instead of the container itself.
835///
836/// Inside a container `127.0.0.1` / `localhost` is the container, so every
837/// endpoint fakecloud hands out on loopback -- the server URL, an RDS or
838/// ElastiCache endpoint address, an MSK bootstrap string -- has to name
839/// `target_host` (the host alias) instead. Rewritten:
840///
841/// - the whole value being a loopback host (`DB_HOST=127.0.0.1`);
842/// - a loopback host followed by `:<port>`, at a token boundary -- in URLs
843///   with or without userinfo (`postgres://u:p@localhost:5432/db`), bare
844///   `host:port` values and comma-separated lists
845///   (`127.0.0.1:9092,127.0.0.1:9094`);
846/// - a URL host without a port (`http://localhost/path`).
847///
848/// A port a service registered with
849/// [`crate::dataplane::register_container_port`] is swapped for its
850/// container-view port. Prose such as `EHLO localhost`, and names that merely
851/// contain a loopback name (`127.0.0.10`, `localhost.localdomain`), are left
852/// alone. When `target_host` is itself loopback (fakecloud and the workload
853/// share a network namespace) the value is returned unchanged.
854pub fn rewrite_loopback_value(value: &str, target_host: &str) -> String {
855    if LOOPBACK_NAMES.contains(&target_host) {
856        return value.to_string();
857    }
858    if LOOPBACK_NAMES.contains(&value.trim()) {
859        return value.replacen(value.trim(), target_host, 1);
860    }
861    let bytes = value.as_bytes();
862    let mut out = String::with_capacity(value.len());
863    let mut i = 0;
864    while i < value.len() {
865        let hit = LOOPBACK_NAMES
866            .iter()
867            .find(|name| value[i..].starts_with(**name) && loopback_at(value, i, name.len()));
868        let Some(name) = hit else {
869            let ch = value[i..].chars().next().unwrap_or_default();
870            out.push(ch);
871            i += ch.len_utf8().max(1);
872            continue;
873        };
874        out.push_str(target_host);
875        i += name.len();
876        // Swap a registered host port for its container-view port.
877        if bytes.get(i) == Some(&b':') {
878            let digits: String = value[i + 1..]
879                .chars()
880                .take_while(|c| c.is_ascii_digit())
881                .collect();
882            if let Some(mapped) = digits
883                .parse::<u16>()
884                .ok()
885                .and_then(crate::dataplane::container_port_for)
886            {
887                out.push(':');
888                out.push_str(&mapped.to_string());
889                i += 1 + digits.len();
890            }
891        }
892    }
893    out
894}
895
896/// Whether the loopback name at `value[i..i + len]` is a host reference: at a
897/// token boundary and followed by `:<digit>`, or a URL host (after `//` or
898/// `@`) followed by the end of the authority.
899fn loopback_at(value: &str, i: usize, len: usize) -> bool {
900    let before = value[..i].chars().next_back();
901    let boundary_before = match before {
902        None => true,
903        Some(c) => matches!(c, '/' | '@' | ',' | '=' | ';' | '(' | '"' | '\'') || c.is_whitespace(),
904    };
905    if !boundary_before {
906        return false;
907    }
908    let rest = &value[i + len..];
909    let mut after = rest.chars();
910    match after.next() {
911        Some(':') => after.next().is_some_and(|c| c.is_ascii_digit()),
912        next => {
913            let url_host = matches!(before, Some('@')) || value[..i].ends_with("//");
914            url_host && matches!(next, None | Some('/') | Some('?') | Some('#'))
915        }
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    #[test]
924    fn cli_available_false_for_missing_binary() {
925        // A binary that doesn't exist fails to spawn -> unavailable, fast.
926        assert!(!cli_available("definitely-not-a-real-cli-binary-xyz-123"));
927    }
928
929    #[cfg(unix)]
930    #[test]
931    fn cli_available_bounds_a_hanging_probe() {
932        // A CLI whose `info` invocation blocks forever (like `docker info`
933        // against an unreachable daemon) must not hang the caller: the probe
934        // is killed at CLI_PROBE_TIMEOUT and reported unavailable. Regression
935        // test for the local-conformance-probe hang.
936        let (_dir, script) = fake_cli("hangcli", "[ \"$1\" = warmup ] && exit 0\nsleep 600\n");
937
938        let start = std::time::Instant::now();
939        let available = cli_available(&script);
940        let elapsed = start.elapsed();
941
942        assert!(!available, "a hanging probe must report unavailable");
943        assert!(
944            elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
945            "probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
946        );
947    }
948
949    #[test]
950    fn registry_auth_hosts_includes_podman_alias() {
951        // The podman sibling alias (host.containers.internal) must be authorized
952        // or image-based Lambda/ECS pulls 401 under podman-in-a-container (0.B2).
953        let hosts = registry_auth_hosts(4566);
954        assert!(hosts.contains(&"localhost:4566".to_string()));
955        assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
956        assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
957        assert!(
958            hosts.contains(&"host.containers.internal:4566".to_string()),
959            "podman sibling alias must be authorized: {hosts:?}"
960        );
961    }
962
963    #[test]
964    fn name_fast_path_matches_podman_names() {
965        assert!(name_indicates_podman("podman"));
966        assert!(name_indicates_podman("podman-remote"));
967        assert!(name_indicates_podman("/opt/homebrew/bin/podman"));
968        assert!(name_indicates_podman("/usr/local/bin/podman-remote"));
969        assert!(!name_indicates_podman("docker"));
970        assert!(!name_indicates_podman("/usr/local/bin/docker"));
971        assert!(!name_indicates_podman("docker-credential-helper"));
972    }
973
974    #[test]
975    fn name_fast_path_needs_no_probe() {
976        // No binary by this name exists: a podman-named CLI is podman without
977        // running anything.
978        assert!(is_podman("/nonexistent-dir-fc-2599/podman"));
979        assert!(is_podman("podman-remote-definitely-missing-xyz"));
980    }
981
982    #[test]
983    fn version_output_classifies_the_engine() {
984        assert_eq!(
985            classify_version_output("podman version 5.2.0\n"),
986            Some(ContainerEngine::Podman)
987        );
988        assert_eq!(
989            classify_version_output("Docker version 27.3.1, build ce12230\n"),
990            Some(ContainerEngine::Docker)
991        );
992        // The podman-docker shim's notice mentions Docker; it is still podman.
993        assert_eq!(
994            classify_version_output(
995                "Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.\npodman version 4.9.4\n"
996            ),
997            Some(ContainerEngine::Podman)
998        );
999        assert_eq!(classify_version_output("my-wrapper 1.0\n"), None);
1000        assert_eq!(classify_version_output(""), None);
1001    }
1002
1003    #[test]
1004    fn info_output_classifies_the_engine() {
1005        assert_eq!(
1006            classify_info_output(
1007                r#"{"host":{"buildahVersion":"1.37.0","ociRuntime":{"name":"crun"}}}"#
1008            ),
1009            Some(ContainerEngine::Podman)
1010        );
1011        assert_eq!(
1012            classify_info_output(r#"{"ID":"abc","ServerVersion":"27.3.1","Driver":"overlay2"}"#),
1013            Some(ContainerEngine::Docker)
1014        );
1015        assert_eq!(classify_info_output("{}"), None);
1016    }
1017
1018    #[cfg(unix)]
1019    use crate::test_support::fake_cli;
1020
1021    #[cfg(unix)]
1022    #[test]
1023    fn podman_docker_shim_is_detected_as_podman() {
1024        // What `podman-docker` installs: a `docker` that is podman.
1025        let (_dir, cli) = fake_cli(
1026            "docker",
1027            "[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1028        );
1029        assert!(is_podman(&cli));
1030        assert_eq!(probe_engine(&cli), Some(ContainerEngine::Podman));
1031    }
1032
1033    #[cfg(unix)]
1034    #[test]
1035    fn real_docker_cli_is_not_podman() {
1036        let (_dir, cli) = fake_cli(
1037            "docker",
1038            "[ \"$1\" = --version ] && { echo 'Docker version 27.3.1, build ce12230'; exit 0; }\nexit 1\n",
1039        );
1040        assert!(!is_podman(&cli));
1041    }
1042
1043    #[cfg(unix)]
1044    #[test]
1045    fn wrapper_with_opaque_version_falls_back_to_info() {
1046        let (_dir, cli) = fake_cli(
1047            "container-cli",
1048            "case \"$1\" in\n  --version) echo 'wrapper 1.0' ;;\n  info) echo '{\"host\":{\"buildahVersion\":\"1.37.0\"}}' ;;\n  *) exit 1 ;;\nesac\n",
1049        );
1050        assert!(is_podman(&cli));
1051    }
1052
1053    #[cfg(unix)]
1054    #[test]
1055    fn engine_probe_is_cached_per_cli() {
1056        // Each run appends to a log; a second lookup must not run the CLI again.
1057        let (dir, cli) = fake_cli(
1058            "docker",
1059            "echo \"$*\" >> \"$(dirname \"$0\")/calls.log\"\n[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1060        );
1061        let log = dir.path().join("calls.log");
1062        let _ = std::fs::remove_file(&log);
1063        assert!(is_podman(&cli));
1064        assert!(is_podman(&cli));
1065        let calls = std::fs::read_to_string(&log).unwrap_or_default();
1066        assert_eq!(calls.lines().collect::<Vec<_>>(), ["--version"]);
1067    }
1068
1069    #[cfg(unix)]
1070    #[tokio::test]
1071    async fn async_probe_detects_the_shim() {
1072        let (_dir, cli) = fake_cli(
1073            "docker",
1074            "[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1075        );
1076        assert!(is_podman_async(&cli).await);
1077    }
1078
1079    #[cfg(unix)]
1080    #[test]
1081    fn engine_probe_bounds_a_hanging_cli() {
1082        // A CLI that never answers must not hang detection: one bounded call,
1083        // no `info` fallback against it, and it reads as Docker.
1084        // Only the probe's commands hang: `fake_cli`'s warmup run (no such
1085        // argument) must return at once, or it eats the test's time budget.
1086        let (_dir, cli) = fake_cli(
1087            "docker",
1088            "case \"$1\" in --version|info) sleep 600 ;; esac\nexit 1\n",
1089        );
1090        let start = std::time::Instant::now();
1091        assert!(!is_podman(&cli));
1092        let elapsed = start.elapsed();
1093        assert!(
1094            elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
1095            "probe took {elapsed:?}, expected one bound near {CLI_PROBE_TIMEOUT:?}"
1096        );
1097    }
1098
1099    #[test]
1100    fn missing_cli_is_not_podman() {
1101        // `FAKECLOUD_CONTAINER_CLI=false`-style sentinels and missing binaries
1102        // read as not-podman without error.
1103        assert!(!is_podman("definitely-not-a-real-cli-binary-fc-2599"));
1104        assert!(!is_podman("false"));
1105    }
1106
1107    #[cfg(unix)]
1108    #[test]
1109    fn resolve_host_alias_treats_the_shim_as_podman() {
1110        let (_dir, cli) = fake_cli(
1111            "docker",
1112            "[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1113        );
1114        let (alias, add_host) = resolve_host_alias(&cli);
1115        assert_eq!(alias, "host.containers.internal");
1116        assert_eq!(add_host, None);
1117    }
1118
1119    #[test]
1120    fn resolve_host_alias_podman_has_no_add_host() {
1121        let (alias, add_host) = resolve_host_alias("podman");
1122        assert_eq!(alias, "host.containers.internal");
1123        assert_eq!(add_host, None);
1124        let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
1125        assert_eq!(alias, "host.containers.internal");
1126        assert_eq!(add_host, None);
1127    }
1128
1129    #[test]
1130    #[cfg(unix)]
1131    fn resolve_host_alias_docker_emits_add_host() {
1132        // A fake Docker CLI, so a host whose `docker` is the podman-docker shim
1133        // can't flip this test.
1134        let (_dir, cli) = fake_cli(
1135            "docker",
1136            "[ \"$1\" = --version ] && { echo 'Docker version 27.3.1, build ce12230'; exit 0; }\nexit 1\n",
1137        );
1138        let (alias, add_host) = resolve_host_alias(&cli);
1139        assert_eq!(alias, "host.docker.internal");
1140        // On macOS this is host-gateway; on Linux it's a bridge IP. Either
1141        // way docker must get an explicit --add-host.
1142        assert!(add_host.is_some());
1143        assert!(add_host.unwrap().starts_with("host.docker.internal:"));
1144    }
1145
1146    #[test]
1147    fn native_host_alias_prevents_docker_add_host_override() {
1148        let add_host =
1149            preserve_native_host_alias(Some("host.docker.internal:host-gateway".to_string()), true);
1150
1151        assert_eq!(add_host, None);
1152    }
1153
1154    #[test]
1155    fn unresolved_host_alias_keeps_docker_add_host() {
1156        let add_host = preserve_native_host_alias(
1157            Some("host.docker.internal:host-gateway".to_string()),
1158            false,
1159        );
1160
1161        assert_eq!(
1162            add_host.as_deref(),
1163            Some("host.docker.internal:host-gateway")
1164        );
1165    }
1166
1167    #[test]
1168    fn absent_docker_add_host_remains_absent() {
1169        assert_eq!(preserve_native_host_alias(None, true), None);
1170        assert_eq!(preserve_native_host_alias(None, false), None);
1171    }
1172
1173    #[test]
1174    fn in_container_mode_parses_truthy_values() {
1175        assert!(in_container_mode(Some("1".to_string())));
1176        assert!(in_container_mode(Some("true".to_string())));
1177        assert!(in_container_mode(Some("True".to_string())));
1178        assert!(in_container_mode(Some("TRUE".to_string())));
1179    }
1180
1181    #[test]
1182    fn in_container_mode_rejects_falsey_and_absent() {
1183        assert!(!in_container_mode(None));
1184        assert!(!in_container_mode(Some(String::new())));
1185        assert!(!in_container_mode(Some("0".to_string())));
1186        assert!(!in_container_mode(Some("false".to_string())));
1187        assert!(!in_container_mode(Some("yes".to_string())));
1188    }
1189
1190    #[test]
1191    fn native_alias_gate_suppresses_only_in_container() {
1192        // The gate `detect` computes: `in_container && host_alias_resolves`.
1193        let add_host = || Some("host.docker.internal:172.17.0.1".to_string());
1194
1195        // In-container + resolves -> Desktop-class runtime provides the alias
1196        // natively in siblings; drop the shadowing bridge mapping.
1197        let in_container = true;
1198        let resolves = true;
1199        assert_eq!(
1200            preserve_native_host_alias(add_host(), in_container && resolves),
1201            None,
1202        );
1203
1204        // NOT in-container (bare host) + resolves -> the resolving alias is
1205        // spurious (hijacking resolver / stray hosts entry). Native Linux docker
1206        // needs the bridge mapping; must NOT drop it. Regression guard.
1207        let in_container = false;
1208        let resolves = true;
1209        assert_eq!(
1210            preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
1211            Some("host.docker.internal:172.17.0.1"),
1212        );
1213
1214        // In-container + does NOT resolve -> nothing native to preserve; keep
1215        // the injected mapping.
1216        let in_container = true;
1217        let resolves = false;
1218        assert_eq!(
1219            preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
1220            Some("host.docker.internal:172.17.0.1"),
1221        );
1222    }
1223
1224    #[test]
1225    fn resolve_sibling_host_defaults_to_loopback() {
1226        assert_eq!(
1227            resolve_sibling_host("host.docker.internal", None),
1228            "127.0.0.1"
1229        );
1230        assert_eq!(
1231            resolve_sibling_host("host.docker.internal", Some(String::new())),
1232            "127.0.0.1"
1233        );
1234        assert_eq!(
1235            resolve_sibling_host("host.docker.internal", Some("0".to_string())),
1236            "127.0.0.1"
1237        );
1238        assert_eq!(
1239            resolve_sibling_host("host.containers.internal", Some("false".to_string())),
1240            "127.0.0.1"
1241        );
1242    }
1243
1244    #[test]
1245    fn resolve_sibling_host_uses_host_alias_when_in_container() {
1246        // Docker: siblings reachable at host.docker.internal.
1247        assert_eq!(
1248            resolve_sibling_host("host.docker.internal", Some("1".to_string())),
1249            "host.docker.internal"
1250        );
1251        assert_eq!(
1252            resolve_sibling_host("host.docker.internal", Some("true".to_string())),
1253            "host.docker.internal"
1254        );
1255        assert_eq!(
1256            resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
1257            "host.docker.internal"
1258        );
1259        // Podman: must use host.containers.internal, NOT host.docker.internal
1260        // (issue #1539 follow-up — gvproxy only resolves the containers alias).
1261        assert_eq!(
1262            resolve_sibling_host("host.containers.internal", Some("1".to_string())),
1263            "host.containers.internal"
1264        );
1265    }
1266
1267    #[test]
1268    fn detect_wires_sibling_host_to_podman_alias_in_container() {
1269        // Full path: a podman binary in a container must advertise siblings
1270        // at host.containers.internal. resolve_host_alias drives host_alias,
1271        // which resolve_sibling_host then reuses.
1272        let (alias, add_host) = resolve_host_alias("podman");
1273        assert_eq!(alias, "host.containers.internal");
1274        assert_eq!(add_host, None);
1275        assert_eq!(
1276            resolve_sibling_host(&alias, Some("1".to_string())),
1277            "host.containers.internal"
1278        );
1279    }
1280
1281    #[test]
1282    fn only_objects_of_a_dead_owner_are_orphans() {
1283        let me = std::process::id();
1284        let alive = |pid: u32| pid == 4242;
1285        // Another live fakecloud process: never an orphan.
1286        assert!(!owned_by_dead_process("fakecloud-4242", alive));
1287        // Its owner is gone: an orphan.
1288        assert!(owned_by_dead_process("fakecloud-777", alive));
1289        // The current process, even if the probe says otherwise.
1290        assert!(!owned_by_dead_process(&format!("fakecloud-{me}"), |_| {
1291            false
1292        }));
1293        // Nothing proves an unparseable owner is gone.
1294        for label in ["", "fakecloud-", "fakecloud-abc", "other-777"] {
1295            assert!(!owned_by_dead_process(label, alive), "{label:?}");
1296        }
1297    }
1298
1299    #[cfg(unix)]
1300    #[test]
1301    fn pid_alive_probes_real_processes() {
1302        assert!(pid_alive(std::process::id()));
1303        // Above any pid_max (Linux caps it at 2^22).
1304        assert!(!pid_alive(i32::MAX as u32));
1305        // Not process IDs: 0 is our process group, and values above i32::MAX
1306        // would be negative group IDs if cast, with group 1 (init's) alive.
1307        assert!(!pid_alive(0));
1308        assert!(!pid_alive(u32::MAX));
1309        assert!(!pid_alive(u32::MAX - 1));
1310    }
1311
1312    #[test]
1313    fn push_add_host_args_noop_for_podman() {
1314        let net = HostNetworking {
1315            host_alias: "host.containers.internal".to_string(),
1316            add_host_arg: None,
1317            sibling_host: "127.0.0.1".to_string(),
1318        };
1319        let mut argv = vec!["create".to_string()];
1320        net.push_add_host_args(&mut argv);
1321        assert_eq!(argv, vec!["create".to_string()]);
1322    }
1323
1324    #[test]
1325    fn push_add_host_args_emits_for_docker() {
1326        let net = HostNetworking {
1327            host_alias: "host.docker.internal".to_string(),
1328            add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
1329            sibling_host: "127.0.0.1".to_string(),
1330        };
1331        let mut argv = vec!["create".to_string()];
1332        net.push_add_host_args(&mut argv);
1333        assert_eq!(
1334            argv,
1335            vec![
1336                "create".to_string(),
1337                "--add-host".to_string(),
1338                "host.docker.internal:host-gateway".to_string(),
1339            ]
1340        );
1341    }
1342}
1343
1344#[cfg(test)]
1345mod bounded_cli_tests {
1346    use super::*;
1347    #[cfg(unix)]
1348    use crate::test_support::fake_cli;
1349
1350    /// A wedged daemon leaves the CLI blocked on connect forever. Every
1351    /// container call has to end at the bound instead of hanging its caller,
1352    /// which for the reaper means hanging server startup.
1353    #[test]
1354    fn a_hanging_cli_call_is_cut_off() {
1355        let start = std::time::Instant::now();
1356        let mut child = spawn_bounded(
1357            std::process::Command::new("sleep")
1358                .arg("600")
1359                .stdout(std::process::Stdio::null())
1360                .stderr(std::process::Stdio::null()),
1361        )
1362        .expect("sleep is available");
1363        assert!(!wait_bounded_group(&mut child));
1364        assert!(
1365            start.elapsed() < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
1366            "the wait must end at the bound"
1367        );
1368    }
1369
1370    /// Output larger than a pipe buffer (64 KiB on Linux) must come back
1371    /// whole. Waiting for the child to exit before reading blocks it on write
1372    /// forever, so this used to burn the full timeout and report failure.
1373    #[test]
1374    fn output_larger_than_the_pipe_buffer_still_comes_back() {
1375        let start = std::time::Instant::now();
1376        // 200_000 bytes: comfortably past the buffer on every supported host.
1377        let out = bounded_output("sh", &["-c", "printf 'x%.0s' $(seq 1 200000)"])
1378            .expect("a large but prompt call must succeed");
1379        assert_eq!(out.len(), 200_000, "output was truncated");
1380        assert!(
1381            start.elapsed() < CLI_PROBE_TIMEOUT,
1382            "a prompt call must not reach the deadline"
1383        );
1384    }
1385
1386    #[test]
1387    fn a_prompt_cli_call_returns_its_output() {
1388        assert_eq!(
1389            bounded_output("echo", &["abc123"])
1390                .as_deref()
1391                .map(str::trim),
1392            Some("abc123")
1393        );
1394        assert!(bounded_status("true", &[]));
1395        assert!(!bounded_status("false", &[]));
1396    }
1397
1398    /// A bounded call gets an empty stdin, never fakecloud's own. The process
1399    /// group `spawn_bounded` creates is a *background* one, so a child that
1400    /// reads the controlling terminal -- a `sudo`/credential-helper wrapper
1401    /// prompting -- is stopped by SIGTTIN, which the WNOHANG `try_wait` loop
1402    /// cannot see: the call would burn the whole deadline and be killed.
1403    /// `cat` with no argument reads stdin to EOF, so it returns at once with
1404    /// nothing only when stdin is /dev/null.
1405    #[cfg(unix)]
1406    #[test]
1407    fn a_bounded_call_reads_an_empty_stdin() {
1408        let start = std::time::Instant::now();
1409        let out = bounded_output("cat", &[]).expect("a call reading stdin must not time out");
1410        assert!(out.is_empty(), "stdin must be empty, got {out:?}");
1411        assert!(
1412            start.elapsed() < CLI_PROBE_TIMEOUT,
1413            "a call reading stdin must not reach the deadline"
1414        );
1415    }
1416
1417    /// A call that finishes in budget hands back its status and both streams,
1418    /// even when its output outgrows a pipe buffer.
1419    #[cfg(unix)]
1420    #[tokio::test]
1421    async fn output_within_returns_a_prompt_calls_output() {
1422        let (_dir, cli) = fake_cli(
1423            "cli",
1424            "head -c 200000 /dev/zero | tr '\\0' x; echo oops >&2; exit 3\n",
1425        );
1426        let out = output_within(
1427            tokio::process::Command::new(&cli).arg("build"),
1428            std::time::Duration::from_secs(20),
1429        )
1430        .await
1431        .expect("a prompt call must not time out");
1432        assert_eq!(out.status.code(), Some(3));
1433        assert_eq!(out.stdout.len(), 200_000);
1434        assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "oops");
1435    }
1436
1437    /// A call that never ends is killed at its budget -- with the grandchild
1438    /// a wrapper CLI started, which would otherwise hold the pipes (and keep
1439    /// the caller waiting) after the direct child died.
1440    #[cfg(unix)]
1441    #[tokio::test]
1442    async fn output_within_kills_a_hung_call_and_its_process_group() {
1443        let dir = tempfile::tempdir().unwrap();
1444        let pidfile = dir.path().join("grandchild.pid");
1445        let (_cli_dir, cli) = fake_cli(
1446            "cli",
1447            &format!(
1448                "[ \"$1\" = warmup ] && exit 0\nsleep 1000 &\necho $! > {}\nwait\n",
1449                pidfile.display()
1450            ),
1451        );
1452        let start = std::time::Instant::now();
1453        let err = output_within(
1454            tokio::process::Command::new(&cli).arg("build"),
1455            std::time::Duration::from_millis(500),
1456        )
1457        .await
1458        .expect_err("a hung call must time out");
1459        assert!(matches!(err, CliRunError::TimedOut(_)), "{err:?}");
1460        assert!(
1461            err.to_string().contains("did not finish within 0.5s"),
1462            "{err}"
1463        );
1464        assert!(
1465            start.elapsed() < std::time::Duration::from_secs(5),
1466            "the timeout returned after {:?}",
1467            start.elapsed()
1468        );
1469        let grandchild = crate::test_support::read_pidfile(&pidfile);
1470        assert!(
1471            crate::test_support::pid_gone(grandchild),
1472            "grandchild {grandchild} survived"
1473        );
1474    }
1475
1476    /// Dropping the call before it finishes -- an outer timeout, a lost
1477    /// `select!`, an aborted task -- kills the command's whole group too, not
1478    /// just the direct child `kill_on_drop` reaches.
1479    #[cfg(unix)]
1480    #[tokio::test]
1481    async fn dropping_output_within_kills_the_process_group() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let pidfile = dir.path().join("grandchild.pid");
1484        let (_cli_dir, cli) = fake_cli(
1485            "cli",
1486            &format!(
1487                "[ \"$1\" = warmup ] && exit 0\nsleep 1000 &\necho $! > {}\nwait\n",
1488                pidfile.display()
1489            ),
1490        );
1491        let mut cmd = tokio::process::Command::new(&cli);
1492        cmd.arg("build");
1493        let call = output_within(&mut cmd, std::time::Duration::from_secs(600));
1494        let start = std::time::Instant::now();
1495        // The outer budget runs out long before the call's own.
1496        assert!(
1497            tokio::time::timeout(std::time::Duration::from_millis(500), call)
1498                .await
1499                .is_err()
1500        );
1501        assert!(start.elapsed() < std::time::Duration::from_secs(5));
1502        let grandchild = crate::test_support::read_pidfile(&pidfile);
1503        assert!(
1504            crate::test_support::pid_gone(grandchild),
1505            "grandchild {grandchild} survived the dropped call"
1506        );
1507    }
1508
1509    #[tokio::test]
1510    async fn output_within_reports_a_missing_cli() {
1511        let err = output_within(
1512            &mut tokio::process::Command::new("/nonexistent-dir-fc/cli"),
1513            std::time::Duration::from_secs(5),
1514        )
1515        .await
1516        .expect_err("no such binary");
1517        assert!(matches!(err, CliRunError::Spawn(_)), "{err:?}");
1518    }
1519
1520    /// The happy path must still hand back the child's output *and* collect the
1521    /// reader, so the no-leak guarantee isn't bought by dropping output.
1522    #[test]
1523    fn a_prompt_cli_call_collects_its_reader() {
1524        let (output, reader) = run_bounded("echo", &["abc123"]);
1525        assert_eq!(output.as_deref().map(str::trim), Some("abc123"));
1526        assert!(
1527            matches!(reader, ReaderState::Finished),
1528            "reader was {reader:?}, expected it collected"
1529        );
1530    }
1531
1532    /// The bridge-gateway probe talks to the same daemon as the liveness probe,
1533    /// so it has to end at the same bound. It used to be a plain
1534    /// `Command::output()`, which against a wedged daemon hung whichever runtime
1535    /// constructor called it -- on Linux, every container-backed service at
1536    /// server startup. Timing out is not an error here: the caller falls back to
1537    /// the conventional `172.17.0.1`.
1538    #[cfg(unix)]
1539    #[test]
1540    fn a_hanging_bridge_gateway_probe_is_cut_off() {
1541        let (_dir, script) = fake_cli("hangcli", "[ \"$1\" = warmup ] && exit 0\nsleep 600\n");
1542
1543        let start = std::time::Instant::now();
1544        let gateway = detect_bridge_gateway(&script);
1545        let elapsed = start.elapsed();
1546
1547        assert_eq!(gateway, None, "a wedged daemon must report no gateway");
1548        assert!(
1549            elapsed < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
1550            "the probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
1551        );
1552    }
1553
1554    /// The unavailable-CLI path keeps its shape: nothing to spawn, no gateway,
1555    /// and the caller's fallback stands.
1556    #[test]
1557    fn a_missing_cli_reports_no_bridge_gateway() {
1558        assert_eq!(
1559            detect_bridge_gateway("definitely-not-a-real-cli-binary-xyz-123"),
1560            None
1561        );
1562    }
1563
1564    /// A CLI that succeeds with no output -- an `inspect --format` over a bridge
1565    /// with no IPAM config -- still means "no gateway", not an empty
1566    /// `--add-host` value. Unchanged by the bounding; guarded so it stays that
1567    /// way.
1568    #[test]
1569    fn an_empty_gateway_is_rejected() {
1570        assert_eq!(detect_bridge_gateway("true"), None);
1571    }
1572
1573    /// `FAKECLOUD_CONTAINER_CLI` is routinely a wrapper (`sh -c 'exec docker
1574    /// "$@"'`, a `podman-remote` shim), which makes the real command a
1575    /// grandchild holding the stdout pipe. Killing only the direct child left
1576    /// the reader's `read_to_end` blocked forever -- a thread parked for the
1577    /// life of the process, once per call, on exactly the wedged-daemon path
1578    /// these bounds were added for (the server reaper calls this at startup).
1579    /// The reader reporting in is the evidence: EOF on that pipe is only
1580    /// possible once every write end is closed, so a collected buffer proves
1581    /// the grandchildren went down with the call.
1582    #[cfg(unix)]
1583    #[test]
1584    fn a_timed_out_wrapper_call_leaves_no_reader_behind() {
1585        let start = std::time::Instant::now();
1586        // A wrapper that outlives its own kill: the backgrounded sleep inherits
1587        // the stdout pipe and is not the process we spawned.
1588        let (output, reader) = run_bounded("sh", &["-c", "sleep 600 & sleep 600"]);
1589        assert_eq!(output, None, "a wedged call must report failure");
1590        assert!(
1591            matches!(reader, ReaderState::Finished),
1592            "reader was {reader:?}: the stdout reader must not outlive the call"
1593        );
1594        assert!(
1595            start.elapsed()
1596                < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
1597            "the call must still end at the bound, took {:?}",
1598            start.elapsed()
1599        );
1600    }
1601}
1602
1603#[cfg(test)]
1604mod endpoint_tests {
1605    use super::*;
1606
1607    #[test]
1608    fn ecr_registry_host_defaults_to_loopback() {
1609        // The host daemon performs the pull; the sibling alias is wrong there.
1610        assert_eq!(resolve_ecr_registry_host(None, false, true), "127.0.0.1");
1611        assert_eq!(resolve_ecr_registry_host(None, false, false), "127.0.0.1");
1612        assert_eq!(
1613            resolve_ecr_registry_host(Some("  ".into()), false, true),
1614            "127.0.0.1"
1615        );
1616        // Podman on Linux pulls on the host; podman machine pulls in its VM.
1617        assert_eq!(resolve_ecr_registry_host(None, true, true), "127.0.0.1");
1618        assert_eq!(
1619            resolve_ecr_registry_host(None, true, false),
1620            "host.containers.internal"
1621        );
1622        assert_eq!(
1623            resolve_ecr_registry_host(Some("10.0.0.5".into()), true, false),
1624            "10.0.0.5"
1625        );
1626    }
1627
1628    #[test]
1629    fn registry_override_host_is_authorized() {
1630        // A pull rewritten to FAKECLOUD_ECR_REGISTRY_HOST must carry auth, or
1631        // the registry answers 401.
1632        let hosts = registry_auth_hosts_with(4566, Some("10.1.2.3".into()));
1633        assert!(hosts.contains(&"10.1.2.3:4566".to_string()), "{hosts:?}");
1634        assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
1635        // No duplicates, blank override ignored.
1636        assert_eq!(
1637            registry_auth_hosts_with(4566, Some("127.0.0.1".into())).len(),
1638            4
1639        );
1640        assert_eq!(registry_auth_hosts_with(4566, Some(" ".into())).len(), 4);
1641    }
1642
1643    #[test]
1644    fn published_port_parses_docker_and_podman_output() {
1645        assert_eq!(
1646            parse_published_port("0.0.0.0:49153\n[::]:49153\n"),
1647            Some(49153)
1648        );
1649        assert_eq!(parse_published_port("[::]:5000"), Some(5000));
1650        assert_eq!(parse_published_port(""), None);
1651        assert_eq!(parse_published_port("garbage"), None);
1652    }
1653
1654    #[test]
1655    fn rewrite_loopback_urls_and_bare_endpoints() {
1656        let h = "host.docker.internal";
1657        assert_eq!(
1658            rewrite_loopback_value("http://localhost:4566", h),
1659            "http://host.docker.internal:4566"
1660        );
1661        assert_eq!(
1662            rewrite_loopback_value("https://127.0.0.1:4566/path", h),
1663            "https://host.docker.internal:4566/path"
1664        );
1665        // Bare host (an RDS / ElastiCache endpoint address).
1666        assert_eq!(rewrite_loopback_value("127.0.0.1", h), h);
1667        assert_eq!(rewrite_loopback_value("localhost", h), h);
1668        // Bare host:port and comma-separated broker lists.
1669        assert_eq!(
1670            rewrite_loopback_value("127.0.0.1:6379", h),
1671            "host.docker.internal:6379"
1672        );
1673        assert_eq!(
1674            rewrite_loopback_value("127.0.0.1:9092,localhost:9094", h),
1675            "host.docker.internal:9092,host.docker.internal:9094"
1676        );
1677        // Scheme-qualified DSNs with userinfo.
1678        assert_eq!(
1679            rewrite_loopback_value("postgres://u:p@localhost:5432/db", h),
1680            "postgres://u:p@host.docker.internal:5432/db"
1681        );
1682        assert_eq!(
1683            rewrite_loopback_value("redis://127.0.0.1:6379/0", h),
1684            "redis://host.docker.internal:6379/0"
1685        );
1686        // URL host without a port.
1687        assert_eq!(
1688            rewrite_loopback_value("http://localhost/health", h),
1689            "http://host.docker.internal/health"
1690        );
1691        // key=value lists.
1692        assert_eq!(
1693            rewrite_loopback_value("host=127.0.0.1:5432 user=x", h),
1694            "host=host.docker.internal:5432 user=x"
1695        );
1696    }
1697
1698    #[test]
1699    fn rewrite_loopback_leaves_prose_and_lookalikes_alone() {
1700        let h = "host.docker.internal";
1701        for v in [
1702            "EHLO localhost",
1703            "connect to localhost soon",
1704            "127.0.0.10:80",
1705            "localhost.localdomain:25",
1706            "mylocalhost:80",
1707            "",
1708        ] {
1709            assert_eq!(rewrite_loopback_value(v, h), v, "{v:?}");
1710        }
1711        // Workload sharing fakecloud's namespace: nothing to rewrite.
1712        assert_eq!(
1713            rewrite_loopback_value("http://localhost:4566", "127.0.0.1"),
1714            "http://localhost:4566"
1715        );
1716    }
1717
1718    #[test]
1719    fn rewrite_loopback_swaps_registered_container_ports() {
1720        // A broker whose host listener advertises loopback registers the
1721        // listener containers must use instead.
1722        crate::dataplane::register_container_port(41001, 41002);
1723        assert_eq!(
1724            rewrite_loopback_value("127.0.0.1:41001", "host.docker.internal"),
1725            "host.docker.internal:41002"
1726        );
1727        crate::dataplane::unregister_container_port(41001);
1728        assert_eq!(
1729            rewrite_loopback_value("127.0.0.1:41001", "host.docker.internal"),
1730            "host.docker.internal:41001"
1731        );
1732    }
1733}