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)
}
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 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_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 {
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> {
[
"localhost",
"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(&"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 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 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()
);
}
}