diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index 0c38538657da28..afe02b4950dafd 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -2301,10 +2301,10 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true let mut parsed_line = line.to_string(); // Replace from devcontainer args first, since they take precedence for (key, value) in &devcontainer_args { - parsed_line = parsed_line.replace(&format!("${{{key}}}"), value) + parsed_line = expand_dockerfile_var(parsed_line, key, value); } for (key, value) in &inline_args { - parsed_line = parsed_line.replace(&format!("${{{key}}}"), value); + parsed_line = expand_dockerfile_var(parsed_line, key, value); } if let Some(arg_directives) = parsed_line.strip_prefix("ARG ") { let trimmed = arg_directives.trim(); @@ -2442,6 +2442,30 @@ enum DevContainerBuildResources { Docker(DockerBuildResources), } +/// Replaces occurrences of `${KEY}` and `$KEY` in `line` with `value`. +/// Bare `$KEY` is only replaced when the character immediately after the key +/// is not a word character (`[A-Za-z0-9_]`), so `$RUBY_VERSION2` is not +/// partially consumed when expanding `$RUBY_VERSION`. +fn expand_dockerfile_var(mut line: String, key: &str, value: &str) -> String { + line = line.replace(&format!("${{{key}}}"), value); + let pattern = format!("${key}"); + let mut result = String::with_capacity(line.len()); + let mut remaining = line.as_str(); + while let Some(pos) = remaining.find(pattern.as_str()) { + result.push_str(&remaining[..pos]); + let after = &remaining[pos + pattern.len()..]; + if after.starts_with(|c: char| c.is_alphanumeric() || c == '_') { + result.push('$'); + remaining = &remaining[pos + 1..]; + } else { + result.push_str(value); + remaining = after; + } + } + result.push_str(remaining); + result +} + fn find_primary_service( docker_compose: &DockerComposeResources, devcontainer: &DevContainerManifest, @@ -5951,6 +5975,55 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de assert_eq!(base_image, "test_image:latest"); } + #[gpui::test] + async fn test_expands_bare_dollar_args_in_dockerfile(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + { + "name": "ruby-devcontainer", + "build": { + "dockerfile": "Dockerfile", + }, + } + "#; + + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + // Mirrors real-world Dockerfiles that use bare $VAR instead of ${VAR}. + // $RUBY_VERSION2 must not be partially replaced when expanding $RUBY_VERSION. + r#" +ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION2=3.3.0 +FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION +RUN echo $RUBY_VERSION2 + "# + .trim() + .to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let expanded = devcontainer_manifest + .expanded_dockerfile_content() + .await + .unwrap(); + + assert_eq!( + expanded, + "ARG RUBY_VERSION=3.4.4\nARG RUBY_VERSION2=3.3.0\nFROM ghcr.io/rails/devcontainer/images/ruby:3.4.4\nRUN echo 3.3.0" + ); + } + #[cfg(not(target_os = "windows"))] #[gpui::test] async fn check_for_existing_container_errors_when_multiple_match(cx: &mut TestAppContext) {