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 {