Skip to main content

fakecloud_lambda/runtime/
docker.rs

1//! Docker/Podman [`LambdaBackend`] implementation.
2//!
3//! Shells out to `docker` or `podman` CLI. Auto-detects which one is
4//! available; honors `FAKECLOUD_CONTAINER_CLI` as an override.
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use base64::Engine;
12use tempfile::TempDir;
13
14use super::backend::{BackendHandle, LambdaBackend, RuntimeError, WarmInstance};
15use super::env_rewrite::rewrite_localhost_envs;
16use crate::state::LambdaFunction;
17
18/// Docker/Podman-based Lambda execution backend.
19pub struct DockerBackend {
20    cli: String,
21    instance_id: String,
22    /// DNS name the container uses to reach fakecloud on the host. For
23    /// docker we use the cross-platform `host.docker.internal` alias (and
24    /// inject it via `--add-host host.docker.internal:host-gateway` on
25    /// Mac/Windows; bridge gateway IP on Linux). For podman we use its
26    /// built-in `host.containers.internal` alias, which podman injects
27    /// automatically without an `--add-host` flag — passing `host-gateway`
28    /// to podman on macOS fails with "host containers internal IP address
29    /// is empty" because podman's gvproxy network doesn't populate the
30    /// magic alias. See issue #1539.
31    host_alias: String,
32    /// `--add-host <alias>:<value>` argument injected into every container
33    /// `create`, or `None` when the runtime provides the alias natively.
34    add_host_arg: Option<String>,
35    /// Port the main fakecloud server bound to. Used to translate AWS
36    /// private-ECR URIs in `PackageType=Image` functions to fakecloud's
37    /// local OCI v2 registry.
38    server_port: u16,
39    /// Hostname fakecloud uses to reach sibling Lambda containers it
40    /// just spawned. `"127.0.0.1"` when fakecloud runs on the host (the
41    /// containers expose ports on the host loopback). When fakecloud is
42    /// itself running in a container with `/var/run/docker.sock`
43    /// bind-mounted, the spawned Lambda containers are *siblings* on
44    /// the host's daemon — their published ports are reachable from
45    /// inside fakecloud's container as `host.docker.internal:<port>`,
46    /// not `127.0.0.1:<port>`. Set via the `FAKECLOUD_IN_CONTAINER=1`
47    /// env var baked into the published image (issue #1539 Bug 4).
48    sibling_host: String,
49    /// Isolated DOCKER_CONFIG dir with Basic auth for `127.0.0.1:<port>`.
50    /// Lets `docker pull` talk to fakecloud ECR without mutating the user's
51    /// `~/.docker/config.json`.
52    docker_config: Option<Arc<TempDir>>,
53}
54
55impl DockerBackend {
56    /// Auto-detect Docker or Podman. Returns `None` if neither is available.
57    /// Override with `FAKECLOUD_CONTAINER_CLI` env var.
58    /// `server_port` is the port the main fakecloud server bound to; used
59    /// to resolve `PackageType=Image` ECR URIs against fakecloud ECR.
60    pub fn auto_detect(server_port: u16) -> Option<Self> {
61        let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
62            if std::process::Command::new(&cli)
63                .arg("info")
64                .stdout(std::process::Stdio::null())
65                .stderr(std::process::Stdio::null())
66                .status()
67                .map(|s| s.success())
68                .unwrap_or(false)
69            {
70                cli
71            } else {
72                return None;
73            }
74        } else if is_cli_available("docker") {
75            "docker".to_string()
76        } else if is_cli_available("podman") {
77            "podman".to_string()
78        } else {
79            return None;
80        };
81
82        let instance_id = format!("fakecloud-{}", std::process::id());
83
84        let (host_alias, add_host_arg) = if is_podman_binary(&cli) {
85            // Podman ships `host.containers.internal` as a built-in container
86            // DNS entry on every supported platform; injecting `host-gateway`
87            // on macOS fails because rootless podman's gvproxy doesn't
88            // expose the magic alias (issue #1539).
89            ("host.containers.internal".to_string(), None)
90        } else if cfg!(target_os = "linux") {
91            // Bare docker on Linux: resolve the bridge gateway IP and add
92            // an explicit alias. `host.docker.internal:host-gateway` only
93            // works on Docker Desktop; native Linux docker has no such
94            // magic.
95            let ip = detect_bridge_gateway(&cli).unwrap_or_else(|| "172.17.0.1".to_string());
96            (
97                "host.docker.internal".to_string(),
98                Some(format!("host.docker.internal:{ip}")),
99            )
100        } else {
101            // Docker Desktop on Mac/Windows: `host-gateway` is a Docker
102            // Desktop-only alias that resolves to the host's IP.
103            (
104                "host.docker.internal".to_string(),
105                Some("host.docker.internal:host-gateway".to_string()),
106            )
107        };
108
109        let docker_config = build_local_registry_docker_config(server_port).map(Arc::new);
110        let sibling_host = resolve_sibling_host(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
111        Some(Self {
112            cli,
113            instance_id,
114            host_alias,
115            add_host_arg,
116            server_port,
117            sibling_host,
118            docker_config,
119        })
120    }
121
122    /// Append `--add-host` arguments to `cmd` when the runtime needs an
123    /// explicit host alias mapping (docker on Linux/Mac/Windows). No-op
124    /// for podman, which provides `host.containers.internal` natively.
125    fn apply_host_alias(&self, cmd: &mut tokio::process::Command) {
126        if let Some(arg) = &self.add_host_arg {
127            cmd.arg("--add-host").arg(arg);
128        }
129    }
130
131    fn docker_config_path(&self) -> Option<PathBuf> {
132        self.docker_config.as_ref().map(|d| d.path().to_path_buf())
133    }
134
135    /// Start a container for a `PackageType=Image` function. The image is
136    /// expected to already embed the Runtime Interface Emulator (RIE) or
137    /// an equivalent, exposing port 8080. AWS private-ECR URIs get
138    /// translated to fakecloud's local OCI v2 registry and retagged so
139    /// the container reports its user-visible image name.
140    async fn start_image_container(
141        &self,
142        func: &LambdaFunction,
143        layers: &[Vec<u8>],
144    ) -> Result<WarmInstance, RuntimeError> {
145        let image = func.image_uri.as_deref().ok_or_else(|| {
146            RuntimeError::ContainerStartFailed("PackageType=Image function has no ImageUri".into())
147        })?;
148
149        // Point the registry host at `sibling_host` (not a bare
150        // `127.0.0.1`): when fakecloud runs in a container the daemon and
151        // the spawned sibling reach fakecloud's published registry port via
152        // `host.docker.internal`, since `127.0.0.1` is the host loopback
153        // from the daemon's view (issue #1539, bug 0.8). On the host,
154        // `sibling_host` is `127.0.0.1`, unchanged.
155        let local_pull_uri = fakecloud_core::ecr_uri::translate_to_local_at(
156            image,
157            &self.sibling_host,
158            self.server_port,
159        );
160        let pull_uri = local_pull_uri.as_deref().unwrap_or(image);
161
162        let mut pull_cmd = tokio::process::Command::new(&self.cli);
163        if let Some(p) = self.docker_config_path() {
164            pull_cmd.env("DOCKER_CONFIG", p);
165        }
166        let pull_out = pull_cmd
167            .args(["pull", pull_uri])
168            .output()
169            .await
170            .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
171        if !pull_out.status.success() {
172            return Err(RuntimeError::ContainerStartFailed(format!(
173                "docker pull failed: {}",
174                String::from_utf8_lossy(&pull_out.stderr)
175            )));
176        }
177        // Retag the local pull URI to the AWS URI so `docker create`
178        // finds the image under the user-visible name. Digest-pinned
179        // refs can't be `docker tag` targets, so fall through and
180        // create under the local URI instead.
181        let run_image = if let Some(ref local_uri) = local_pull_uri {
182            if fakecloud_core::ecr_uri::is_digest_ref(image) {
183                local_uri.clone()
184            } else {
185                let _ = tokio::process::Command::new(&self.cli)
186                    .args(["tag", local_uri, image])
187                    .output()
188                    .await;
189                image.to_string()
190            }
191        } else {
192            image.to_string()
193        };
194
195        let mut cmd = tokio::process::Command::new(&self.cli);
196        cmd.arg("create")
197            .arg("-p")
198            .arg(":8080")
199            .arg("--label")
200            .arg(format!("fakecloud-lambda={}", func.function_name))
201            .arg("--label")
202            .arg(format!("fakecloud-instance={}", self.instance_id));
203        self.apply_host_alias(&mut cmd);
204
205        for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) {
206            cmd.arg("-e").arg(format!("{key}={value}"));
207        }
208        cmd.arg("-e")
209            .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
210
211        let tmpfs_arg = ephemeral_storage_tmpfs_arg(func.ephemeral_storage_size);
212        cmd.arg("--tmpfs").arg(tmpfs_arg);
213
214        cmd.arg(&run_image);
215
216        let output = cmd
217            .output()
218            .await
219            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
220        if !output.status.success() {
221            return Err(RuntimeError::ContainerStartFailed(
222                String::from_utf8_lossy(&output.stderr).to_string(),
223            ));
224        }
225        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
226
227        if let Err(e) = self.copy_layers_into(&container_id, layers).await {
228            self.remove_container(&container_id).await;
229            return Err(e);
230        }
231
232        let start_result = tokio::process::Command::new(&self.cli)
233            .args(["start", &container_id])
234            .output()
235            .await
236            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
237        if !start_result.status.success() {
238            self.remove_container(&container_id).await;
239            return Err(RuntimeError::ContainerStartFailed(format!(
240                "docker start failed: {}",
241                String::from_utf8_lossy(&start_result.stderr)
242            )));
243        }
244
245        let port = self.query_host_port(&container_id).await?;
246        self.wait_for_ready(&container_id, port).await?;
247
248        tracing::info!(
249            function = %func.function_name,
250            container_id = %container_id,
251            port = port,
252            image = %image,
253            "Lambda image container started"
254        );
255
256        Ok(WarmInstance {
257            endpoint: format!("{}:{port}", self.sibling_host),
258            handle: BackendHandle::Container { id: container_id },
259        })
260    }
261
262    async fn start_zip_container(
263        &self,
264        func: &LambdaFunction,
265        zip_bytes: &[u8],
266        layers: &[Vec<u8>],
267    ) -> Result<WarmInstance, RuntimeError> {
268        let image = runtime_to_image(&func.runtime)
269            .ok_or_else(|| RuntimeError::UnsupportedRuntime(func.runtime.clone()))?;
270
271        // Extract ZIP to a temp directory (only needed during container setup).
272        // Run in spawn_blocking to avoid blocking the async runtime with fs I/O.
273        let code_dir =
274            TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
275        let zip_bytes = zip_bytes.to_vec();
276        let code_path = code_dir.path().to_path_buf();
277        tokio::task::spawn_blocking(move || extract_zip(&zip_bytes, &code_path))
278            .await
279            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
280
281        // Step 1: docker create (no volume mounts — works in Docker-in-Docker)
282        let mut cmd = tokio::process::Command::new(&self.cli);
283        cmd.arg("create")
284            .arg("-p")
285            .arg(":8080")
286            .arg("--label")
287            .arg(format!("fakecloud-lambda={}", func.function_name))
288            .arg("--label")
289            .arg(format!("fakecloud-instance={}", self.instance_id));
290        self.apply_host_alias(&mut cmd);
291
292        for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) {
293            cmd.arg("-e").arg(format!("{key}={value}"));
294        }
295
296        cmd.arg("-e")
297            .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
298
299        let tmpfs_arg = ephemeral_storage_tmpfs_arg(func.ephemeral_storage_size);
300        cmd.arg("--tmpfs").arg(tmpfs_arg);
301
302        cmd.arg(&image).arg(&func.handler);
303
304        let output = cmd
305            .output()
306            .await
307            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
308
309        if !output.status.success() {
310            let stderr = String::from_utf8_lossy(&output.stderr);
311            return Err(RuntimeError::ContainerStartFailed(stderr.to_string()));
312        }
313
314        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
315
316        // Step 2: docker cp — copy code into the container
317        let cp_result = tokio::process::Command::new(&self.cli)
318            .arg("cp")
319            .arg(format!("{}/.", code_dir.path().display()))
320            .arg(format!("{}:/var/task", container_id))
321            .output()
322            .await
323            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
324
325        if !cp_result.status.success() {
326            self.remove_container(&container_id).await;
327            let stderr = String::from_utf8_lossy(&cp_result.stderr);
328            return Err(RuntimeError::ContainerStartFailed(format!(
329                "docker cp failed: {stderr}"
330            )));
331        }
332
333        // For provided/custom runtimes, also copy to /var/runtime
334        if func.runtime.starts_with("provided") {
335            let cp_runtime = tokio::process::Command::new(&self.cli)
336                .arg("cp")
337                .arg(format!("{}/.", code_dir.path().display()))
338                .arg(format!("{}:/var/runtime", container_id))
339                .output()
340                .await
341                .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
342
343            if !cp_runtime.status.success() {
344                self.remove_container(&container_id).await;
345                let stderr = String::from_utf8_lossy(&cp_runtime.stderr);
346                return Err(RuntimeError::ContainerStartFailed(format!(
347                    "docker cp to /var/runtime failed: {stderr}"
348                )));
349            }
350        }
351
352        if let Err(e) = self.copy_layers_into(&container_id, layers).await {
353            self.remove_container(&container_id).await;
354            return Err(e);
355        }
356
357        // TempDir is dropped here — code now lives inside the container
358
359        let start_result = tokio::process::Command::new(&self.cli)
360            .args(["start", &container_id])
361            .output()
362            .await
363            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
364
365        if !start_result.status.success() {
366            self.remove_container(&container_id).await;
367            let stderr = String::from_utf8_lossy(&start_result.stderr);
368            return Err(RuntimeError::ContainerStartFailed(format!(
369                "docker start failed: {stderr}"
370            )));
371        }
372
373        let port = self.query_host_port(&container_id).await?;
374        self.wait_for_ready(&container_id, port).await?;
375
376        tracing::info!(
377            function = %func.function_name,
378            container_id = %container_id,
379            port = port,
380            runtime = %func.runtime,
381            "Lambda container started"
382        );
383
384        Ok(WarmInstance {
385            endpoint: format!("{}:{port}", self.sibling_host),
386            handle: BackendHandle::Container { id: container_id },
387        })
388    }
389
390    async fn query_host_port(&self, container_id: &str) -> Result<u16, RuntimeError> {
391        let port_output = tokio::process::Command::new(&self.cli)
392            .args(["port", container_id, "8080"])
393            .output()
394            .await
395            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
396        let port_str = String::from_utf8_lossy(&port_output.stdout);
397        port_str
398            .trim()
399            .rsplit(':')
400            .next()
401            .and_then(|p| p.parse().ok())
402            .ok_or_else(|| {
403                RuntimeError::ContainerStartFailed(format!(
404                    "could not determine port from: {}",
405                    port_str.trim()
406                ))
407            })
408    }
409
410    async fn wait_for_ready(&self, container_id: &str, port: u16) -> Result<(), RuntimeError> {
411        for _ in 0..20 {
412            tokio::time::sleep(Duration::from_millis(500)).await;
413            if tokio::net::TcpStream::connect(format!("{}:{port}", self.sibling_host))
414                .await
415                .is_ok()
416            {
417                return Ok(());
418            }
419        }
420        self.remove_container(container_id).await;
421        Err(RuntimeError::ContainerStartFailed(
422            "container did not become ready within 10 seconds".to_string(),
423        ))
424    }
425
426    /// Extract each layer ZIP into a shared temp directory and `docker cp`
427    /// it into `/opt/` of the target container. Layer ZIPs include
428    /// language-specific subpaths (`python/`, `nodejs/`, `java/`, `lib/`,
429    /// `bin/`) that AWS base images already wire onto the runtime's
430    /// import paths, so plain extraction at the temp root produces the
431    /// correct on-disk layout. Empty `layers` is a no-op.
432    async fn copy_layers_into(
433        &self,
434        container_id: &str,
435        layers: &[Vec<u8>],
436    ) -> Result<(), RuntimeError> {
437        if layers.is_empty() {
438            return Ok(());
439        }
440        let layers_dir =
441            TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
442        let layers_path = layers_dir.path().to_path_buf();
443        let layers_owned: Vec<Vec<u8>> = layers.to_vec();
444        tokio::task::spawn_blocking(move || {
445            for bytes in &layers_owned {
446                extract_zip(bytes, &layers_path)?;
447            }
448            Ok::<_, RuntimeError>(())
449        })
450        .await
451        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
452
453        let cp_result = tokio::process::Command::new(&self.cli)
454            .arg("cp")
455            .arg(format!("{}/.", layers_dir.path().display()))
456            .arg(format!("{}:/opt", container_id))
457            .output()
458            .await
459            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
460        if !cp_result.status.success() {
461            let stderr = String::from_utf8_lossy(&cp_result.stderr);
462            return Err(RuntimeError::ContainerStartFailed(format!(
463                "docker cp layers to /opt failed: {stderr}"
464            )));
465        }
466        Ok(())
467    }
468
469    /// Remove a container (stop + rm, since we don't use --rm with docker create).
470    async fn remove_container(&self, container_id: &str) {
471        let _ = tokio::process::Command::new(&self.cli)
472            .args(["rm", "-f", container_id])
473            .output()
474            .await;
475    }
476}
477
478#[async_trait]
479impl LambdaBackend for DockerBackend {
480    fn name(&self) -> &str {
481        &self.cli
482    }
483
484    async fn launch(
485        &self,
486        func: &LambdaFunction,
487        code_zip: Option<&[u8]>,
488        layers: &[Vec<u8>],
489        _deploy_id: &str,
490    ) -> Result<WarmInstance, RuntimeError> {
491        if func.package_type == "Image" {
492            self.start_image_container(func, layers).await
493        } else {
494            let bytes =
495                code_zip.ok_or_else(|| RuntimeError::NoCodeZip(func.function_name.clone()))?;
496            self.start_zip_container(func, bytes, layers).await
497        }
498    }
499
500    async fn terminate(&self, handle: &BackendHandle) {
501        match handle {
502            BackendHandle::Container { id } => self.remove_container(id).await,
503            // Pod handles belong to the K8s backend — defensive no-op
504            // so a mis-wired multi-backend setup doesn't panic.
505            BackendHandle::Pod { .. } => {}
506        }
507    }
508
509    async fn prepull_image(&self, image: &str) -> Result<(), RuntimeError> {
510        // Translate AWS-flavored ECR URIs to fakecloud's local registry so
511        // private-ECR `Image` package functions can be warmed too. Falls
512        // back to the URI as-is for public-ECR / Docker Hub / Quay images.
513        let local_uri = fakecloud_core::ecr_uri::translate_to_local_at(
514            image,
515            &self.sibling_host,
516            self.server_port,
517        );
518        let pull_uri = local_uri.as_deref().unwrap_or(image);
519
520        let mut cmd = tokio::process::Command::new(&self.cli);
521        if let Some(p) = self.docker_config_path() {
522            cmd.env("DOCKER_CONFIG", p);
523        }
524        let out = cmd
525            .args(["pull", pull_uri])
526            .output()
527            .await
528            .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
529        if !out.status.success() {
530            return Err(RuntimeError::ContainerStartFailed(format!(
531                "docker pull failed for {pull_uri}: {}",
532                String::from_utf8_lossy(&out.stderr)
533            )));
534        }
535        Ok(())
536    }
537}
538
539/// Map AWS runtime identifier to a Docker image tag.
540pub fn runtime_to_image(runtime: &str) -> Option<String> {
541    let (base, tag) = match runtime {
542        "python3.14" => ("python", "3.14"),
543        "python3.13" => ("python", "3.13"),
544        "python3.12" => ("python", "3.12"),
545        "python3.11" => ("python", "3.11"),
546        "python3.10" => ("python", "3.10"),
547        "python3.9" => ("python", "3.9"),
548        "python3.8" => ("python", "3.8"),
549        "nodejs24.x" => ("nodejs", "24"),
550        "nodejs22.x" => ("nodejs", "22"),
551        "nodejs20.x" => ("nodejs", "20"),
552        "nodejs18.x" => ("nodejs", "18"),
553        "nodejs16.x" => ("nodejs", "16"),
554        "ruby3.4" => ("ruby", "3.4"),
555        "ruby3.3" => ("ruby", "3.3"),
556        "java25" => ("java", "25"),
557        "java21" => ("java", "21"),
558        "java17" => ("java", "17"),
559        "java11" => ("java", "11"),
560        "dotnet10" => ("dotnet", "10"),
561        "dotnet8" => ("dotnet", "8"),
562        "go1.x" => ("go", "1"),
563        "provided.al2023" => ("provided", "al2023"),
564        "provided.al2" => ("provided", "al2"),
565        _ => return None,
566    };
567    Some(format!("public.ecr.aws/lambda/{base}:{tag}"))
568}
569
570/// Build the `--tmpfs` argument string used by `docker create` so that
571/// `/tmp` inside the container is sized to the function's
572/// `EphemeralStorage.Size`. Pure helper extracted from the container
573/// boot path so unit tests can verify the flag without spawning Docker.
574///
575/// Defaults to AWS's 512 MiB when `size` is `None`, and clamps to a 64
576/// MiB minimum so legacy snapshots that smuggled in absurd values still
577/// produce a tmpfs Docker accepts. The `exec` mount option matches AWS
578/// Lambda's `/tmp` behavior — handlers that unpack and run binaries
579/// from `/tmp` would otherwise hit `EACCES` against Docker's default
580/// `noexec` tmpfs.
581pub(crate) fn ephemeral_storage_tmpfs_arg(size: Option<i64>) -> String {
582    let mib = size.unwrap_or(512).max(64);
583    format!("/tmp:size={mib}m,exec")
584}
585
586/// Extract a ZIP archive to a destination directory.
587pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<(), RuntimeError> {
588    let cursor = std::io::Cursor::new(zip_bytes);
589    let mut archive = zip::ZipArchive::new(cursor)
590        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
591
592    for i in 0..archive.len() {
593        let mut file = archive
594            .by_index(i)
595            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
596
597        let out_path = dest.join(file.enclosed_name().ok_or_else(|| {
598            RuntimeError::ZipExtractionFailed("invalid file name in ZIP".to_string())
599        })?);
600
601        if file.is_dir() {
602            std::fs::create_dir_all(&out_path)
603                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
604        } else {
605            if let Some(parent) = out_path.parent() {
606                std::fs::create_dir_all(parent)
607                    .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
608            }
609            let mut out_file = std::fs::File::create(&out_path)
610                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
611            std::io::copy(&mut file, &mut out_file)
612                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
613
614            #[cfg(unix)]
615            {
616                use std::os::unix::fs::PermissionsExt;
617                if let Some(mode) = file.unix_mode() {
618                    std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
619                        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
620                }
621            }
622        }
623    }
624    Ok(())
625}
626
627/// Detect the Docker bridge gateway IP on Linux. Returns None if detection fails.
628fn detect_bridge_gateway(cli: &str) -> Option<String> {
629    let output = std::process::Command::new(cli)
630        .args([
631            "network",
632            "inspect",
633            "bridge",
634            "--format",
635            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
636        ])
637        .output()
638        .ok()?;
639
640    if output.status.success() {
641        let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
642        if !gateway.is_empty() && gateway.contains('.') {
643            tracing::info!(
644                gateway = %gateway,
645                "Detected Docker bridge gateway for Lambda containers"
646            );
647            return Some(gateway);
648        }
649    }
650    None
651}
652
653/// Decide what loopback address fakecloud uses to reach the *sibling*
654/// Lambda containers it just spawned. Pure helper so the env-var
655/// parsing can be tested without touching the process's real
656/// environment (which would race with parallel tests).
657///
658/// - `Some("1")` or `Some("true")` -> fakecloud is in a container,
659///   sibling published ports live on `host.docker.internal:<port>`.
660/// - Anything else, including `None` -> fakecloud runs on the host,
661///   sibling ports live on `127.0.0.1:<port>`.
662fn resolve_sibling_host(env_value: Option<String>) -> String {
663    let in_container = env_value
664        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
665        .unwrap_or(false);
666    if in_container {
667        "host.docker.internal".to_string()
668    } else {
669        "127.0.0.1".to_string()
670    }
671}
672
673fn is_cli_available(name: &str) -> bool {
674    std::process::Command::new(name)
675        .arg("info")
676        .stdout(std::process::Stdio::null())
677        .stderr(std::process::Stdio::null())
678        .status()
679        .map(|s| s.success())
680        .unwrap_or(false)
681}
682
683/// True when `cli` is podman or a podman-compatible binary. Matches on the
684/// filename component so absolute paths (`/opt/homebrew/bin/podman`) and
685/// wrappers (`podman-remote`) both register as podman. Docker Desktop's
686/// compatibility CLI is named `docker`, so this check is safe.
687fn is_podman_binary(cli: &str) -> bool {
688    std::path::Path::new(cli)
689        .file_name()
690        .and_then(|n| n.to_str())
691        .map(|n| n.contains("podman"))
692        .unwrap_or(false)
693}
694
695fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
696    let dir = TempDir::new().ok()?;
697    let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-lambda-runtime");
698    // Authorize both hostnames fakecloud's ECR can be addressed by:
699    // `127.0.0.1` on the host, and `host.docker.internal` when fakecloud
700    // runs in a container and the pull URI is rewritten to the sibling
701    // host (issue #1539, bug 0.8).
702    let config = serde_json::json!({
703        "auths": {
704            format!("127.0.0.1:{server_port}"): { "auth": auth },
705            format!("host.docker.internal:{server_port}"): { "auth": auth },
706        }
707    });
708    std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
709    Some(dir)
710}
711
712#[cfg(test)]
713mod tests {
714    use std::io::{Read, Write};
715
716    use super::*;
717
718    #[test]
719    fn test_runtime_to_image() {
720        assert_eq!(
721            runtime_to_image("python3.12"),
722            Some("public.ecr.aws/lambda/python:3.12".to_string())
723        );
724        assert_eq!(
725            runtime_to_image("nodejs20.x"),
726            Some("public.ecr.aws/lambda/nodejs:20".to_string())
727        );
728        assert_eq!(
729            runtime_to_image("provided.al2023"),
730            Some("public.ecr.aws/lambda/provided:al2023".to_string())
731        );
732        assert_eq!(
733            runtime_to_image("ruby3.4"),
734            Some("public.ecr.aws/lambda/ruby:3.4".to_string())
735        );
736        assert_eq!(
737            runtime_to_image("java21"),
738            Some("public.ecr.aws/lambda/java:21".to_string())
739        );
740        assert_eq!(
741            runtime_to_image("dotnet8"),
742            Some("public.ecr.aws/lambda/dotnet:8".to_string())
743        );
744        assert_eq!(
745            runtime_to_image("nodejs16.x"),
746            Some("public.ecr.aws/lambda/nodejs:16".to_string())
747        );
748        assert_eq!(
749            runtime_to_image("python3.10"),
750            Some("public.ecr.aws/lambda/python:3.10".to_string())
751        );
752        assert_eq!(
753            runtime_to_image("python3.9"),
754            Some("public.ecr.aws/lambda/python:3.9".to_string())
755        );
756        assert_eq!(
757            runtime_to_image("python3.8"),
758            Some("public.ecr.aws/lambda/python:3.8".to_string())
759        );
760        assert_eq!(
761            runtime_to_image("java11"),
762            Some("public.ecr.aws/lambda/java:11".to_string())
763        );
764        assert_eq!(
765            runtime_to_image("go1.x"),
766            Some("public.ecr.aws/lambda/go:1".to_string())
767        );
768        assert_eq!(
769            runtime_to_image("nodejs24.x"),
770            Some("public.ecr.aws/lambda/nodejs:24".to_string())
771        );
772        assert_eq!(
773            runtime_to_image("python3.14"),
774            Some("public.ecr.aws/lambda/python:3.14".to_string())
775        );
776        assert_eq!(
777            runtime_to_image("java25"),
778            Some("public.ecr.aws/lambda/java:25".to_string())
779        );
780        assert_eq!(
781            runtime_to_image("dotnet10"),
782            Some("public.ecr.aws/lambda/dotnet:10".to_string())
783        );
784        assert_eq!(runtime_to_image("unknown"), None);
785    }
786
787    #[test]
788    fn is_podman_binary_matches_bare_name() {
789        assert!(is_podman_binary("podman"));
790        assert!(is_podman_binary("podman-remote"));
791    }
792
793    #[test]
794    fn is_podman_binary_matches_absolute_path() {
795        assert!(is_podman_binary("/opt/homebrew/bin/podman"));
796        assert!(is_podman_binary("/usr/local/bin/podman-remote"));
797    }
798
799    #[test]
800    fn is_podman_binary_rejects_docker() {
801        assert!(!is_podman_binary("docker"));
802        assert!(!is_podman_binary("/usr/local/bin/docker"));
803        // Docker Desktop's compatibility CLI is `docker`, not `podman`.
804        assert!(!is_podman_binary("docker-credential-helper"));
805    }
806
807    #[test]
808    fn resolve_sibling_host_defaults_to_loopback() {
809        assert_eq!(resolve_sibling_host(None), "127.0.0.1");
810        assert_eq!(resolve_sibling_host(Some("".to_string())), "127.0.0.1");
811        assert_eq!(resolve_sibling_host(Some("0".to_string())), "127.0.0.1");
812        assert_eq!(resolve_sibling_host(Some("false".to_string())), "127.0.0.1");
813    }
814
815    #[test]
816    fn resolve_sibling_host_uses_docker_internal_when_in_container() {
817        assert_eq!(
818            resolve_sibling_host(Some("1".to_string())),
819            "host.docker.internal"
820        );
821        assert_eq!(
822            resolve_sibling_host(Some("true".to_string())),
823            "host.docker.internal"
824        );
825        assert_eq!(
826            resolve_sibling_host(Some("TRUE".to_string())),
827            "host.docker.internal"
828        );
829    }
830
831    #[test]
832    fn test_extract_zip() {
833        let buf = Vec::new();
834        let cursor = std::io::Cursor::new(buf);
835        let mut writer = zip::ZipWriter::new(cursor);
836        let options = zip::write::SimpleFileOptions::default();
837        writer.start_file("handler.py", options).unwrap();
838        writer
839            .write_all(b"def handler(event, context):\n    return {'statusCode': 200}\n")
840            .unwrap();
841        let cursor = writer.finish().unwrap();
842        let zip_bytes = cursor.into_inner();
843
844        let dir = TempDir::new().unwrap();
845        extract_zip(&zip_bytes, dir.path()).unwrap();
846
847        let handler_path = dir.path().join("handler.py");
848        assert!(handler_path.exists());
849
850        let mut content = String::new();
851        std::fs::File::open(&handler_path)
852            .unwrap()
853            .read_to_string(&mut content)
854            .unwrap();
855        assert!(content.contains("def handler"));
856    }
857
858    #[test]
859    fn ephemeral_storage_tmpfs_arg_defaults_to_512_when_none() {
860        // None -> AWS default of 512 MiB. The `exec` flag is required so
861        // handlers that unpack and run binaries from /tmp don't hit
862        // EACCES against Docker's default `noexec` tmpfs.
863        assert_eq!(ephemeral_storage_tmpfs_arg(None), "/tmp:size=512m,exec");
864    }
865
866    #[test]
867    fn ephemeral_storage_tmpfs_arg_uses_supplied_size() {
868        assert_eq!(
869            ephemeral_storage_tmpfs_arg(Some(2048)),
870            "/tmp:size=2048m,exec"
871        );
872        assert_eq!(
873            ephemeral_storage_tmpfs_arg(Some(10240)),
874            "/tmp:size=10240m,exec"
875        );
876    }
877
878    #[test]
879    fn ephemeral_storage_tmpfs_arg_clamps_to_64_floor() {
880        // API-level validation already rejects values below 512, but the
881        // runtime defends against legacy snapshots and stale state by
882        // clamping to a 64 MiB floor that Docker still accepts.
883        assert_eq!(ephemeral_storage_tmpfs_arg(Some(0)), "/tmp:size=64m,exec");
884        assert_eq!(ephemeral_storage_tmpfs_arg(Some(32)), "/tmp:size=64m,exec");
885    }
886}