pub const CONTAINER_RUNTIME_HINT: &str = "Install and start Docker or Podman, or set FAKECLOUD_CONTAINER_CLI to your container CLI path.";
pub fn detect_container_cli() -> Option<String> {
if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
return if cli_available(&cli) { Some(cli) } else { None };
}
if cli_available("docker") {
Some("docker".to_string())
} else if cli_available("podman") {
Some("podman".to_string())
} else {
None
}
}
pub const CLI_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
static CLI_AVAILABLE_CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, bool>>,
> = std::sync::OnceLock::new();
pub fn cli_available(cli: &str) -> bool {
let cache =
CLI_AVAILABLE_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
if let Some(&cached) = cache.lock().unwrap().get(cli) {
return cached;
}
let result = probe_cli(cli);
cache.lock().unwrap().insert(cli.to_string(), result);
result
}
fn probe_cli(cli: &str) -> bool {
let child = spawn_bounded(
std::process::Command::new(cli)
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()),
);
let Ok(mut child) = child else {
return false;
};
wait_bounded_group(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false)
}
fn spawn_bounded(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
cmd.stdin(std::process::Stdio::null());
#[cfg(unix)]
{
std::os::unix::process::CommandExt::process_group(cmd, 0);
}
cmd.spawn()
}
fn wait_bounded_group(child: &mut std::process::Child) -> bool {
let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => {}
Err(_) => return false,
}
if std::time::Instant::now() >= deadline {
kill_expired(child);
let _ = child.wait();
return false;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
#[cfg(unix)]
fn kill_expired(child: &mut std::process::Child) {
let _ = unsafe { libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL) };
let _ = child.kill();
}
#[cfg(not(unix))]
fn kill_expired(child: &mut std::process::Child) {
let _ = child.kill();
}
#[derive(Debug)]
enum ReaderState {
Finished,
Abandoned,
}
const READER_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
pub fn bounded_output(cli: &str, args: &[&str]) -> Option<String> {
run_bounded(cli, args).0
}
fn run_bounded(cli: &str, args: &[&str]) -> (Option<String>, ReaderState) {
let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
let child = spawn_bounded(
std::process::Command::new(cli)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null()),
);
let Ok(mut child) = child else {
return (None, ReaderState::Finished);
};
let Some(mut stdout) = child.stdout.take() else {
kill_expired(&mut child);
let _ = child.wait();
return (None, ReaderState::Finished);
};
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut stdout, &mut buf);
let _ = tx.send(buf);
});
let exited = wait_bounded_group(&mut child);
let status = child.wait().ok();
let grace = deadline
.saturating_duration_since(std::time::Instant::now())
.max(READER_DRAIN_GRACE);
let drained = rx.recv_timeout(grace).ok();
let output = match (exited, status, &drained) {
(true, Some(status), Some(buf)) if status.success() => {
Some(String::from_utf8_lossy(buf).into_owned())
}
_ => None,
};
let reader = if drained.is_some() {
ReaderState::Finished
} else {
ReaderState::Abandoned
};
(output, reader)
}
pub fn bounded_status(cli: &str, args: &[&str]) -> bool {
let Ok(mut child) = spawn_bounded(
std::process::Command::new(cli)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()),
) else {
return false;
};
wait_bounded_group(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false)
}
#[cfg(unix)]
pub fn pid_alive(pid: u32) -> bool {
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
pub fn pid_alive(_pid: u32) -> bool {
true
}
pub fn owned_by_dead_process(label: &str, is_alive: impl Fn(u32) -> bool) -> bool {
let Some(pid) = label
.strip_prefix("fakecloud-")
.and_then(|p| p.parse::<u32>().ok())
else {
return false;
};
pid != std::process::id() && !is_alive(pid)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerEngine {
Docker,
Podman,
}
static PODMAN_CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, bool>>,
> = std::sync::OnceLock::new();
fn podman_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, bool>> {
PODMAN_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
pub fn is_podman(cli: &str) -> bool {
if name_indicates_podman(cli) {
return true;
}
if let Some(&cached) = podman_cache().lock().unwrap().get(cli) {
return cached;
}
let podman = probe_engine(cli) == Some(ContainerEngine::Podman);
podman_cache()
.lock()
.unwrap()
.insert(cli.to_string(), podman);
podman
}
pub async fn is_podman_async(cli: &str) -> bool {
if name_indicates_podman(cli) {
return true;
}
if let Some(&cached) = podman_cache().lock().unwrap().get(cli) {
return cached;
}
let owned = cli.to_string();
tokio::task::spawn_blocking(move || is_podman(&owned))
.await
.unwrap_or(false)
}
fn name_indicates_podman(cli: &str) -> bool {
std::path::Path::new(cli)
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.contains("podman"))
.unwrap_or(false)
}
fn probe_engine(cli: &str) -> Option<ContainerEngine> {
let version = bounded_output(cli, &["--version"])?;
classify_version_output(&version).or_else(|| {
bounded_output(cli, &["info", "--format", "{{json .}}"])
.and_then(|info| classify_info_output(&info))
})
}
pub fn classify_version_output(stdout: &str) -> Option<ContainerEngine> {
let lower = stdout.to_ascii_lowercase();
if lower.contains("podman") {
Some(ContainerEngine::Podman)
} else if lower.contains("docker") {
Some(ContainerEngine::Docker)
} else {
None
}
}
pub fn classify_info_output(stdout: &str) -> Option<ContainerEngine> {
if stdout.contains("\"buildahVersion\"") || stdout.contains("\"ociRuntime\"") {
Some(ContainerEngine::Podman)
} else if stdout.contains("\"ServerVersion\"") {
Some(ContainerEngine::Docker)
} else {
None
}
}
pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
let stdout = bounded_output(
cli,
&[
"network",
"inspect",
"bridge",
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
],
)?;
let gateway = stdout.trim().to_string();
if gateway.is_empty() || !gateway.contains('.') {
return None;
}
Some(gateway)
}
#[derive(Debug, Clone)]
pub struct HostNetworking {
pub host_alias: String,
pub add_host_arg: Option<String>,
pub sibling_host: String,
}
impl HostNetworking {
pub fn detect(cli: &str) -> Self {
let (host_alias, mut add_host_arg) = resolve_host_alias(cli);
let in_container = in_container_mode(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
add_host_arg = preserve_native_host_alias(
add_host_arg,
in_container && host_alias_resolves(&host_alias),
);
let sibling_host =
resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
Self {
host_alias,
add_host_arg,
sibling_host,
}
}
pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
if let Some(arg) = &self.add_host_arg {
argv.push("--add-host".to_string());
argv.push(arg.clone());
}
}
}
pub const HOST_ALIAS_RESOLVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
fn host_alias_resolves(host_alias: &str) -> bool {
let (tx, rx) = std::sync::mpsc::channel();
let alias = host_alias.to_string();
std::thread::spawn(move || {
let resolves = std::net::ToSocketAddrs::to_socket_addrs(&(alias.as_str(), 0)).is_ok();
let _ = tx.send(resolves);
});
rx.recv_timeout(HOST_ALIAS_RESOLVE_TIMEOUT).unwrap_or(false)
}
fn preserve_native_host_alias(
add_host_arg: Option<String>,
should_suppress: bool,
) -> Option<String> {
if add_host_arg.is_some() && should_suppress {
None
} else {
add_host_arg
}
}
pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
if is_podman(cli) {
("host.containers.internal".to_string(), None)
} else if cfg!(target_os = "linux") {
let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
(
"host.docker.internal".to_string(),
Some(format!("host.docker.internal:{ip}")),
)
} else {
(
"host.docker.internal".to_string(),
Some("host.docker.internal:host-gateway".to_string()),
)
}
}
pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
if in_container_mode(env_value) {
host_alias.to_string()
} else {
"127.0.0.1".to_string()
}
}
fn in_container_mode(env_value: Option<String>) -> bool {
env_value
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
registry_auth_hosts_with(
server_port,
std::env::var("FAKECLOUD_ECR_REGISTRY_HOST").ok(),
)
}
pub fn registry_auth_hosts_with(server_port: u16, override_host: Option<String>) -> Vec<String> {
let mut hosts: Vec<String> = [
"localhost",
"127.0.0.1",
"host.docker.internal",
"host.containers.internal",
]
.iter()
.map(|h| h.to_string())
.collect();
if let Some(h) = override_host
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
if !hosts.contains(&h) {
hosts.push(h);
}
}
hosts
.iter()
.map(|host| format!("{host}:{server_port}"))
.collect()
}
pub fn ecr_registry_host(cli: &str) -> String {
resolve_ecr_registry_host(
std::env::var("FAKECLOUD_ECR_REGISTRY_HOST").ok(),
is_podman(cli),
cfg!(target_os = "linux"),
)
}
pub fn resolve_ecr_registry_host(env_value: Option<String>, podman: bool, linux: bool) -> String {
if let Some(v) = env_value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
return v;
}
if podman && !linux {
"host.containers.internal".to_string()
} else {
"127.0.0.1".to_string()
}
}
pub fn parse_published_port(output: &str) -> Option<u16> {
output
.lines()
.filter_map(|l| l.trim().rsplit(':').next())
.find_map(|p| p.parse::<u16>().ok())
}
const LOOPBACK_NAMES: [&str; 2] = ["127.0.0.1", "localhost"];
pub fn rewrite_loopback_value(value: &str, target_host: &str) -> String {
if LOOPBACK_NAMES.contains(&target_host) {
return value.to_string();
}
if LOOPBACK_NAMES.contains(&value.trim()) {
return value.replacen(value.trim(), target_host, 1);
}
let bytes = value.as_bytes();
let mut out = String::with_capacity(value.len());
let mut i = 0;
while i < value.len() {
let hit = LOOPBACK_NAMES
.iter()
.find(|name| value[i..].starts_with(**name) && loopback_at(value, i, name.len()));
let Some(name) = hit else {
let ch = value[i..].chars().next().unwrap_or_default();
out.push(ch);
i += ch.len_utf8().max(1);
continue;
};
out.push_str(target_host);
i += name.len();
if bytes.get(i) == Some(&b':') {
let digits: String = value[i + 1..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if let Some(mapped) = digits
.parse::<u16>()
.ok()
.and_then(crate::dataplane::container_port_for)
{
out.push(':');
out.push_str(&mapped.to_string());
i += 1 + digits.len();
}
}
}
out
}
fn loopback_at(value: &str, i: usize, len: usize) -> bool {
let before = value[..i].chars().next_back();
let boundary_before = match before {
None => true,
Some(c) => matches!(c, '/' | '@' | ',' | '=' | ';' | '(' | '"' | '\'') || c.is_whitespace(),
};
if !boundary_before {
return false;
}
let rest = &value[i + len..];
let mut after = rest.chars();
match after.next() {
Some(':') => after.next().is_some_and(|c| c.is_ascii_digit()),
next => {
let url_host = matches!(before, Some('@')) || value[..i].ends_with("//");
url_host && matches!(next, None | Some('/') | Some('?') | Some('#'))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_available_false_for_missing_binary() {
assert!(!cli_available("definitely-not-a-real-cli-binary-xyz-123"));
}
#[cfg(unix)]
#[test]
fn cli_available_bounds_a_hanging_probe() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("fc-clitest-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("hangcli");
std::fs::write(&script, "#!/bin/sh\nsleep 600\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
std::io::stdout().flush().ok();
let start = std::time::Instant::now();
let available = cli_available(script.to_str().unwrap());
let elapsed = start.elapsed();
std::fs::remove_dir_all(&dir).ok();
assert!(!available, "a hanging probe must report unavailable");
assert!(
elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
"probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
);
}
#[test]
fn registry_auth_hosts_includes_podman_alias() {
let hosts = registry_auth_hosts(4566);
assert!(hosts.contains(&"localhost:4566".to_string()));
assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
assert!(
hosts.contains(&"host.containers.internal:4566".to_string()),
"podman sibling alias must be authorized: {hosts:?}"
);
}
#[test]
fn name_fast_path_matches_podman_names() {
assert!(name_indicates_podman("podman"));
assert!(name_indicates_podman("podman-remote"));
assert!(name_indicates_podman("/opt/homebrew/bin/podman"));
assert!(name_indicates_podman("/usr/local/bin/podman-remote"));
assert!(!name_indicates_podman("docker"));
assert!(!name_indicates_podman("/usr/local/bin/docker"));
assert!(!name_indicates_podman("docker-credential-helper"));
}
#[test]
fn name_fast_path_needs_no_probe() {
assert!(is_podman("/nonexistent-dir-fc-2599/podman"));
assert!(is_podman("podman-remote-definitely-missing-xyz"));
}
#[test]
fn version_output_classifies_the_engine() {
assert_eq!(
classify_version_output("podman version 5.2.0\n"),
Some(ContainerEngine::Podman)
);
assert_eq!(
classify_version_output("Docker version 27.3.1, build ce12230\n"),
Some(ContainerEngine::Docker)
);
assert_eq!(
classify_version_output(
"Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.\npodman version 4.9.4\n"
),
Some(ContainerEngine::Podman)
);
assert_eq!(classify_version_output("my-wrapper 1.0\n"), None);
assert_eq!(classify_version_output(""), None);
}
#[test]
fn info_output_classifies_the_engine() {
assert_eq!(
classify_info_output(
r#"{"host":{"buildahVersion":"1.37.0","ociRuntime":{"name":"crun"}}}"#
),
Some(ContainerEngine::Podman)
);
assert_eq!(
classify_info_output(r#"{"ID":"abc","ServerVersion":"27.3.1","Driver":"overlay2"}"#),
Some(ContainerEngine::Docker)
);
assert_eq!(classify_info_output("{}"), None);
}
#[cfg(unix)]
fn fake_cli(name: &str, body: &str) -> (tempfile::TempDir, String) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut attempts = 0;
loop {
match std::process::Command::new(&path).arg("warmup").output() {
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 200 => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(5));
}
_ => break,
}
}
let cli = path.display().to_string();
(dir, cli)
}
#[cfg(unix)]
#[test]
fn podman_docker_shim_is_detected_as_podman() {
let (_dir, cli) = fake_cli(
"docker",
"[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
);
assert!(is_podman(&cli));
assert_eq!(probe_engine(&cli), Some(ContainerEngine::Podman));
}
#[cfg(unix)]
#[test]
fn real_docker_cli_is_not_podman() {
let (_dir, cli) = fake_cli(
"docker",
"[ \"$1\" = --version ] && { echo 'Docker version 27.3.1, build ce12230'; exit 0; }\nexit 1\n",
);
assert!(!is_podman(&cli));
}
#[cfg(unix)]
#[test]
fn wrapper_with_opaque_version_falls_back_to_info() {
let (_dir, cli) = fake_cli(
"container-cli",
"case \"$1\" in\n --version) echo 'wrapper 1.0' ;;\n info) echo '{\"host\":{\"buildahVersion\":\"1.37.0\"}}' ;;\n *) exit 1 ;;\nesac\n",
);
assert!(is_podman(&cli));
}
#[cfg(unix)]
#[test]
fn engine_probe_is_cached_per_cli() {
let (dir, cli) = fake_cli(
"docker",
"echo \"$*\" >> \"$(dirname \"$0\")/calls.log\"\n[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
);
let log = dir.path().join("calls.log");
let _ = std::fs::remove_file(&log);
assert!(is_podman(&cli));
assert!(is_podman(&cli));
let calls = std::fs::read_to_string(&log).unwrap_or_default();
assert_eq!(calls.lines().collect::<Vec<_>>(), ["--version"]);
}
#[cfg(unix)]
#[tokio::test]
async fn async_probe_detects_the_shim() {
let (_dir, cli) = fake_cli(
"docker",
"[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
);
assert!(is_podman_async(&cli).await);
}
#[cfg(unix)]
#[test]
fn engine_probe_bounds_a_hanging_cli() {
let (_dir, cli) = fake_cli(
"docker",
"case \"$1\" in --version|info) sleep 600 ;; esac\nexit 1\n",
);
let start = std::time::Instant::now();
assert!(!is_podman(&cli));
let elapsed = start.elapsed();
assert!(
elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
"probe took {elapsed:?}, expected one bound near {CLI_PROBE_TIMEOUT:?}"
);
}
#[test]
fn missing_cli_is_not_podman() {
assert!(!is_podman("definitely-not-a-real-cli-binary-fc-2599"));
assert!(!is_podman("false"));
}
#[cfg(unix)]
#[test]
fn resolve_host_alias_treats_the_shim_as_podman() {
let (_dir, cli) = fake_cli(
"docker",
"[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
);
let (alias, add_host) = resolve_host_alias(&cli);
assert_eq!(alias, "host.containers.internal");
assert_eq!(add_host, None);
}
#[test]
fn resolve_host_alias_podman_has_no_add_host() {
let (alias, add_host) = resolve_host_alias("podman");
assert_eq!(alias, "host.containers.internal");
assert_eq!(add_host, None);
let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
assert_eq!(alias, "host.containers.internal");
assert_eq!(add_host, None);
}
#[test]
#[cfg(unix)]
fn resolve_host_alias_docker_emits_add_host() {
let (_dir, cli) = fake_cli(
"docker",
"[ \"$1\" = --version ] && { echo 'Docker version 27.3.1, build ce12230'; exit 0; }\nexit 1\n",
);
let (alias, add_host) = resolve_host_alias(&cli);
assert_eq!(alias, "host.docker.internal");
assert!(add_host.is_some());
assert!(add_host.unwrap().starts_with("host.docker.internal:"));
}
#[test]
fn native_host_alias_prevents_docker_add_host_override() {
let add_host =
preserve_native_host_alias(Some("host.docker.internal:host-gateway".to_string()), true);
assert_eq!(add_host, None);
}
#[test]
fn unresolved_host_alias_keeps_docker_add_host() {
let add_host = preserve_native_host_alias(
Some("host.docker.internal:host-gateway".to_string()),
false,
);
assert_eq!(
add_host.as_deref(),
Some("host.docker.internal:host-gateway")
);
}
#[test]
fn absent_docker_add_host_remains_absent() {
assert_eq!(preserve_native_host_alias(None, true), None);
assert_eq!(preserve_native_host_alias(None, false), None);
}
#[test]
fn in_container_mode_parses_truthy_values() {
assert!(in_container_mode(Some("1".to_string())));
assert!(in_container_mode(Some("true".to_string())));
assert!(in_container_mode(Some("True".to_string())));
assert!(in_container_mode(Some("TRUE".to_string())));
}
#[test]
fn in_container_mode_rejects_falsey_and_absent() {
assert!(!in_container_mode(None));
assert!(!in_container_mode(Some(String::new())));
assert!(!in_container_mode(Some("0".to_string())));
assert!(!in_container_mode(Some("false".to_string())));
assert!(!in_container_mode(Some("yes".to_string())));
}
#[test]
fn native_alias_gate_suppresses_only_in_container() {
let add_host = || Some("host.docker.internal:172.17.0.1".to_string());
let in_container = true;
let resolves = true;
assert_eq!(
preserve_native_host_alias(add_host(), in_container && resolves),
None,
);
let in_container = false;
let resolves = true;
assert_eq!(
preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
Some("host.docker.internal:172.17.0.1"),
);
let in_container = true;
let resolves = false;
assert_eq!(
preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
Some("host.docker.internal:172.17.0.1"),
);
}
#[test]
fn resolve_sibling_host_defaults_to_loopback() {
assert_eq!(
resolve_sibling_host("host.docker.internal", None),
"127.0.0.1"
);
assert_eq!(
resolve_sibling_host("host.docker.internal", Some(String::new())),
"127.0.0.1"
);
assert_eq!(
resolve_sibling_host("host.docker.internal", Some("0".to_string())),
"127.0.0.1"
);
assert_eq!(
resolve_sibling_host("host.containers.internal", Some("false".to_string())),
"127.0.0.1"
);
}
#[test]
fn resolve_sibling_host_uses_host_alias_when_in_container() {
assert_eq!(
resolve_sibling_host("host.docker.internal", Some("1".to_string())),
"host.docker.internal"
);
assert_eq!(
resolve_sibling_host("host.docker.internal", Some("true".to_string())),
"host.docker.internal"
);
assert_eq!(
resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
"host.docker.internal"
);
assert_eq!(
resolve_sibling_host("host.containers.internal", Some("1".to_string())),
"host.containers.internal"
);
}
#[test]
fn detect_wires_sibling_host_to_podman_alias_in_container() {
let (alias, add_host) = resolve_host_alias("podman");
assert_eq!(alias, "host.containers.internal");
assert_eq!(add_host, None);
assert_eq!(
resolve_sibling_host(&alias, Some("1".to_string())),
"host.containers.internal"
);
}
#[test]
fn only_objects_of_a_dead_owner_are_orphans() {
let me = std::process::id();
let alive = |pid: u32| pid == 4242;
assert!(!owned_by_dead_process("fakecloud-4242", alive));
assert!(owned_by_dead_process("fakecloud-777", alive));
assert!(!owned_by_dead_process(&format!("fakecloud-{me}"), |_| {
false
}));
for label in ["", "fakecloud-", "fakecloud-abc", "other-777"] {
assert!(!owned_by_dead_process(label, alive), "{label:?}");
}
}
#[cfg(unix)]
#[test]
fn pid_alive_probes_real_processes() {
assert!(pid_alive(std::process::id()));
assert!(!pid_alive(u32::MAX - 1));
}
#[test]
fn push_add_host_args_noop_for_podman() {
let net = HostNetworking {
host_alias: "host.containers.internal".to_string(),
add_host_arg: None,
sibling_host: "127.0.0.1".to_string(),
};
let mut argv = vec!["create".to_string()];
net.push_add_host_args(&mut argv);
assert_eq!(argv, vec!["create".to_string()]);
}
#[test]
fn push_add_host_args_emits_for_docker() {
let net = HostNetworking {
host_alias: "host.docker.internal".to_string(),
add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
sibling_host: "127.0.0.1".to_string(),
};
let mut argv = vec!["create".to_string()];
net.push_add_host_args(&mut argv);
assert_eq!(
argv,
vec![
"create".to_string(),
"--add-host".to_string(),
"host.docker.internal:host-gateway".to_string(),
]
);
}
}
#[cfg(test)]
mod bounded_cli_tests {
use super::*;
#[test]
fn a_hanging_cli_call_is_cut_off() {
let start = std::time::Instant::now();
let mut child = spawn_bounded(
std::process::Command::new("sleep")
.arg("600")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()),
)
.expect("sleep is available");
assert!(!wait_bounded_group(&mut child));
assert!(
start.elapsed() < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
"the wait must end at the bound"
);
}
#[test]
fn output_larger_than_the_pipe_buffer_still_comes_back() {
let start = std::time::Instant::now();
let out = bounded_output("sh", &["-c", "printf 'x%.0s' $(seq 1 200000)"])
.expect("a large but prompt call must succeed");
assert_eq!(out.len(), 200_000, "output was truncated");
assert!(
start.elapsed() < CLI_PROBE_TIMEOUT,
"a prompt call must not reach the deadline"
);
}
#[test]
fn a_prompt_cli_call_returns_its_output() {
assert_eq!(
bounded_output("echo", &["abc123"])
.as_deref()
.map(str::trim),
Some("abc123")
);
assert!(bounded_status("true", &[]));
assert!(!bounded_status("false", &[]));
}
#[cfg(unix)]
#[test]
fn a_bounded_call_reads_an_empty_stdin() {
let start = std::time::Instant::now();
let out = bounded_output("cat", &[]).expect("a call reading stdin must not time out");
assert!(out.is_empty(), "stdin must be empty, got {out:?}");
assert!(
start.elapsed() < CLI_PROBE_TIMEOUT,
"a call reading stdin must not reach the deadline"
);
}
#[test]
fn a_prompt_cli_call_collects_its_reader() {
let (output, reader) = run_bounded("echo", &["abc123"]);
assert_eq!(output.as_deref().map(str::trim), Some("abc123"));
assert!(
matches!(reader, ReaderState::Finished),
"reader was {reader:?}, expected it collected"
);
}
#[cfg(unix)]
#[test]
fn a_hanging_bridge_gateway_probe_is_cut_off() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("fc-gwtest-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("hangcli");
std::fs::write(&script, "#!/bin/sh\nsleep 600\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let start = std::time::Instant::now();
let gateway = detect_bridge_gateway(script.to_str().unwrap());
let elapsed = start.elapsed();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(gateway, None, "a wedged daemon must report no gateway");
assert!(
elapsed < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
"the probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
);
}
#[test]
fn a_missing_cli_reports_no_bridge_gateway() {
assert_eq!(
detect_bridge_gateway("definitely-not-a-real-cli-binary-xyz-123"),
None
);
}
#[test]
fn an_empty_gateway_is_rejected() {
assert_eq!(detect_bridge_gateway("true"), None);
}
#[cfg(unix)]
#[test]
fn a_timed_out_wrapper_call_leaves_no_reader_behind() {
let start = std::time::Instant::now();
let (output, reader) = run_bounded("sh", &["-c", "sleep 600 & sleep 600"]);
assert_eq!(output, None, "a wedged call must report failure");
assert!(
matches!(reader, ReaderState::Finished),
"reader was {reader:?}: the stdout reader must not outlive the call"
);
assert!(
start.elapsed()
< CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
"the call must still end at the bound, took {:?}",
start.elapsed()
);
}
}
#[cfg(test)]
mod endpoint_tests {
use super::*;
#[test]
fn ecr_registry_host_defaults_to_loopback() {
assert_eq!(resolve_ecr_registry_host(None, false, true), "127.0.0.1");
assert_eq!(resolve_ecr_registry_host(None, false, false), "127.0.0.1");
assert_eq!(
resolve_ecr_registry_host(Some(" ".into()), false, true),
"127.0.0.1"
);
assert_eq!(resolve_ecr_registry_host(None, true, true), "127.0.0.1");
assert_eq!(
resolve_ecr_registry_host(None, true, false),
"host.containers.internal"
);
assert_eq!(
resolve_ecr_registry_host(Some("10.0.0.5".into()), true, false),
"10.0.0.5"
);
}
#[test]
fn registry_override_host_is_authorized() {
let hosts = registry_auth_hosts_with(4566, Some("10.1.2.3".into()));
assert!(hosts.contains(&"10.1.2.3:4566".to_string()), "{hosts:?}");
assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
assert_eq!(
registry_auth_hosts_with(4566, Some("127.0.0.1".into())).len(),
4
);
assert_eq!(registry_auth_hosts_with(4566, Some(" ".into())).len(), 4);
}
#[test]
fn published_port_parses_docker_and_podman_output() {
assert_eq!(
parse_published_port("0.0.0.0:49153\n[::]:49153\n"),
Some(49153)
);
assert_eq!(parse_published_port("[::]:5000"), Some(5000));
assert_eq!(parse_published_port(""), None);
assert_eq!(parse_published_port("garbage"), None);
}
#[test]
fn rewrite_loopback_urls_and_bare_endpoints() {
let h = "host.docker.internal";
assert_eq!(
rewrite_loopback_value("http://localhost:4566", h),
"http://host.docker.internal:4566"
);
assert_eq!(
rewrite_loopback_value("https://127.0.0.1:4566/path", h),
"https://host.docker.internal:4566/path"
);
assert_eq!(rewrite_loopback_value("127.0.0.1", h), h);
assert_eq!(rewrite_loopback_value("localhost", h), h);
assert_eq!(
rewrite_loopback_value("127.0.0.1:6379", h),
"host.docker.internal:6379"
);
assert_eq!(
rewrite_loopback_value("127.0.0.1:9092,localhost:9094", h),
"host.docker.internal:9092,host.docker.internal:9094"
);
assert_eq!(
rewrite_loopback_value("postgres://u:p@localhost:5432/db", h),
"postgres://u:p@host.docker.internal:5432/db"
);
assert_eq!(
rewrite_loopback_value("redis://127.0.0.1:6379/0", h),
"redis://host.docker.internal:6379/0"
);
assert_eq!(
rewrite_loopback_value("http://localhost/health", h),
"http://host.docker.internal/health"
);
assert_eq!(
rewrite_loopback_value("host=127.0.0.1:5432 user=x", h),
"host=host.docker.internal:5432 user=x"
);
}
#[test]
fn rewrite_loopback_leaves_prose_and_lookalikes_alone() {
let h = "host.docker.internal";
for v in [
"EHLO localhost",
"connect to localhost soon",
"127.0.0.10:80",
"localhost.localdomain:25",
"mylocalhost:80",
"",
] {
assert_eq!(rewrite_loopback_value(v, h), v, "{v:?}");
}
assert_eq!(
rewrite_loopback_value("http://localhost:4566", "127.0.0.1"),
"http://localhost:4566"
);
}
#[test]
fn rewrite_loopback_swaps_registered_container_ports() {
crate::dataplane::register_container_port(41001, 41002);
assert_eq!(
rewrite_loopback_value("127.0.0.1:41001", "host.docker.internal"),
"host.docker.internal:41002"
);
crate::dataplane::unregister_container_port(41001);
assert_eq!(
rewrite_loopback_value("127.0.0.1:41001", "host.docker.internal"),
"host.docker.internal:41001"
);
}
}