From 6bad71826bf2d03f6abfdb832ce4dd76564e7043 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 00:09:00 -0500 Subject: [PATCH] feat(config): resolve variable substitution in extends paths The devcontainer spec allows variable substitution (e.g., ${localEnv:X}, ${localWorkspaceFolder}) in all string properties including extends paths. Previously, extends resolution happened before variable substitution, so variable references in extends paths were used as literal strings. Apply variable substitution to extends ref strings before the extends resolver attempts to open the referenced files. This handles both the top-level ParseDevContainerJSONFile entry point and the recursive parseDevContainerJSONFileWithVisited path. --- pkg/devcontainer/config/extends.go | 3 +- pkg/devcontainer/config/extends_test.go | 90 +++++++++++++++++++++++++ pkg/devcontainer/config/parse.go | 35 +++++++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/pkg/devcontainer/config/extends.go b/pkg/devcontainer/config/extends.go index dee5196c8..f8382dc16 100644 --- a/pkg/devcontainer/config/extends.go +++ b/pkg/devcontainer/config/extends.go @@ -122,8 +122,9 @@ func parseDevContainerJSONFileWithVisited( } devContainer.Origin = absPath - // Recursively resolve extends + // Recursively resolve extends — substitute variables in refs first if !devContainer.Extends.IsEmpty() { + devContainer.Extends = substituteExtendsRefs(devContainer.Extends, absPath) declaringDir := filepath.Dir(absPath) parent, err := resolveExtendsArray(ctx, devContainer.Extends, declaringDir, visited) if err != nil { diff --git a/pkg/devcontainer/config/extends_test.go b/pkg/devcontainer/config/extends_test.go index 18e89113f..5493ae153 100644 --- a/pkg/devcontainer/config/extends_test.go +++ b/pkg/devcontainer/config/extends_test.go @@ -678,6 +678,96 @@ func TestExtendsRef_MarshalJSON(t *testing.T) { } } +func TestExtends_VarSub_LocalEnv(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, ".devcontainer") + // #nosec G301 -- test directory + if err := os.MkdirAll(subDir, 0o750); err != nil { + t.Fatal(err) + } + + writeJSON(t, subDir, "parent.json", `{ + "image": "ubuntu:22.04", + "remoteUser": "dev" + }`) + + t.Setenv("DEVSY_TEST_EXTENDS_DIR", subDir) + + childPath := writeJSON(t, subDir, "devcontainer.json", `{ + "extends": "${localEnv:DEVSY_TEST_EXTENDS_DIR}/parent.json", + "name": "child-with-env" + }`) + + cfg, err := ParseDevContainerJSONFile(childPath) + if err != nil { + t.Fatal(err) + } + if cfg.Name != "child-with-env" { + t.Errorf("expected name 'child-with-env', got %q", cfg.Name) + } + if cfg.Image != "ubuntu:22.04" { + t.Errorf("expected image inherited from parent, got %q", cfg.Image) + } + if cfg.RemoteUser != "dev" { + t.Errorf("expected remoteUser 'dev', got %q", cfg.RemoteUser) + } +} + +func TestExtends_VarSub_LocalWorkspaceFolder(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, ".devcontainer") + // #nosec G301 -- test directory + if err := os.MkdirAll(subDir, 0o750); err != nil { + t.Fatal(err) + } + + writeJSON(t, tmpDir, "base-config.json", `{ + "image": "node:20", + "remoteUser": "node" + }`) + + // ${localWorkspaceFolder} should resolve to the parent of .devcontainer + childPath := writeJSON(t, subDir, "devcontainer.json", `{ + "extends": "${localWorkspaceFolder}/base-config.json", + "name": "workspace-ref" + }`) + + cfg, err := ParseDevContainerJSONFile(childPath) + if err != nil { + t.Fatal(err) + } + if cfg.Name != "workspace-ref" { + t.Errorf("expected name 'workspace-ref', got %q", cfg.Name) + } + if cfg.Image != "node:20" { + t.Errorf("expected image 'node:20', got %q", cfg.Image) + } +} + +func TestExtends_VarSub_MissingEnvResolvesToEmpty(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, ".devcontainer") + // #nosec G301 -- test directory + if err := os.MkdirAll(subDir, 0o750); err != nil { + t.Fatal(err) + } + + // Ensure the env var is unset + t.Setenv("DEVSY_TEST_NONEXISTENT_VAR", "") + _ = os.Unsetenv("DEVSY_TEST_NONEXISTENT_VAR") + + childPath := writeJSON(t, subDir, "devcontainer.json", `{ + "extends": "${localEnv:DEVSY_TEST_NONEXISTENT_VAR}/parent.json", + "name": "missing-env" + }`) + + // Missing env var resolves to empty string, resulting in an invalid path + _, err := ParseDevContainerJSONFile(childPath) + if err == nil { + t.Fatal("expected error when env var resolves to empty and path is invalid") + } +} + func strPtr(s string) *string { return &s } diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index d81aeb977..0e92bfee2 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -8,6 +8,7 @@ import ( "os" path2 "path" "path/filepath" + "runtime" "strings" "unicode/utf8" @@ -97,8 +98,11 @@ func ParseDevContainerJSONFile(jsonFilePath string) (*DevContainerConfig, error) } devContainer.Origin = path - // Resolve extends before applying legacy transforms + // Resolve extends before applying legacy transforms. + // Variable substitution must be applied to extends paths first so that + // ${localEnv:X}, ${localWorkspaceFolder}, etc. resolve before path lookup. if !devContainer.Extends.IsEmpty() { + devContainer.Extends = substituteExtendsRefs(devContainer.Extends, path) visited := map[string]bool{path: true} declaringDir := filepath.Dir(path) parent, err := resolveExtendsArray( @@ -284,6 +288,35 @@ func Convert(from any, to any) error { return json.Unmarshal(out, to) } +// substituteExtendsRefs applies variable substitution to extends path strings +// so that ${localEnv:X}, ${localWorkspaceFolder}, etc. are resolved before the +// extends resolver attempts to open the referenced files. +func substituteExtendsRefs(refs ExtendsRef, configFilePath string) ExtendsRef { + localWorkspaceFolder := filepath.Dir(filepath.Dir(configFilePath)) + env := ListToObject(os.Environ()) + isWindows := runtime.GOOS == "windows" + if isWindows { + newEnv := map[string]string{} + for k, v := range env { + newEnv[strings.ToLower(k)] = v + } + env = newEnv + } + + subCtx := &SubstitutionContext{ + LocalWorkspaceFolder: localWorkspaceFolder, + Env: env, + } + + result := make(ExtendsRef, len(refs)) + for i, ref := range refs { + result[i] = ResolveString(ref, func(match, variable string, args []string) string { + return replaceWithContext(isWindows, subCtx, match, variable, args) + }) + } + return result +} + func ParseKeyValueFile(filename string) ([]string, error) { f, err := os.Open(filename) if err != nil {