From 6d9f28f3184bdc81ecb95b3c22cb35f69e66b46d Mon Sep 17 00:00:00 2001 From: abdelhadi703 Date: Sat, 10 Oct 2026 14:26:42 +0200 Subject: [PATCH 1/3] Fix variable interpolation in .env files loaded through envFile --- Extension/CHANGELOG.md | 1 + .../src/Debugger/ParsedEnvironmentFile.ts | 11 ++++++++ .../tests/ParsedEnvironmentFile.test.ts | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/Extension/CHANGELOG.md b/Extension/CHANGELOG.md index b8c8b62db..dfdaabcdc 100644 --- a/Extension/CHANGELOG.md +++ b/Extension/CHANGELOG.md @@ -15,6 +15,7 @@ * Reduce CPU usage when resolving headers in large Windows workspaces. ### Bug Fixes +* Fix `${VAR}` interpolation in `.env` files used by `envFile`: references to variables defined earlier in the same file (or in the OS environment) are now resolved. [#13781](https://github.com/microsoft/vscode-cpptools/issues/13781) * Fix 'Go to Declaration' and 'Go to Definition' returning the current location instead of an available counterpart. [#14509](https://github.com/microsoft/vscode-cpptools/issues/14509) * Update MIEngine to fix debugger failures with synthetic GDB stack frames. [#14741](https://github.com/microsoft/vscode-cpptools/issues/14741) * Thanks for the contribution. [@tzcnt](https://github.com/tzcnt) [PR MIEngine#1605](https://github.com/microsoft/MIEngine/pull/1605) diff --git a/Extension/src/Debugger/ParsedEnvironmentFile.ts b/Extension/src/Debugger/ParsedEnvironmentFile.ts index 9018a540b..311fda21b 100644 --- a/Extension/src/Debugger/ParsedEnvironmentFile.ts +++ b/Extension/src/Debugger/ParsedEnvironmentFile.ts @@ -59,6 +59,17 @@ export class ParsedEnvironmentFile { value = value.replace(/(^['"]|['"]$)/g, ""); + // Resolve ${VAR} references against the variables defined so far in the file + // (and the process environment), following the dotenv interpolation rules. + // A reference to a name we don't know is left as-is instead of being dropped. + value = value.replace(/\$\{([\w\.\-]+)\}/g, (full: string, name: string): string => { + if (env.has(name)) { + return env.get(name); + } + const processValue: string | undefined = process.env[name]; + return processValue !== undefined ? processValue : full; + }); + env.set(key, value); } else { // Blank lines and lines starting with # are no parse errors diff --git a/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts b/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts index a107971ce..6e8abff01 100644 --- a/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts +++ b/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts @@ -100,4 +100,31 @@ MyName2=Value2 assertEnvironmentEqual(result.Env, "MyName1", "Value1"); assertEnvironmentEqual(result.Env, "MyName2", "Value2"); }); + + test("Resolve a reference to a variable defined earlier in the file", () => { + const content: string = `USER=admin +EMAIL=\${USER}@example.org`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "USER", "admin"); + assertEnvironmentEqual(result.Env, "EMAIL", "admin@example.org"); + }); + + test("Resolve a reference from the initial environment", () => { + const initialEnv: Environment[] = [{ name: "USER", value: "admin" }]; + const content: string = `EMAIL=\${USER}@example.org`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", initialEnv); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "EMAIL", "admin@example.org"); + }); + + test("Leave an unknown reference untouched", () => { + const content: string = `EMAIL=\${UNKNOWN_USER}@example.org`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "EMAIL", "${UNKNOWN_USER}@example.org"); + }); }); From 66ea67b844e043fa12fb46e933ff7f77e5c2cc7f Mon Sep 17 00:00:00 2001 From: abdelhadi703 Date: Wed, 14 Oct 2026 00:31:15 +0200 Subject: [PATCH 2/3] Leave single-quoted and escaped references alone in .env interpolation The quotes come off before the references are resolved, so VALUE='${SECRET}' was expanded and a \${SECRET} reference kept its backslash while losing its meaning. Both are literal in dotenv, and both were literal before the interpolation existed. Adds the missing test for the process-environment fallback the changelog advertises. --- Extension/CHANGELOG.md | 2 +- .../src/Debugger/ParsedEnvironmentFile.ts | 31 ++++++++++------ .../tests/ParsedEnvironmentFile.test.ts | 35 +++++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Extension/CHANGELOG.md b/Extension/CHANGELOG.md index dfdaabcdc..c3487fed2 100644 --- a/Extension/CHANGELOG.md +++ b/Extension/CHANGELOG.md @@ -15,7 +15,7 @@ * Reduce CPU usage when resolving headers in large Windows workspaces. ### Bug Fixes -* Fix `${VAR}` interpolation in `.env` files used by `envFile`: references to variables defined earlier in the same file (or in the OS environment) are now resolved. [#13781](https://github.com/microsoft/vscode-cpptools/issues/13781) +* Fix `${VAR}` interpolation in `.env` files used by `envFile`: references to variables defined earlier in the same file (or in the OS environment) are now resolved. Single-quoted values and `\${VAR}` are left as written. [#13781](https://github.com/microsoft/vscode-cpptools/issues/13781) * Fix 'Go to Declaration' and 'Go to Definition' returning the current location instead of an available counterpart. [#14509](https://github.com/microsoft/vscode-cpptools/issues/14509) * Update MIEngine to fix debugger failures with synthetic GDB stack frames. [#14741](https://github.com/microsoft/vscode-cpptools/issues/14741) * Thanks for the contribution. [@tzcnt](https://github.com/tzcnt) [PR MIEngine#1605](https://github.com/microsoft/MIEngine/pull/1605) diff --git a/Extension/src/Debugger/ParsedEnvironmentFile.ts b/Extension/src/Debugger/ParsedEnvironmentFile.ts index 311fda21b..30cd9e1a2 100644 --- a/Extension/src/Debugger/ParsedEnvironmentFile.ts +++ b/Extension/src/Debugger/ParsedEnvironmentFile.ts @@ -57,18 +57,29 @@ export class ParsedEnvironmentFile { value = value.replace(/\\n/gm, "\n"); } + // A single-quoted value is literal, so a reference inside it must not be + // expanded. The quotes come off on the next line, so this has to be read here. + const singleQuoted: boolean = (value.length > 1) && (value.charAt(0) === "'") && (value.charAt(value.length - 1) === "'"); + value = value.replace(/(^['"]|['"]$)/g, ""); - // Resolve ${VAR} references against the variables defined so far in the file - // (and the process environment), following the dotenv interpolation rules. - // A reference to a name we don't know is left as-is instead of being dropped. - value = value.replace(/\$\{([\w\.\-]+)\}/g, (full: string, name: string): string => { - if (env.has(name)) { - return env.get(name); - } - const processValue: string | undefined = process.env[name]; - return processValue !== undefined ? processValue : full; - }); + // Resolve ${VAR} references against the variables defined so far in the file, + // then the process environment. A single-quoted value and a reference written + // with a backslash both stay literal; the backslash itself is dropped, so + // \${VAR} ends up as the text ${VAR}. A name we don't know is left as-is + // instead of being dropped. + if (!singleQuoted) { + value = value.replace(/(\\)?\$\{([\w\.\-]+)\}/g, (full: string, escape: string | undefined, name: string): string => { + if (escape) { + return full.substring(1); + } + if (env.has(name)) { + return env.get(name); + } + const processValue: string | undefined = process.env[name]; + return processValue !== undefined ? processValue : full; + }); + } env.set(key, value); } else { diff --git a/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts b/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts index 6e8abff01..24d38d66a 100644 --- a/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts +++ b/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts @@ -127,4 +127,39 @@ EMAIL=\${USER}@example.org`; assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); assertEnvironmentEqual(result.Env, "EMAIL", "${UNKNOWN_USER}@example.org"); }); + + test("Resolve a reference from the process environment", () => { + const name: string = "CPPTOOLS_ENVFILE_INTERPOLATION_TEST"; + const previous: string | undefined = process.env[name]; + process.env[name] = "from-process"; + try { + const content: string = `EMAIL=\${${name}}@example.org`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "EMAIL", "from-process@example.org"); + } finally { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } + } + }); + + test("Leave a reference inside a single-quoted value alone", () => { + const content: string = `USER=admin\nEMAIL='\${USER}@example.org'`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "EMAIL", "${USER}@example.org"); + }); + + test("Leave an escaped reference alone, without the backslash", () => { + const content: string = `USER=admin\nEMAIL=\\\${USER}@example.org`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "EMAIL", "${USER}@example.org"); + }); }); From e34efbf418e3d87e819c409ff7abfa0d7964278f Mon Sep 17 00:00:00 2001 From: abdelhadi703 Date: Wed, 14 Oct 2026 12:09:21 +0200 Subject: [PATCH 3/3] Protect literal references from the launch expansion The parser's output goes through expandAllStrings straight after it (configurationProvider.expand), which substitutes every ${...} it knows and warns about the rest. Writing a literal back as a bare ${VAR} therefore did not keep it literal: the expansion either replaced it from the launch configuration or reported "Invalid variable reference" -- the very symptom this change set out to remove. Both literal forms now come out as ${dollar}{VAR}, the one form that expansion leaves alone and collapses to a plain $ once it is finished. Drops the changelog entry, since those are written separately before a release. --- Extension/CHANGELOG.md | 1 - .../src/Debugger/ParsedEnvironmentFile.ts | 20 +++++++++++++------ .../tests/ParsedEnvironmentFile.test.ts | 18 +++++++++++++---- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/Extension/CHANGELOG.md b/Extension/CHANGELOG.md index c3487fed2..b8c8b62db 100644 --- a/Extension/CHANGELOG.md +++ b/Extension/CHANGELOG.md @@ -15,7 +15,6 @@ * Reduce CPU usage when resolving headers in large Windows workspaces. ### Bug Fixes -* Fix `${VAR}` interpolation in `.env` files used by `envFile`: references to variables defined earlier in the same file (or in the OS environment) are now resolved. Single-quoted values and `\${VAR}` are left as written. [#13781](https://github.com/microsoft/vscode-cpptools/issues/13781) * Fix 'Go to Declaration' and 'Go to Definition' returning the current location instead of an available counterpart. [#14509](https://github.com/microsoft/vscode-cpptools/issues/14509) * Update MIEngine to fix debugger failures with synthetic GDB stack frames. [#14741](https://github.com/microsoft/vscode-cpptools/issues/14741) * Thanks for the contribution. [@tzcnt](https://github.com/tzcnt) [PR MIEngine#1605](https://github.com/microsoft/MIEngine/pull/1605) diff --git a/Extension/src/Debugger/ParsedEnvironmentFile.ts b/Extension/src/Debugger/ParsedEnvironmentFile.ts index 30cd9e1a2..9fa85f4bf 100644 --- a/Extension/src/Debugger/ParsedEnvironmentFile.ts +++ b/Extension/src/Debugger/ParsedEnvironmentFile.ts @@ -64,14 +64,22 @@ export class ParsedEnvironmentFile { value = value.replace(/(^['"]|['"]$)/g, ""); // Resolve ${VAR} references against the variables defined so far in the file, - // then the process environment. A single-quoted value and a reference written - // with a backslash both stay literal; the backslash itself is dropped, so - // \${VAR} ends up as the text ${VAR}. A name we don't know is left as-is - // instead of being dropped. - if (!singleQuoted) { + // then the process environment. A name we don't know is left as-is rather than + // dropped, so ${workspaceFolder} and the other launch variables still resolve + // from the debug configuration. + // + // A reference that has to stay literal is written back as ${dollar}{VAR}. This + // parser's output goes on to expandAllStrings (configurationProvider.expand), + // which substitutes every remaining ${...} it knows and warns about the rest, so + // a bare ${VAR} would be replaced or reported instead of reaching the debuggee. + // ${dollar} is the one form that expansion leaves alone and collapses to a plain + // $ once it is done, which is what makes the literal survive the trip. + if (singleQuoted) { + value = value.replace(/\$\{([\w\.\-]+)\}/g, (_full: string, name: string): string => `\${dollar}{${name}}`); + } else { value = value.replace(/(\\)?\$\{([\w\.\-]+)\}/g, (full: string, escape: string | undefined, name: string): string => { if (escape) { - return full.substring(1); + return `\${dollar}{${name}}`; } if (env.has(name)) { return env.get(name); diff --git a/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts b/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts index 24d38d66a..52a9e81c0 100644 --- a/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts +++ b/Extension/test/scenarios/SingleRootProject/tests/ParsedEnvironmentFile.test.ts @@ -147,19 +147,29 @@ EMAIL=\${USER}@example.org`; } }); - test("Leave a reference inside a single-quoted value alone", () => { + test("Protect a reference inside a single-quoted value from the launch expansion", () => { const content: string = `USER=admin\nEMAIL='\${USER}@example.org'`; const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); - assertEnvironmentEqual(result.Env, "EMAIL", "${USER}@example.org"); + // ${dollar} is the one form expandAllStrings leaves alone, and it collapses to a plain $ + // when that expansion finishes (expand.ts), so the debuggee still receives the literal. + assertEnvironmentEqual(result.Env, "EMAIL", "${dollar}{USER}@example.org"); }); - test("Leave an escaped reference alone, without the backslash", () => { + test("Protect an escaped reference the same way, without the backslash", () => { const content: string = `USER=admin\nEMAIL=\\\${USER}@example.org`; const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); - assertEnvironmentEqual(result.Env, "EMAIL", "${USER}@example.org"); + assertEnvironmentEqual(result.Env, "EMAIL", "${dollar}{USER}@example.org"); + }); + + test("Leave a resolved reference bare, so the expansion can still see it", () => { + const content: string = `USER=admin\nEMAIL=\${USER}@example.org`; + const result: ParsedEnvironmentFile = ParsedEnvironmentFile.CreateFromContent(content, "TestEnvFileName", []); + + assert(!result.Warning, `Failed to assert that Warning was empty: ${result.Warning}`); + assertEnvironmentEqual(result.Env, "EMAIL", "admin@example.org"); }); });