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 = std::process::Command::new(cli)
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let Ok(mut child) = child else {
return false;
};
let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {}
Err(_) => return false,
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return false;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
pub fn is_podman_binary(cli: &str) -> bool {
std::path::Path::new(cli)
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.contains("podman"))
.unwrap_or(false)
}
pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
let output = std::process::Command::new(cli)
.args([
"network",
"inspect",
"bridge",
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let gateway = String::from_utf8_lossy(&output.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, add_host_arg) = resolve_host_alias(cli);
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 fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
if is_podman_binary(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 {
let in_container = env_value
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if in_container {
host_alias.to_string()
} else {
"127.0.0.1".to_string()
}
}
pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
[
"127.0.0.1",
"host.docker.internal",
"host.containers.internal",
]
.iter()
.map(|host| format!("{host}:{server_port}"))
.collect()
}
#[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 is_podman_binary_matches_bare_name() {
assert!(is_podman_binary("podman"));
assert!(is_podman_binary("podman-remote"));
}
#[test]
fn registry_auth_hosts_includes_podman_alias() {
let hosts = registry_auth_hosts(4566);
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 is_podman_binary_matches_absolute_path() {
assert!(is_podman_binary("/opt/homebrew/bin/podman"));
assert!(is_podman_binary("/usr/local/bin/podman-remote"));
}
#[test]
fn is_podman_binary_rejects_docker() {
assert!(!is_podman_binary("docker"));
assert!(!is_podman_binary("/usr/local/bin/docker"));
assert!(!is_podman_binary("docker-credential-helper"));
}
#[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]
fn resolve_host_alias_docker_emits_add_host() {
let (alias, add_host) = resolve_host_alias("docker");
assert_eq!(alias, "host.docker.internal");
assert!(add_host.is_some());
assert!(add_host.unwrap().starts_with("host.docker.internal:"));
}
#[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 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(),
]
);
}
}