From 92360d3ff5fe4d33f782d10912ab8d9addc14030 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 23:42:11 +0000 Subject: [PATCH 1/5] Spike a VS Code E2E harness with @vscode/test-electron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads both extensions into one real VS Code window and drives the host->workspace cert flow, plus a guard for the fidelity gap that approach introduces. The workspace extension no-ops unless `vscode.env.remoteName` is set, and that property is read-only and populated only by a resolver extension claiming an authority — nothing in a test can set it. So `isRemoteContext()` also accepts DEVCONTAINER_DEV_CERTS_TEST_REMOTE=1, but only when `context.extensionMode !== ExtensionMode.Production`. Production is what VS Code assigns to every installed extension, so the override is unreachable in any shipped build: an env var alone cannot reach it. tests/remoteGate.test.ts pins that, in the fast vitest suite rather than only in the E2E job. The vertical slice runs getAllCertMaterialV3 from the workspace extension to the UI extension and asserts the container-side install on disk — PEM present and matching, {hash}.N symlink resolving, .NET Root store PFX written, My-store PFX correctly absent. It uses a user certificate rather than the auto-generated dev cert: that path skips the modal consent prompt and never touches the host OS trust store. All writes are redirected into a mkdtemp sandbox via HOME and DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY. Within one extension host executeCommand passes objects by reference, while the real host<->remote hop serializes them — so a payload carrying a Buffer would pass this suite and fail in production. wireGuard.ts walks every cross-host payload for non-JSON values and round-trips it through structuredClone and JSON. Its negative self-tests are load-bearing: without them a guard that accepted everything would produce an identical green run. CI gets a separate `vscode-e2e` job so a VS Code download failure can't be mistaken for a unit-test regression. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1 --- .github/workflows/build-extensions.yml | 47 +++ AGENTS.md | 30 ++ eslint.config.mjs | 1 + package-lock.json | 367 +++++++++++++++++- package.json | 6 +- .../src/extension.ts | 53 ++- .../tests/__mocks__/vscode.ts | 22 ++ .../tests/remoteGate.test.ts | 71 ++++ test/vscode-e2e/esbuild.mjs | 24 ++ test/vscode-e2e/runTests.mjs | 183 +++++++++ test/vscode-e2e/src/env.ts | 39 ++ test/vscode-e2e/src/index.ts | 25 ++ test/vscode-e2e/src/runner.ts | 94 +++++ test/vscode-e2e/src/suites/activation.ts | 88 +++++ test/vscode-e2e/src/suites/certFlow.ts | 185 +++++++++ test/vscode-e2e/src/suites/serialization.ts | 136 +++++++ test/vscode-e2e/src/wireGuard.ts | 265 +++++++++++++ test/vscode-e2e/tsconfig.json | 16 + 18 files changed, 1646 insertions(+), 6 deletions(-) create mode 100644 src/vscode-workspace-extension/tests/remoteGate.test.ts create mode 100644 test/vscode-e2e/esbuild.mjs create mode 100644 test/vscode-e2e/runTests.mjs create mode 100644 test/vscode-e2e/src/env.ts create mode 100644 test/vscode-e2e/src/index.ts create mode 100644 test/vscode-e2e/src/runner.ts create mode 100644 test/vscode-e2e/src/suites/activation.ts create mode 100644 test/vscode-e2e/src/suites/certFlow.ts create mode 100644 test/vscode-e2e/src/suites/serialization.ts create mode 100644 test/vscode-e2e/src/wireGuard.ts create mode 100644 test/vscode-e2e/tsconfig.json diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index 5e7833d..a1720e7 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -76,6 +76,53 @@ jobs: name: vsix path: src/vscode-*-extension/*.vsix + vscode-e2e: + # Deliberately a separate job from `extensions`, not a step inside it. + # This one is the only thing in CI that downloads a VS Code build at run + # time, so it is also the only thing that can fail for reasons that have + # nothing to do with the code (update.code.visualstudio.com being down, a + # slow CDN, a runner with no egress). Keeping it separate means such a + # failure shows up as "vscode-e2e red, extensions green" instead of + # masking — or being mistaken for — a real unit-test regression. + name: VS Code E2E (spike) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install dependencies + run: npm ci + + # esbuild/vitest never typecheck; the E2E sources sit outside every + # workspace tsconfig, so they need their own pass like the others do. + - name: Typecheck E2E suite + run: npm run typecheck:e2e + + # The suite loads the extensions' real bundles via + # --extensionDevelopmentPath, so `dist/extension.js` has to exist for + # both. Never the production build: the remote-gate seam the suite + # depends on is only honored outside ExtensionMode.Production, and a + # minified bundle makes failures unreadable for no benefit here. + - name: Build UI Extension + run: npm run build -w src/vscode-ui-extension + + - name: Build Workspace Extension + run: npm run build -w src/vscode-workspace-extension + + - name: Build E2E suite + run: npm run build:e2e + + - name: Generate test certificate fixture + run: npm run gen:test-cert + + # VS Code is an Electron app and needs an X server even with + # --disable-gpu. `xvfb-run -a` picks a free display number. + - name: Run VS Code E2E suite + run: xvfb-run -a npm run test:e2e + windows-certificate-validation: name: Windows Certificate Validation runs-on: windows-latest diff --git a/AGENTS.md b/AGENTS.md index dd5a1dd..dd454b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,36 @@ These decisions were made deliberately. Do not change them without discussion. - **Extension testing**: F5 launches an Extension Development Host. The `build-extensions` task hydrates a test project at `.out/test-project/` from the template at `test/sample-project/`. The workspace extension VSIX is staged in `.out/test-project/.devcontainer/` and referenced via `${containerWorkspaceFolder}` in `customizations.vscode.extensions`. - **The `trust` operation generates the cert if it doesn't exist.** This is intentional — it's the single entry point for provisioning. +### VS Code E2E harness (`test/vscode-e2e/`) — spike status + +`@vscode/test-electron` launches a real VS Code with **both** extensions loaded into one window (`--extensionDevelopmentPath` twice) and runs the suite in the extension host. Scripts, from the repo root: + +``` +npm run typecheck:e2e # tsc pass; esbuild and the runner never typecheck +npm run build:e2e # bundles the suite to .out/vscode-e2e/suite.cjs +npm run test:e2e # on Linux: xvfb-run -a npm run test:e2e +``` + +It needs `dist/extension.js` for both extensions and the `.out/test-fixtures/` cert (the launcher runs `gen:test-cert` itself if it's missing). CI runs it as the separate `vscode-e2e` job in `build-extensions.yml` — separate so a VS Code download failure can't be confused with a unit-test regression. + +**What it covers:** activation of both extensions without throwing, command registration on both sides, and one vertical slice — `getAllCertMaterialV3` driven from the workspace extension to the UI extension, with the container-side install asserted on disk (PEM present and matching, `{hash}.N` symlink resolving, .NET Root store PFX written, My-store PFX correctly *absent*). All writes are redirected into a `mkdtemp` sandbox via `HOME` and `DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY`, so the runner's real `~/.aspnet` and `~/.dotnet` are untouched. + +**What it does NOT cover — do not oversell this as full E2E:** + +- **The actual host↔server hop.** Both extensions share one extension host, so `executeCommand` passes objects **by reference**. In production the payload is serialized. This is the approach's central fidelity gap, and `src/wireGuard.ts` is the compensation: every cross-host payload is walked for non-JSON values (Buffer, Date, Map, class instances, functions, bigint, non-finite numbers, cycles, own `toJSON`) and round-tripped through `structuredClone` and JSON. Its negative self-tests are not optional decoration — without them a guard that silently accepted everything would produce an identical green run. `undefined`-valued object properties are the one tolerated difference (VS Code's RPC drops them exactly as JSON does); `undefined` inside an *array* is still rejected, because it becomes `null`. +- **The container filesystem.** The "container" is a temp dir on the same machine. Nothing exercises a real container's users, permissions, mounts, or a genuinely separate `$HOME`. +- **The dotnet dev-cert path.** The slice runs a *user* certificate. Generating the dev cert would write to the runner's real OS trust store and raise a modal consent dialog nothing headless can dismiss, so `generateDotNetCert` is off for the run. Reverse-sync (`acceptContainerDevCert`) is likewise uncovered. + +### The remote-gate seam (`DEVCONTAINER_DEV_CERTS_TEST_REMOTE`) — test-only + +`src/vscode-workspace-extension/src/extension.ts` no-ops unless `vscode.env.remoteName` is set. That property is read-only and is populated only by a resolver extension that has claimed an authority, so nothing inside a test can set it — hence `isRemoteContext()`, which also accepts `DEVCONTAINER_DEV_CERTS_TEST_REMOTE=1`. + +**This is a test-only code path in production code, and the gating is the whole reason it's acceptable.** The env var alone does nothing. It is honored only when `context.extensionMode !== vscode.ExtensionMode.Production`, and `Production` is what VS Code assigns to *every* installed extension — the marketplace VSIX, a sideloaded VSIX, a `--install-extension` copy. Reaching the override requires launching VS Code with `--extensionDevelopmentPath` or `--extensionTestsPath` pointed at a source checkout. So the branch is not merely unlikely in a shipped build, it is unreachable: someone who can set environment variables still cannot flip it on. + +If you change this, keep both halves. An env-var-only check would be a genuine escape hatch in shipped code and should be rejected in review. `tests/remoteGate.test.ts` pins exactly that — it asserts the override is refused under `ExtensionMode.Production` for every truthy spelling of the variable — and it runs in the fast vitest suite, not only in the E2E job, so the guarantee doesn't depend on a VS Code download succeeding. + +The alternative that avoids a product-code seam entirely is a resolver extension implementing `resolveAuthority` to populate `remoteName` for real. It is more faithful and would also unlock testing against a real container — but it rides a **proposed API**, so it needs `--enable-proposed-api` and can break between VS Code releases. Choosing between the two is the open question this spike exists to inform. + ## File Paths That Matter | Path (in container) | Purpose | diff --git a/eslint.config.mjs b/eslint.config.mjs index 387ec5d..693b88d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,7 @@ export default tseslint.config( "./src/shared/tsconfig.json", "./src/vscode-ui-extension/tsconfig.lint.json", "./src/vscode-workspace-extension/tsconfig.lint.json", + "./test/vscode-e2e/tsconfig.json", ], tsconfigRootDir: import.meta.dirname, }, diff --git a/package-lock.json b/package-lock.json index 37ff7e6..ce8c337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ ], "devDependencies": { "@eslint/js": "^10.0.1", + "@vscode/test-electron": "^2.5.2", "eslint": "^10.3.0", "globals": "^17.6.0", "semver": "^7.7.4", @@ -2116,6 +2117,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@vscode/vsce": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", @@ -2741,6 +2759,35 @@ "license": "ISC", "optional": true }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -2801,6 +2848,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3682,6 +3736,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3985,6 +4052,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4013,8 +4087,7 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -4092,6 +4165,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4102,6 +4188,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4118,6 +4217,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4250,6 +4356,52 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4320,6 +4472,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4680,6 +4842,49 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4821,6 +5026,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -5047,6 +5265,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -5084,6 +5318,68 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5129,6 +5425,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -5409,6 +5712,13 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -5604,6 +5914,23 @@ "node": ">=0.10.0" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5759,6 +6086,13 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5865,6 +6199,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -6005,6 +6352,19 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6513,8 +6873,7 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/validate-npm-package-license": { "version": "3.0.4", diff --git a/package.json b/package.json index daa3c22..ce06148 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,14 @@ "scripts": { "hydrate:sample-project": "node test/sample-project/hydrate-sample-project.mjs", "gen:test-cert": "node test/generate-test-cert.mjs", - "lint": "eslint ." + "lint": "eslint .", + "typecheck:e2e": "tsc --noEmit -p test/vscode-e2e/tsconfig.json", + "build:e2e": "node test/vscode-e2e/esbuild.mjs", + "test:e2e": "node test/vscode-e2e/runTests.mjs" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@vscode/test-electron": "^2.5.2", "eslint": "^10.3.0", "globals": "^17.6.0", "semver": "^7.7.4", diff --git a/src/vscode-workspace-extension/src/extension.ts b/src/vscode-workspace-extension/src/extension.ts index 297b531..68472fd 100644 --- a/src/vscode-workspace-extension/src/extension.ts +++ b/src/vscode-workspace-extension/src/extension.ts @@ -54,12 +54,63 @@ function isTruthyEnv(val: string | undefined, defaultVal: boolean): boolean { return /^(1|true|yes|on)$/i.test(val.trim()); } +/** + * Env var that lets the E2E harness pretend this window is remote. Honored + * ONLY outside `ExtensionMode.Production` — see `isRemoteContext`. + */ +const TEST_REMOTE_ENV = "DEVCONTAINER_DEV_CERTS_TEST_REMOTE"; + +/** + * Whether this extension should do anything at all. + * + * The real signal is `vscode.env.remoteName`, which is read-only and is + * populated only by a resolver extension that has claimed an authority. + * Nothing inside a test can set it, so exercising this extension in a plain + * local VS Code instance (`test/vscode-e2e`) needs a seam. + * + * The seam is deliberately double-gated, and the `extensionMode` half is the + * part that matters: `ExtensionMode.Production` is what VS Code assigns to + * every installed extension — the marketplace VSIX, a sideloaded VSIX, a + * `--install-extension` copy. The override branch is therefore unreachable in + * any build a user runs, no matter what they put in their environment. + * `Development` (loaded via `--extensionDevelopmentPath`) and `Test` (a + * `--extensionTestsPath` run) are the only modes that can reach it, and both + * require someone to have launched VS Code with a flag pointing at a source + * checkout. An env var alone is not enough, which is the property that makes + * this safe to ship: an attacker (or a confused user) who can set environment + * variables still cannot flip this on. + * + * The alternative — a resolver extension implementing the proposed + * `resolveAuthority` API purely to populate `remoteName` — would avoid a + * product-code seam entirely, at the cost of riding a proposed API that + * requires `--enable-proposed-api` and can break between VS Code releases. + * That trade is the open question this spike exists to answer; see AGENTS.md. + * + * Exported for testing: `tests/remoteGate.test.ts` pins that the override is + * refused under `ExtensionMode.Production`. That assertion is the reason this + * seam is acceptable in product code at all, so it deserves a direct test in + * the fast suite rather than only implicit coverage from the E2E run. + */ +export function isRemoteContext(context: vscode.ExtensionContext): boolean { + if (vscode.env.remoteName) return true; + + if (context.extensionMode === vscode.ExtensionMode.Production) return false; + if (!isTruthyEnv(process.env[TEST_REMOTE_ENV], false)) return false; + + log( + `${TEST_REMOTE_ENV} is set and extensionMode is ` + + `${vscode.ExtensionMode[context.extensionMode]} (not Production) — ` + + "treating this window as remote for testing." + ); + return true; +} + export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(initLogger("Dev Container Dev Certs (Remote)")); log(`Workspace extension activated. remoteName=${vscode.env.remoteName}`); - if (!vscode.env.remoteName) { + if (!isRemoteContext(context)) { log("Not running in a remote context, extension will no-op."); return; } diff --git a/src/vscode-workspace-extension/tests/__mocks__/vscode.ts b/src/vscode-workspace-extension/tests/__mocks__/vscode.ts index aa1cc43..77a1cb2 100644 --- a/src/vscode-workspace-extension/tests/__mocks__/vscode.ts +++ b/src/vscode-workspace-extension/tests/__mocks__/vscode.ts @@ -1,5 +1,27 @@ // Minimal vscode module stub for workspace extension tests. +/** + * Mirrors `vscode.env`. Only `remoteName` matters here: it is read-only in + * the real API and drives the workspace extension's activation gate. + */ +export const env: { remoteName: string | undefined } = { + remoteName: undefined, +}; + +export function __setRemoteName(name: string | undefined) { + env.remoteName = name; +} + +/** Mirrors the real `vscode.ExtensionMode` enum values. */ +export const ExtensionMode = { + Production: 1, + Development: 2, + Test: 3, + 1: "Production", + 2: "Development", + 3: "Test", +} as const; + // Captured output-channel lines from the shared `log()` helper. Tests can // snapshot / clear / assert against this array to verify what was (and // wasn't) written to the Remote output channel. diff --git a/src/vscode-workspace-extension/tests/remoteGate.test.ts b/src/vscode-workspace-extension/tests/remoteGate.test.ts new file mode 100644 index 0000000..98d72be --- /dev/null +++ b/src/vscode-workspace-extension/tests/remoteGate.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import * as vscode from "vscode"; +import { isRemoteContext } from "../src/extension"; + +const { __setRemoteName, ExtensionMode } = vscode as unknown as { + __setRemoteName: (name: string | undefined) => void; + ExtensionMode: { Production: number; Development: number; Test: number }; +}; + +const ENV_VAR = "DEVCONTAINER_DEV_CERTS_TEST_REMOTE"; + +function contextWith(extensionMode: number): vscode.ExtensionContext { + return { extensionMode } as unknown as vscode.ExtensionContext; +} + +describe("isRemoteContext", () => { + let priorEnv: string | undefined; + + beforeEach(() => { + priorEnv = process.env[ENV_VAR]; + delete process.env[ENV_VAR]; + __setRemoteName(undefined); + }); + + afterEach(() => { + if (priorEnv === undefined) delete process.env[ENV_VAR]; + else process.env[ENV_VAR] = priorEnv; + __setRemoteName(undefined); + }); + + it("is remote whenever VS Code reports a remote authority", () => { + __setRemoteName("dev-container"); + expect(isRemoteContext(contextWith(ExtensionMode.Production))).toBe(true); + }); + + it("is not remote in a plain local window", () => { + expect(isRemoteContext(contextWith(ExtensionMode.Development))).toBe(false); + }); + + /** + * The assertion that makes the test-only seam acceptable in shipped code. + * + * ExtensionMode.Production is what VS Code assigns to every INSTALLED + * extension — marketplace VSIX, sideloaded VSIX, `--install-extension` + * copy. If this ever returns true, the escape hatch is live in users' + * editors and a plain environment variable can make the extension write + * certificates into a non-container home directory. Do not relax it. + */ + it("refuses the test override in Production, whatever the env says", () => { + for (const value of ["1", "true", "yes", "on", "TRUE"]) { + process.env[ENV_VAR] = value; + expect(isRemoteContext(contextWith(ExtensionMode.Production))).toBe(false); + } + }); + + it("honors the test override in Development and Test modes", () => { + process.env[ENV_VAR] = "1"; + expect(isRemoteContext(contextWith(ExtensionMode.Development))).toBe(true); + expect(isRemoteContext(contextWith(ExtensionMode.Test))).toBe(true); + }); + + it("ignores an unset or falsy override outside Production", () => { + for (const value of [undefined, "", "0", "false", "no", "off"]) { + if (value === undefined) delete process.env[ENV_VAR]; + else process.env[ENV_VAR] = value; + expect(isRemoteContext(contextWith(ExtensionMode.Development))).toBe( + false + ); + } + }); +}); diff --git a/test/vscode-e2e/esbuild.mjs b/test/vscode-e2e/esbuild.mjs new file mode 100644 index 0000000..150a321 --- /dev/null +++ b/test/vscode-e2e/esbuild.mjs @@ -0,0 +1,24 @@ +import * as esbuild from "esbuild"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", ".."); + +// CJS, because VS Code `require()`s the --extensionTestsPath module in the +// extension host. `vscode` stays external for the same reason it does in the +// extensions' own bundles: it's injected by the host, not resolvable from +// node_modules. Everything else — including the shared package, which is +// published as TypeScript source — is bundled in, so the suite is a single +// self-contained file with no resolution surprises at runtime. +await esbuild.build({ + entryPoints: [resolve(here, "src", "index.ts")], + bundle: true, + outfile: resolve(repoRoot, ".out", "vscode-e2e", "suite.cjs"), + external: ["vscode"], + format: "cjs", + platform: "node", + target: "node20", + sourcemap: true, + logLevel: "info", +}); diff --git a/test/vscode-e2e/runTests.mjs b/test/vscode-e2e/runTests.mjs new file mode 100644 index 0000000..9146e63 --- /dev/null +++ b/test/vscode-e2e/runTests.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Launcher for the VS Code E2E suite. + * + * Runs in plain Node (not in VS Code): downloads a VS Code build via + * `@vscode/test-electron`, builds a throwaway sandbox, and starts VS Code with + * BOTH extensions loaded into one window plus `--extensionTestsPath` pointing + * at the bundled suite. + * + * Usage (from the repo root): + * npm run build -w src/vscode-ui-extension + * npm run build -w src/vscode-workspace-extension + * npm run build:e2e + * npm run test:e2e # on Linux: xvfb-run -a npm run test:e2e + * + * The sandbox is a `mkdtemp` directory used as `$HOME`, so everything the + * workspace extension installs (`~/.dotnet/corefx/...`, `~/.aspnet/...`) lands + * there instead of in the runner's real home. It is removed on success and + * deliberately left behind on failure so the artifacts can be inspected. + */ +import { runTests } from "@vscode/test-electron"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", ".."); + +const USER_CERT_NAME = "e2e-user-cert"; +const FIXTURE_DIR = join(repoRoot, ".out", "test-fixtures"); +const FIXTURE_PEM = join(FIXTURE_DIR, "corp-wildcard.pem"); +const FIXTURE_KEY = join(FIXTURE_DIR, "corp-wildcard.key"); + +/** The fixture generator is cheap and deterministic; just run it if needed. */ +function ensureFixture() { + if (existsSync(FIXTURE_PEM) && existsSync(FIXTURE_KEY)) return; + console.log("Generating test certificate fixture..."); + const result = spawnSync( + process.execPath, + [join(repoRoot, "test", "generate-test-cert.mjs")], + { cwd: repoRoot, stdio: "inherit" } + ); + if (result.status !== 0) { + throw new Error("test/generate-test-cert.mjs failed"); + } +} + +function requireBuilt(label, path) { + if (!existsSync(path)) { + throw new Error( + `${label} is missing (${path}). Build it first — see the usage note in ` + + "test/vscode-e2e/runTests.mjs." + ); + } + return path; +} + +function buildSandbox() { + const root = mkdtempSync(join(tmpdir(), "devcerts-e2e-")); + const home = join(root, "home"); + const userDataDir = join(root, "user-data"); + const extensionsDir = join(root, "extensions"); + const workspaceDir = join(root, "workspace"); + const trustDir = join(home, ".aspnet", "dev-certs", "trust"); + + for (const dir of [home, userDataDir, extensionsDir, workspaceDir, trustDir]) { + mkdirSync(dir, { recursive: true }); + } + + // Machine/user settings for the run. `userCertificates` is what makes the + // host serve a certificate at all; the rest keeps the window quiet and the + // flow deterministic: + // - generateDotNetCert/autoProvision off → the dev-cert branch (and its + // modal consent prompt, and the OS trust-store write) never runs. + // - autoInject off → the suite drives the pull explicitly instead of + // racing extension activation. + // - warnOnStaleDevCerts off → no post-install warning dialog. + mkdirSync(join(userDataDir, "User"), { recursive: true }); + writeFileSync( + join(userDataDir, "User", "settings.json"), + JSON.stringify( + { + "devcontainerDevCerts.generateDotNetCert": false, + "devcontainerDevCerts.autoProvision": false, + "devcontainerDevCerts.autoInject": false, + "devcontainerDevCerts.warnOnStaleDevCerts": false, + "devcontainerDevCerts.installUserCertsToDotNetStore": false, + "devcontainerDevCerts.userCertificates": [ + { + name: USER_CERT_NAME, + pemCertPath: FIXTURE_PEM, + pemKeyPath: FIXTURE_KEY, + trustInContainer: true, + }, + ], + "security.workspace.trust.enabled": false, + "telemetry.telemetryLevel": "off", + "update.mode": "none", + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + "workbench.startupEditor": "none", + }, + null, + 2 + ) + ); + + return { root, home, userDataDir, extensionsDir, workspaceDir, trustDir }; +} + +async function main() { + ensureFixture(); + + const uiExtension = resolve(repoRoot, "src", "vscode-ui-extension"); + const workspaceExtension = resolve(repoRoot, "src", "vscode-workspace-extension"); + requireBuilt("UI extension bundle", join(uiExtension, "dist", "extension.js")); + requireBuilt( + "workspace extension bundle", + join(workspaceExtension, "dist", "extension.js") + ); + const extensionTestsPath = requireBuilt( + "E2E suite bundle", + resolve(repoRoot, ".out", "vscode-e2e", "suite.cjs") + ); + + const sandbox = buildSandbox(); + console.log(`Sandbox: ${sandbox.root}`); + + let exitCode = 0; + try { + await runTests({ + version: process.env.DEVCERTS_E2E_VSCODE_VERSION ?? "stable", + // Both extensions into ONE window. In a local window VS Code ignores + // `extensionKind`, so the "ui" and "workspace" extensions share an + // extension host — which is what makes the cross-host commands + // reachable at all, and also what the serialization guard exists to + // compensate for. + extensionDevelopmentPath: [uiExtension, workspaceExtension], + extensionTestsPath, + launchArgs: [ + sandbox.workspaceDir, + "--user-data-dir", + sandbox.userDataDir, + "--extensions-dir", + sandbox.extensionsDir, + // Installed extensions off; --extensionDevelopmentPath ones still load. + "--disable-extensions", + "--disable-gpu", + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + "--no-sandbox", + ], + extensionTestsEnv: { + // Redirects getDotNetStorePath / getDotNetRootStorePath / + // getKestrelDefaultCertPath, which are all os.homedir()-relative. + HOME: sandbox.home, + DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY: sandbox.trustDir, + // The remote-gate seam. Only honored outside ExtensionMode.Production + // — see isRemoteContext in the workspace extension. + DEVCONTAINER_DEV_CERTS_TEST_REMOTE: "1", + DEVCERTS_E2E_HOME: sandbox.home, + DEVCERTS_E2E_TRUST_DIR: sandbox.trustDir, + DEVCERTS_E2E_USER_CERT_NAME: USER_CERT_NAME, + DEVCERTS_E2E_USER_CERT_PEM: FIXTURE_PEM, + }, + }); + } catch (err) { + exitCode = 1; + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); + } + + if (exitCode === 0) { + rmSync(sandbox.root, { recursive: true, force: true }); + } else { + console.error(`Sandbox left in place for inspection: ${sandbox.root}`); + } + process.exit(exitCode); +} + +await main(); diff --git a/test/vscode-e2e/src/env.ts b/test/vscode-e2e/src/env.ts new file mode 100644 index 0000000..11bef5e --- /dev/null +++ b/test/vscode-e2e/src/env.ts @@ -0,0 +1,39 @@ +/** + * The contract between `runTests.mjs` (which builds the sandbox and launches + * VS Code) and the suites (which run inside the extension host and have to + * find that sandbox again). + */ + +export const UI_EXTENSION_ID = "dnegstad.devcontainer-dev-certs-host"; +export const WORKSPACE_EXTENSION_ID = "dnegstad.devcontainer-dev-certs-remote"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error( + `${name} is not set. The suite must be launched via test/vscode-e2e/runTests.mjs, ` + + "which builds the sandbox and passes it down through extensionTestsEnv." + ); + } + return value; +} + +export interface Sandbox { + /** Fake HOME. Everything the workspace extension installs lands under here. */ + home: string; + /** `DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY` for this run. */ + trustDir: string; + /** `devcontainerDevCerts.userCertificates[0].name`. */ + userCertName: string; + /** Host-side source PEM the user cert entry points at. */ + userCertPemPath: string; +} + +export function sandbox(): Sandbox { + return { + home: required("DEVCERTS_E2E_HOME"), + trustDir: required("DEVCERTS_E2E_TRUST_DIR"), + userCertName: required("DEVCERTS_E2E_USER_CERT_NAME"), + userCertPemPath: required("DEVCERTS_E2E_USER_CERT_PEM"), + }; +} diff --git a/test/vscode-e2e/src/index.ts b/test/vscode-e2e/src/index.ts new file mode 100644 index 0000000..c47a9c5 --- /dev/null +++ b/test/vscode-e2e/src/index.ts @@ -0,0 +1,25 @@ +/** + * Entry point VS Code loads via `--extensionTestsPath`. + * + * The contract is just `run(): Promise` — resolve on success, reject on + * failure. VS Code exits non-zero when it rejects. + */ +import { runRegistered } from "./runner"; +import { registerActivationTests } from "./suites/activation"; +import { registerCertFlowTests } from "./suites/certFlow"; +import { registerSerializationTests } from "./suites/serialization"; + +export async function run(): Promise { + console.log(""); + console.log("devcontainer-dev-certs :: VS Code E2E"); + console.log(""); + + registerActivationTests(); + registerCertFlowTests(); + registerSerializationTests(); + + const summary = await runRegistered(); + if (summary.failed > 0) { + throw new Error(`${summary.failed} E2E test(s) failed`); + } +} diff --git a/test/vscode-e2e/src/runner.ts b/test/vscode-e2e/src/runner.ts new file mode 100644 index 0000000..ff6ddb8 --- /dev/null +++ b/test/vscode-e2e/src/runner.ts @@ -0,0 +1,94 @@ +/** + * A ~50-line test runner, used instead of pulling mocha in. + * + * VS Code's `--extensionTestsPath` contract is just "export `run(): Promise`, + * reject on failure" — it has no opinion about the framework behind it. For a + * spike, a runner this small is one fewer dependency in the tree and one fewer + * thing to configure. Swapping in mocha later is a contained change: it only + * touches this file and the `test()` import in the suites. + */ + +export interface TestCase { + name: string; + fn: () => void | Promise; +} + +const cases: TestCase[] = []; + +/** Register a test. Order of registration is order of execution. */ +export function test(name: string, fn: () => void | Promise): void { + cases.push({ name, fn }); +} + +export interface RunSummary { + passed: number; + failed: number; +} + +export async function runRegistered(): Promise { + let passed = 0; + const failures: { name: string; error: unknown }[] = []; + + for (const testCase of cases) { + const started = Date.now(); + try { + await testCase.fn(); + passed++; + console.log(` ok ${testCase.name} (${Date.now() - started}ms)`); + } catch (err: unknown) { + failures.push({ name: testCase.name, error: err }); + console.log(` FAIL ${testCase.name} (${Date.now() - started}ms)`); + } + } + + if (failures.length > 0) { + console.log(""); + for (const failure of failures) { + console.log(`--- ${failure.name} ---`); + const err = failure.error; + console.log(err instanceof Error ? (err.stack ?? err.message) : String(err)); + console.log(""); + } + } + + console.log(""); + console.log(`${passed} passed, ${failures.length} failed`); + return { passed, failed: failures.length }; +} + +/** Minimal assertions, so the suites don't reach for node:assert's deep forms. */ +export function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`Assertion failed: ${message}`); +} + +export function assertEqual(actual: T, expected: T, message: string): void { + if (!Object.is(actual, expected)) { + throw new Error( + `Assertion failed: ${message}\n expected: ${String(expected)}\n actual: ${String(actual)}` + ); + } +} + +/** Assert that `fn` throws, and that the message mentions `contains`. */ +export async function assertThrows( + fn: () => unknown, + contains: string, + message: string +): Promise { + let threw: unknown; + try { + await fn(); + } catch (err: unknown) { + threw = err ?? new Error("threw a falsy value"); + } + if (threw === undefined) { + throw new Error(`Assertion failed: ${message} (nothing was thrown)`); + } + const text = + threw instanceof Error ? threw.message : JSON.stringify(threw) ?? ""; + if (!text.includes(contains)) { + throw new Error( + `Assertion failed: ${message}\n expected the error to mention: ${contains}\n got: ${text}` + ); + } +} diff --git a/test/vscode-e2e/src/suites/activation.ts b/test/vscode-e2e/src/suites/activation.ts new file mode 100644 index 0000000..5f1c1b3 --- /dev/null +++ b/test/vscode-e2e/src/suites/activation.ts @@ -0,0 +1,88 @@ +/** + * Plain activation assertions for both extensions. + * + * These are cheap and they replace a Node harness that had to stub + * `require("vscode")` by hand to answer the same questions. Here the answers + * come from a real extension host. + * + * On output channels: VS Code exposes no API to enumerate them, so "the + * channel exists" cannot be asserted directly. What the activation assertion + * *does* cover is that `initLogger(...)` — which calls + * `vscode.window.createOutputChannel` and is the first statement of both + * `activate()` functions — ran without throwing. A green `isActive` is + * therefore a real signal about the channel, just an indirect one. + */ +import * as vscode from "vscode"; +import { assert, assertEqual, test } from "../runner"; +import { UI_EXTENSION_ID, WORKSPACE_EXTENSION_ID } from "../env"; + +/** Registered by the UI extension, which runs on the host. */ +const UI_COMMANDS = [ + "devcontainer-dev-certs.getCertMaterial", + "devcontainer-dev-certs.getAllCertMaterial", + "devcontainer-dev-certs.getAllCertMaterialV3", + "devcontainer-dev-certs.acceptContainerDevCert", + "devcontainer-dev-certs.trustInBrowsers", + "devcontainer-dev-certs.resetContainerCertConsent", +]; + +/** + * Registered by the workspace extension — but only past the remote gate. + * Their presence is what proves the `DEVCONTAINER_DEV_CERTS_TEST_REMOTE` seam + * actually opened; without it `activate()` returns before registering these + * and every one of them is missing. + */ +const WORKSPACE_COMMANDS = [ + "devcontainer-dev-certs.injectCert", + "devcontainer-dev-certs.cleanupStaleDevCerts", +]; + +export async function activateBoth(): Promise { + for (const id of [UI_EXTENSION_ID, WORKSPACE_EXTENSION_ID]) { + const ext = vscode.extensions.getExtension(id); + if (!ext) throw new Error(`extension ${id} was not loaded`); + await ext.activate(); + } +} + +export function registerActivationTests(): void { + test("both extensions are loaded into the same window", () => { + for (const id of [UI_EXTENSION_ID, WORKSPACE_EXTENSION_ID]) { + assert( + vscode.extensions.getExtension(id) !== undefined, + `extension ${id} should be loaded via --extensionDevelopmentPath` + ); + } + }); + + test("activate() does not throw for either extension", async () => { + await activateBoth(); + for (const id of [UI_EXTENSION_ID, WORKSPACE_EXTENSION_ID]) { + assertEqual( + vscode.extensions.getExtension(id)?.isActive, + true, + `${id} should be active after activate()` + ); + } + }); + + test("UI extension registers its cross-host commands", async () => { + await activateBoth(); + const registered = new Set(await vscode.commands.getCommands(true)); + for (const command of UI_COMMANDS) { + assert(registered.has(command), `${command} should be registered`); + } + }); + + test("workspace extension registers its commands past the remote gate", async () => { + await activateBoth(); + const registered = new Set(await vscode.commands.getCommands(true)); + for (const command of WORKSPACE_COMMANDS) { + assert( + registered.has(command), + `${command} should be registered — its absence means the remote gate ` + + "closed and activate() returned early" + ); + } + }); +} diff --git a/test/vscode-e2e/src/suites/certFlow.ts b/test/vscode-e2e/src/suites/certFlow.ts new file mode 100644 index 0000000..19183ce --- /dev/null +++ b/test/vscode-e2e/src/suites/certFlow.ts @@ -0,0 +1,185 @@ +/** + * The vertical slice: drive `getAllCertMaterialV3` from the workspace + * extension to the UI extension inside one real VS Code window, then assert + * the workspace side actually installed the material on disk. + * + * Why a *user* certificate rather than the auto-generated dotnet dev cert: + * generating the dev cert makes the UI extension put a certificate into the + * runner's real OS trust store and, on first run, raises a modal consent + * dialog that nothing in a headless test can dismiss. A user cert + * (`devcontainerDevCerts.userCertificates`) takes a completely different path + * on the host — `resolveDotnetProvisioning` short-circuits before any prompt, + * and user-managed certs are never added to the host trust store — while + * exercising the identical wire contract and the identical container-side + * installer. `generateDotNetCert` is set to false for the run so the dev-cert + * branch is off entirely. + * + * Everything the workspace side writes is redirected into a sandbox: `HOME` + * points at a temp dir (`getDotNetStorePath`, `getDotNetRootStorePath` and + * `getKestrelDefaultCertPath` are all `os.homedir()`-relative, and Node's + * `os.homedir()` honors `$HOME` on POSIX) and + * `DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY` redirects the OpenSSL + * trust dir. Nothing touches the runner's real `~/.aspnet` or `~/.dotnet`. + */ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + computeSubjectHash, + getDotNetRootStorePath, + getDotNetStorePath, + getPemFileNameForUser, + getPfxFileName, + hasHashSymlink, +} from "@devcontainer-dev-certs/shared"; +import type { CertBundleV3, CertMaterialV3 } from "@devcontainer-dev-certs/shared"; +import { assert, assertEqual, test } from "../runner"; +import { sandbox } from "../env"; +import { activateBoth } from "./activation"; + +/** Strip PEM armor and whitespace, leaving the base64 DER body. */ +function pemBody(pem: string): string { + const begin = pem.indexOf("-----BEGIN CERTIFICATE-----"); + const end = pem.indexOf("-----END CERTIFICATE-----", begin); + assert(begin >= 0 && end > begin, "input should contain a PEM certificate"); + return pem + .slice(begin + "-----BEGIN CERTIFICATE-----".length, end) + .replace(/\s/g, ""); +} + +export async function fetchBundle(): Promise { + await activateBoth(); + const bundle = await vscode.commands.executeCommand( + "devcontainer-dev-certs.getAllCertMaterialV3", + { includeDotNetDev: false, includeUserCerts: true } + ); + assert(bundle !== undefined && bundle !== null, "host returned no bundle"); + return bundle; +} + +function userCert(bundle: CertBundleV3, name: string): CertMaterialV3 { + const material = bundle.certs.find((c) => c.name === name); + assert( + material !== undefined, + `bundle should carry the configured user cert '${name}' ` + + `(got: ${bundle.certs.map((c) => c.name).join(", ") || "nothing"})` + ); + return material; +} + +export function registerCertFlowTests(): void { + const { home, trustDir, userCertName, userCertPemPath } = sandbox(); + + test("host serves the configured user cert over getAllCertMaterialV3", async () => { + const bundle = await fetchBundle(); + const material = userCert(bundle, userCertName); + + assertEqual(material.kind, "user", "cert kind"); + assertEqual(material.trustInContainer, true, "trustInContainer"); + assert( + material.pemCertBase64.length > 0, + "bundle should carry PEM certificate bytes" + ); + assertEqual( + pemBody(Buffer.from(material.pemCertBase64, "base64").toString("utf-8")), + pemBody(fs.readFileSync(userCertPemPath, "utf-8")), + "the certificate on the wire should be the one configured on the host" + ); + }); + + test("workspace extension installs the material into the sandbox", async () => { + const bundle = await fetchBundle(); + const material = userCert(bundle, userCertName); + + // This is the command the activation path calls; auto-inject is turned + // off for the run so the flow is driven explicitly rather than racing + // startup. + await vscode.commands.executeCommand("devcontainer-dev-certs.injectCert"); + + // --- PEM is present and is the right certificate --- + const pemFileName = getPemFileNameForUser(userCertName); + const pemPath = path.join(trustDir, pemFileName); + assert(fs.existsSync(pemPath), `${pemPath} should exist after inject`); + + const onDisk = fs.readFileSync(pemPath, "utf-8"); + assertEqual( + pemBody(onDisk), + pemBody(fs.readFileSync(userCertPemPath, "utf-8")), + "installed PEM should be the certificate the host was configured with" + ); + + // --- the hash symlink OpenSSL would actually follow --- + // Resolved with plain fs rather than only via `hasHashSymlink`, so the + // assertion doesn't depend on the same helper the installer used. A link + // under the wrong hash is indistinguishable from an untrusted cert at the + // point of use, which is exactly the bug class worth catching here. + const hash = computeSubjectHash(onDisk); + assert(hash !== null, "subject hash should be computable for the PEM"); + + let linked: string | null = null; + for (let i = 0; i < 16; i++) { + const candidate = path.join(trustDir, `${hash}.${i}`); + let stat: fs.Stats; + try { + stat = fs.lstatSync(candidate); + } catch { + break; // real gap — OpenSSL stops walking here too + } + if (stat.isSymbolicLink() && fs.readlinkSync(candidate) === pemFileName) { + linked = candidate; + break; + } + } + assert( + linked !== null, + `no ${hash}.N symlink in ${trustDir} resolves to ${pemFileName}; ` + + "OpenSSL would not find this certificate via SSL_CERT_DIR" + ); + assertEqual( + fs.readFileSync(linked, "utf-8"), + onDisk, + "reading through the hash symlink should yield the installed PEM" + ); + assertEqual( + hasHashSymlink(trustDir, pemFileName, onDisk), + true, + "hasHashSymlink should agree that the cert is reachable" + ); + + // --- .NET Root store got the public-cert-only PFX --- + const rootPfx = path.join( + getDotNetRootStorePath(), + getPfxFileName(material.thumbprint) + ); + assert( + fs.existsSync(rootPfx), + `${rootPfx} should exist — trustInContainer is true, so .NET should ` + + "consider this certificate trusted inside the container" + ); + + // --- and the My store did NOT, because the cert never opted in --- + // `installUserCertsToDotNetStore` is false by default; that flag is the + // one that strips the PFX password, so a regression here would be a + // security regression rather than a cosmetic one. + assertEqual( + material.installToDotNetStore, + false, + "user cert should not opt into the .NET My store by default" + ); + const myPfx = path.join( + getDotNetStorePath(), + getPfxFileName(material.thumbprint) + ); + assertEqual( + fs.existsSync(myPfx), + false, + `${myPfx} should NOT exist — the cert did not opt into the My store` + ); + + // --- the sandbox actually contained the writes --- + assert( + rootPfx.startsWith(home), + `install wrote outside the sandbox: ${rootPfx} is not under ${home}` + ); + }); +} diff --git a/test/vscode-e2e/src/suites/serialization.ts b/test/vscode-e2e/src/suites/serialization.ts new file mode 100644 index 0000000..51df40d --- /dev/null +++ b/test/vscode-e2e/src/suites/serialization.ts @@ -0,0 +1,136 @@ +/** + * The serialization guard. + * + * Within one extension host `executeCommand` passes objects by reference, so + * a payload carrying a `Buffer` passes the vertical-slice test above and + * breaks in a real dev container. Every payload that would cross the real + * host↔remote hop is therefore checked against what VS Code's RPC layer would + * do to it. See `../wireGuard.ts` for the rules and for why `undefined` + * properties are the one tolerated difference. + * + * The suite opens with negative self-tests. Without them a green run is + * ambiguous: a guard that silently accepted everything would look exactly the + * same. These pin that the guard rejects the specific shapes that would break + * in production. + */ +import * as vscode from "vscode"; +import type { CertBundle } from "@devcontainer-dev-certs/shared"; +import { assert, assertEqual, assertThrows, test } from "../runner"; +import { + assertCrossHostPayload, + findWireViolations, +} from "../wireGuard"; +import { sandbox } from "../env"; +import { activateBoth } from "./activation"; +import { fetchBundle } from "./certFlow"; + +export function registerSerializationTests(): void { + const { userCertName } = sandbox(); + + test("guard rejects a Buffer payload (self-test)", async () => { + // The exact regression this whole suite exists for: `pfx` as raw bytes + // instead of base64 is by-reference-fine and wire-fatal. + await assertThrows( + () => + assertCrossHostPayload("fake bundle", { + certs: [{ name: "x", pfx: Buffer.from("hello") }], + }), + "not a plain object", + "a Buffer in the payload must be rejected" + ); + }); + + test("guard rejects other non-JSON values (self-test)", async () => { + const cases: [string, unknown, string][] = [ + ["Date", { notAfter: new Date() }, "not a plain object"], + ["Map", { byName: new Map() }, "not a plain object"], + ["Uint8Array", { der: new Uint8Array([1, 2]) }, "not a plain object"], + ["class instance", { cert: new (class Cert {})() }, "not a plain object"], + ["function", { onDone: () => undefined }, "function is not serializable"], + ["bigint", { serial: 1n }, "bigint is not serializable"], + ["NaN", { count: NaN }, "non-finite number"], + ["undefined in array", { names: [undefined] }, "undefined inside an array"], + ["own toJSON", { name: "x", toJSON: () => ({}) }, "toJSON"], + ]; + for (const [label, payload, expected] of cases) { + await assertThrows( + () => assertCrossHostPayload(`fake ${label} payload`, payload), + expected, + `${label} must be rejected` + ); + } + + // Circular references are their own failure mode — JSON.stringify throws + // rather than returning something wrong. + const circular: Record = { name: "x" }; + circular["self"] = circular; + await assertThrows( + () => assertCrossHostPayload("circular payload", circular), + "circular reference", + "a cycle must be rejected" + ); + }); + + test("guard accepts a plain payload with undefined optionals (self-test)", () => { + // The tolerated case. `CertMaterialV3` sets absent optionals to a literal + // `undefined`, and VS Code's RPC drops those keys exactly as JSON does — + // flagging it would make the guard fire on every real bundle. + assertEqual( + findWireViolations({ + certs: [{ name: "x", pemKeyBase64: undefined, trustInContainer: true }], + }).length, + 0, + "undefined-valued optional properties should be tolerated" + ); + }); + + test("V3 bundle survives the host↔remote hop unchanged", async () => { + const bundle = await fetchBundle(); + assert(bundle.certs.length > 0, "bundle should not be empty"); + assertCrossHostPayload("getAllCertMaterialV3 result", bundle); + }); + + test("V2 bundle survives the host↔remote hop unchanged", async () => { + // Still on the wire: the workspace extension falls back to V2 against a + // pinned older host, so its shape has to hold up too. + await activateBoth(); + const bundle = await vscode.commands.executeCommand( + "devcontainer-dev-certs.getAllCertMaterial", + { includeDotNetDev: false, includeUserCerts: true } + ); + assert(bundle !== undefined && bundle !== null, "host returned no V2 bundle"); + assertCrossHostPayload("getAllCertMaterial result", bundle); + }); + + test("command arguments survive the host↔remote hop unchanged", () => { + // The request direction matters as much as the response: these objects + // are built by the workspace extension and serialized on the way out. + assertCrossHostPayload("getAllCertMaterial* args", { + includeDotNetDev: false, + includeUserCerts: true, + }); + }); + + test("the bundle is byte-identical after a JSON round trip", async () => { + // A stronger, narrower statement than the guard makes: the base64 payload + // fields are exactly what a container would decode. This is the property + // that keeps a mangled certificate from being installed silently. + const bundle = await fetchBundle(); + const material = bundle.certs.find((c) => c.name === userCertName); + assert(material !== undefined, "configured user cert should be in bundle"); + + const roundTripped = JSON.parse(JSON.stringify(bundle)) as typeof bundle; + const after = roundTripped.certs.find((c) => c.name === userCertName); + assert(after !== undefined, "cert should survive the round trip"); + + for (const field of [ + "thumbprint", + "pemCertBase64", + "pemKeyBase64", + "pfxBase64", + "rootPfxBase64", + ] as const) { + assertEqual(after[field], material[field], `${field} after JSON round trip`); + } + }); +} diff --git a/test/vscode-e2e/src/wireGuard.ts b/test/vscode-e2e/src/wireGuard.ts new file mode 100644 index 0000000..40a73db --- /dev/null +++ b/test/vscode-e2e/src/wireGuard.ts @@ -0,0 +1,265 @@ +/** + * Serialization guard for cross-host command payloads. + * + * This is the fidelity patch for the biggest hole in the whole E2E approach. + * + * In production the UI extension runs on the host and the workspace extension + * runs inside the dev container. A `vscode.commands.executeCommand` between + * them crosses a process *and machine* boundary, so arguments and return + * values are serialized by VS Code's RPC layer. Inside a single extension + * host — which is exactly what `test/vscode-e2e` gives us — the same call is a + * direct in-process function invocation and objects pass **by reference**. + * + * The consequence is that a payload carrying a `Buffer`, a `Date`, a `Map`, or + * any class instance sails through the E2E test and breaks in production, with + * a failure mode (silently mangled cert bytes) that is miserable to diagnose. + * So every payload that crosses the host↔workspace seam is run through the + * checks below, which model what the RPC layer would do to it. + * + * Two independent checks, because they fail on different things: + * + * 1. `findWireViolations` — a structural walk. Rejects anything that is not + * a JSON primitive, a plain object, or an array. This is what catches a + * `Buffer` field, because the value that comes back from a JSON round + * trip (`{type:"Buffer",data:[…]}`) is *not* equal to the original and, + * worse, a `Uint8Array` would come back from `structuredClone` looking + * perfectly fine while dying on the real wire. + * 2. `assertRoundTripStable` — an actual `structuredClone` and an actual + * `JSON.parse(JSON.stringify(...))`, compared back against the original. + * Catches ordering/identity surprises the structural walk can't see, and + * is the check that would fire on a getter with side effects or a + * `toJSON` that rewrites the shape. + * + * ## The one thing deliberately tolerated: `undefined` properties + * + * `JSON.stringify` drops object keys whose value is `undefined`, and VS Code's + * own RPC does the same. `CertMaterialV3` is full of optional fields that the + * host sets to a literal `undefined` (`pemKeyBase64`, `rootPfxBase64`, + * `dotNetStorePfxBase64`, …), so a strict `deepStrictEqual` would fail on + * every real bundle for a difference that is semantically nil — every consumer + * of these types tests with `?.` or `!== undefined`, for which an absent key + * and an `undefined` key are identical. + * + * So the comparison treats "key absent" and "key present, value undefined" as + * equal. It does NOT extend that tolerance to arrays: `undefined` in an array + * becomes `null` after a JSON round trip, which is a real change of value and + * is reported as a violation. + */ + +export interface WireViolation { + /** JSON-path-ish location of the offending value, e.g. `$.certs[0].pfx`. */ + path: string; + reason: string; +} + +/** Thrown by the `assert*` helpers. Carries the structured findings. */ +export class WireGuardError extends Error { + // Plain field rather than a constructor parameter property, so this module + // also loads under Node's strip-only TypeScript support. + readonly violations: readonly WireViolation[]; + + constructor(message: string, violations: readonly WireViolation[] = []) { + super(message); + this.name = "WireGuardError"; + this.violations = violations; + } +} + +function describe(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + const t = typeof value; + if (t !== "object") return t; + const proto: unknown = Object.getPrototypeOf(value); + if (proto === null) return "null-prototype object"; + const ctor = (proto as { constructor?: { name?: string } }).constructor; + return ctor?.name ?? "object"; +} + +/** + * Collect every reason `value` would not survive VS Code's host↔remote RPC + * unchanged. An empty array means the payload is wire-safe. + */ +export function findWireViolations( + value: unknown, + rootPath = "$" +): WireViolation[] { + const violations: WireViolation[] = []; + // Tracks the current ancestor chain rather than every value ever seen, so + // a payload that legitimately references the same string twice isn't + // mistaken for a cycle. + const ancestors = new Set(); + + const walk = (node: unknown, path: string, inArray: boolean): void => { + if (node === null) return; + + const type = typeof node; + + if (type === "string" || type === "boolean") return; + + if (typeof node === "number") { + // NaN / ±Infinity all serialize to `null`. Narrowed on `typeof node` + // rather than the cached `type` so `node` really is a `number` here. + if (!Number.isFinite(node)) { + violations.push({ + path, + reason: `non-finite number (${node}) serializes to null`, + }); + } + return; + } + + if (type === "undefined") { + if (inArray) { + violations.push({ + path, + reason: "undefined inside an array serializes to null", + }); + } + // As an object property it is dropped, which we treat as equivalent to + // the key being absent. See the module docstring. + return; + } + + if (type === "bigint" || type === "symbol" || type === "function") { + violations.push({ + path, + reason: `${type} is not serializable across the host↔remote boundary`, + }); + return; + } + + // Objects and arrays from here down. + if (ancestors.has(node)) { + violations.push({ path, reason: "circular reference" }); + return; + } + ancestors.add(node); + try { + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, `${path}[${i}]`, true)); + return; + } + + const proto: unknown = Object.getPrototypeOf(node); + if (proto !== Object.prototype && proto !== null) { + // Buffer, Uint8Array, Date, Map, Set, RegExp, Error, and every class + // instance land here. Some of these survive `structuredClone` intact, + // which is precisely why the structural check exists alongside it — + // VS Code's RPC is JSON-shaped, not structured-clone-shaped. + violations.push({ + path, + reason: + `${describe(node)} is not a plain object; only JSON primitives, ` + + "plain objects and arrays cross the host↔remote boundary intact", + }); + return; + } + + if (Object.getOwnPropertySymbols(node).length > 0) { + violations.push({ + path, + reason: "symbol-keyed properties are dropped on serialization", + }); + } + + const record = node as Record; + if (Object.prototype.hasOwnProperty.call(record, "toJSON")) { + violations.push({ + path, + reason: "own `toJSON` rewrites the payload shape on serialization", + }); + } + + for (const key of Object.keys(record)) { + walk(record[key], `${path}.${key}`, false); + } + } finally { + ancestors.delete(node); + } + }; + + walk(value, rootPath, false); + return violations; +} + +/** + * Deep equality where a missing key and a key holding `undefined` are the + * same thing — see the module docstring for why that tolerance exists and + * why it stops at array elements. + */ +export function equalIgnoringUndefined(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (a === null || b === null) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + return a.every((item, i) => equalIgnoringUndefined(item, b[i])); + } + + const ra = a as Record; + const rb = b as Record; + const keys = new Set([...Object.keys(ra), ...Object.keys(rb)]); + for (const key of keys) { + if (!equalIgnoringUndefined(ra[key], rb[key])) return false; + } + return true; +} + +/** + * Throw unless `value` is free of wire violations. `label` names the payload + * in the failure message (e.g. `getAllCertMaterialV3 result`). + */ +export function assertWireSafe(label: string, value: unknown): void { + const violations = findWireViolations(value); + if (violations.length === 0) return; + const detail = violations + .map((v) => ` ${v.path}: ${v.reason}`) + .join("\n"); + throw new WireGuardError( + `${label} would not survive the host↔remote hop:\n${detail}`, + violations + ); +} + +/** + * Throw unless `value` comes back unchanged from both a `structuredClone` and + * a JSON round trip. + */ +export function assertRoundTripStable(label: string, value: unknown): void { + let cloned: unknown; + try { + cloned = structuredClone(value); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new WireGuardError(`${label} is not structured-cloneable: ${message}`); + } + if (!equalIgnoringUndefined(value, cloned)) { + throw new WireGuardError(`${label} changed under structuredClone.`); + } + + let json: unknown; + try { + json = JSON.parse(JSON.stringify(value)) as unknown; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new WireGuardError(`${label} is not JSON-serializable: ${message}`); + } + if (!equalIgnoringUndefined(value, json)) { + throw new WireGuardError( + `${label} changed under a JSON round trip. This payload would arrive ` + + "mangled in a real dev container." + ); + } +} + +/** + * The check to call on anything crossing the host↔workspace command seam: + * structural walk first (better messages), then the real round trips. + */ +export function assertCrossHostPayload(label: string, value: unknown): void { + assertWireSafe(label, value); + assertRoundTripStable(label, value); +} diff --git a/test/vscode-e2e/tsconfig.json b/test/vscode-e2e/tsconfig.json new file mode 100644 index 0000000..53a1776 --- /dev/null +++ b/test/vscode-e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "moduleResolution": "Node16", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} From f7546f41fba05952805bc034965e5270d66c5338 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:30:16 +0000 Subject: [PATCH 2/5] ci: install xvfb explicitly in the vscode-e2e job The hosted ubuntu images ship it, but pinning the dependency costs a few seconds and removes a reliance on the runner image's contents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1 --- .github/workflows/build-extensions.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index a1720e7..fa7acb6 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -119,7 +119,15 @@ jobs: run: npm run gen:test-cert # VS Code is an Electron app and needs an X server even with - # --disable-gpu. `xvfb-run -a` picks a free display number. + # --disable-gpu. The hosted ubuntu images ship xvfb, but installing it + # explicitly costs a few seconds and removes a dependency on the runner + # image's contents staying the way they are. + - name: Install xvfb + run: sudo apt-get update -y && sudo apt-get install -y --no-install-recommends xvfb + + # `xvfb-run -a` picks a free display number. DEVCERTS_E2E_VSCODE_VERSION + # pins a specific VS Code build if `stable` ever regresses; unset means + # stable, which is what we want CI tracking by default. - name: Run VS Code E2E suite run: xvfb-run -a npm run test:e2e From a7541211b0be8c863b4b50ad3c841df6a400788f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:34:41 +0000 Subject: [PATCH 3/5] fix(e2e): load reflect-metadata before the shared package The suite bundles @devcontainer-dev-certs/shared, which pulls in @peculiar/x509 -> tsyringe, and tsyringe binds its @injectable decorators to Reflect.metadata at module-init time. Without the polyfill the extension host threw while requiring --extensionTestsPath, before any test ran: Error: tsyringe requires a reflect polyfill. at node_modules/tsyringe/dist/cjs/index.js (suite.cjs:11081:13) at node_modules/@peculiar/x509/build/x509.cjs.js at src/shared/src/cert/types.ts Both extensions' entry points and vitest.setup.ts already carry this import; the E2E entry point was the one place that didn't. reflect-metadata becomes an explicit root devDependency rather than something inherited by workspace hoisting. Also adds test/vscode-e2e/loadCheck.cjs, which stubs `vscode` and requires the built bundle. It reproduces this exact failure in ~200ms. Finding it the other way cost an npm ci, two extension builds, a ~110MB VS Code download and an Electron launch, so the CI job now runs the load check before the download step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1 --- .github/workflows/build-extensions.yml | 8 +++ package-lock.json | 1 + package.json | 2 + test/vscode-e2e/loadCheck.cjs | 93 ++++++++++++++++++++++++++ test/vscode-e2e/src/index.ts | 6 ++ 5 files changed, 110 insertions(+) create mode 100644 test/vscode-e2e/loadCheck.cjs diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index fa7acb6..df3a875 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -118,6 +118,14 @@ jobs: - name: Generate test certificate fixture run: npm run gen:test-cert + # ~200ms, and it runs BEFORE the VS Code download on purpose. A bundle + # that throws during module init (a missing reflect-metadata polyfill is + # the one that already bit us) fails identically inside the extension + # host — but only after npm ci, two builds and a ~110MB download. Cheapest + # failure, earliest. + - name: Check E2E suite bundle loads + run: npm run check:e2e-load + # VS Code is an Electron app and needs an X server even with # --disable-gpu. The hosted ubuntu images ship xvfb, but installing it # explicitly costs a few seconds and removes a dependency on the runner diff --git a/package-lock.json b/package-lock.json index ce8c337..f6c7859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@vscode/test-electron": "^2.5.2", "eslint": "^10.3.0", "globals": "^17.6.0", + "reflect-metadata": "^0.2.2", "semver": "^7.7.4", "typescript-eslint": "^8.59.2" } diff --git a/package.json b/package.json index ce06148..ed6f419 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "eslint .", "typecheck:e2e": "tsc --noEmit -p test/vscode-e2e/tsconfig.json", "build:e2e": "node test/vscode-e2e/esbuild.mjs", + "check:e2e-load": "node test/vscode-e2e/loadCheck.cjs", "test:e2e": "node test/vscode-e2e/runTests.mjs" }, "devDependencies": { @@ -19,6 +20,7 @@ "@vscode/test-electron": "^2.5.2", "eslint": "^10.3.0", "globals": "^17.6.0", + "reflect-metadata": "^0.2.2", "semver": "^7.7.4", "typescript-eslint": "^8.59.2" } diff --git a/test/vscode-e2e/loadCheck.cjs b/test/vscode-e2e/loadCheck.cjs new file mode 100644 index 0000000..0504da0 --- /dev/null +++ b/test/vscode-e2e/loadCheck.cjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Fast smoke check: can the extension host actually `require()` the built + * suite bundle? + * + * This exists because it already caught a real failure. The suite bundles the + * shared package, which pulls in @peculiar/x509 -> tsyringe, and tsyringe + * binds its @injectable decorators to `Reflect.metadata` at module-init time. + * A missing `import "reflect-metadata"` in the entry point throws while VS + * Code is requiring --extensionTestsPath — before a single test runs — and the + * only way to find out was a full CI job: npm ci, two extension builds, a + * ~110MB VS Code download, an Electron launch. Roughly two minutes to learn + * something reproducible in 200ms. + * + * So: stub `vscode` (the one module the host injects and node_modules can't + * resolve) and require the bundle. Module-level initialization is exactly what + * this class of bug happens in, and it needs no VS Code at all. + * + * Deliberately NOT a substitute for the real run — it proves the bundle loads, + * not that anything works. Run it first because it's ~200ms and turns the + * cheapest failure into the earliest one. + * + * node test/vscode-e2e/loadCheck.cjs (or: npm run check:e2e-load) + */ +const Module = require("node:module"); +const path = require("node:path"); +const fs = require("node:fs"); + +const BUNDLE = path.resolve(__dirname, "..", "..", ".out", "vscode-e2e", "suite.cjs"); + +if (!fs.existsSync(BUNDLE)) { + console.error(`Suite bundle is missing (${BUNDLE}). Run: npm run build:e2e`); + process.exit(1); +} + +/** + * Minimal `vscode` stand-in. Only needs to satisfy module-level evaluation — + * every real API call happens inside a test function, which never runs here. + */ +const vscodeStub = { + extensions: { getExtension: () => undefined }, + commands: { + getCommands: () => Promise.resolve([]), + executeCommand: () => Promise.resolve(undefined), + registerCommand: () => ({ dispose() {} }), + }, + window: { + createOutputChannel: () => ({ appendLine() {}, show() {}, dispose() {} }), + showInformationMessage: () => Promise.resolve(undefined), + showWarningMessage: () => Promise.resolve(undefined), + showErrorMessage: () => Promise.resolve(undefined), + }, + workspace: { + getConfiguration: () => ({ get: (_key, fallback) => fallback, inspect: () => undefined }), + }, + env: { remoteName: undefined }, + ExtensionMode: { Production: 1, Development: 2, Test: 3 }, + ConfigurationTarget: { Global: 1, Workspace: 2, WorkspaceFolder: 3 }, + l10n: { t: (message) => message }, + debug: { registerDebugConfigurationProvider: () => ({ dispose() {} }) }, +}; + +const originalResolve = Module._resolveFilename; +Module._resolveFilename = function (request, ...rest) { + if (request === "vscode") return "vscode"; + return originalResolve.call(this, request, ...rest); +}; +require.cache["vscode"] = { + id: "vscode", + filename: "vscode", + loaded: true, + exports: vscodeStub, +}; + +try { + const suite = require(BUNDLE); + if (typeof suite.run !== "function") { + console.error( + "Suite bundle loaded but does not export run(). VS Code's " + + "--extensionTestsPath contract requires it." + ); + process.exit(1); + } + console.log("E2E suite bundle loads cleanly and exports run()."); +} catch (err) { + console.error("Suite bundle threw during module initialization:"); + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); + console.error( + "\nThis would fail identically inside the extension host, after the " + + "VS Code download. Fix it here." + ); + process.exit(1); +} diff --git a/test/vscode-e2e/src/index.ts b/test/vscode-e2e/src/index.ts index c47a9c5..08149be 100644 --- a/test/vscode-e2e/src/index.ts +++ b/test/vscode-e2e/src/index.ts @@ -4,6 +4,12 @@ * The contract is just `run(): Promise` — resolve on success, reject on * failure. VS Code exits non-zero when it rejects. */ +// Must be the first import, exactly as in both extensions' entry points. +// This suite bundles the shared package, which pulls in @peculiar/x509 -> +// tsyringe, and tsyringe wires up its @injectable decorators against +// Reflect.metadata at module-init time. Without the polyfill loaded first the +// extension host throws while requiring this file, before a single test runs. +import "reflect-metadata"; import { runRegistered } from "./runner"; import { registerActivationTests } from "./suites/activation"; import { registerCertFlowTests } from "./suites/certFlow"; From 7dc641011eb1ad1c7dab8107accafa5ca1dc4c84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:38:47 +0000 Subject: [PATCH 4/5] docs: record the E2E load check in AGENTS.md It is a new script with a non-obvious reason to exist and a specific place in the ordering (before the VS Code download), neither of which was written down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1 --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dd454b3..6c3ba19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,9 +100,12 @@ These decisions were made deliberately. Do not change them without discussion. ``` npm run typecheck:e2e # tsc pass; esbuild and the runner never typecheck npm run build:e2e # bundles the suite to .out/vscode-e2e/suite.cjs +npm run check:e2e-load # ~200ms: does the bundle even load? (see below) npm run test:e2e # on Linux: xvfb-run -a npm run test:e2e ``` +**Run `check:e2e-load` before `test:e2e`, and keep it ahead of the download step in CI.** It stubs `vscode` and `require()`s the built bundle, which is where module-init bugs surface. That is not hypothetical: the suite bundles the shared package, which pulls in `@peculiar/x509` → tsyringe, and a missing `import "reflect-metadata"` in the entry point threw inside the extension host before a single test ran. The load check reproduces that in ~200ms; discovering it the other way costs `npm ci`, two extension builds, a ~110MB VS Code download and an Electron launch. If you add an import to the suite that reaches new runtime machinery, this is the check that tells you cheaply. + It needs `dist/extension.js` for both extensions and the `.out/test-fixtures/` cert (the launcher runs `gen:test-cert` itself if it's missing). CI runs it as the separate `vscode-e2e` job in `build-extensions.yml` — separate so a VS Code download failure can't be confused with a unit-test regression. **What it covers:** activation of both extensions without throwing, command registration on both sides, and one vertical slice — `getAllCertMaterialV3` driven from the workspace extension to the UI extension, with the container-side install asserted on disk (PEM present and matching, `{hash}.N` symlink resolving, .NET Root store PFX written, My-store PFX correctly *absent*). All writes are redirected into a `mkdtemp` sandbox via `HOME` and `DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY`, so the runner's real `~/.aspnet` and `~/.dotnet` are untouched. From 12d606a6e0b84c3d7ae18dddb07cb5e7a366f546 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:54:55 +0000 Subject: [PATCH 5/5] Address code review: root deps, Windows sandbox, stderr flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review, all confirmed: esbuild and typescript back the new root-level build:e2e and typecheck:e2e scripts but resolved only through workspace hoisting. A version conflict or a nested install strategy would have broken the CI job for reasons with nothing to do with the code. Declared at the root, matching the workspace versions, as @vscode/test-electron and reflect-metadata already are. The sandbox set only HOME, which os.homedir() ignores on Windows in favour of %USERPROFILE% — so `npm run test:e2e` on a Windows checkout would have written the Root-store PFX into the developer's real profile. Both variables are now set. The containment check also moves ahead of the install and covers all three target directories, so a future break in the redirect refuses to run rather than reporting the damage afterwards. It compares with path.relative instead of startsWith, which additionally stops `-evil` reading as inside ``. process.exit() truncates pending pipe writes, discarding the stack trace and the sandbox path precisely when the job is red and they are the only things worth having. Setting process.exitCode lets Node drain first; verified the launcher still exits 1 with the diagnostics intact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AX4749FUyTUSGTb3VWMSC1 --- package-lock.json | 2 ++ package.json | 2 ++ test/vscode-e2e/runTests.mjs | 10 +++++++- test/vscode-e2e/src/suites/certFlow.ts | 34 +++++++++++++++++++++----- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6c7859..99f24a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@vscode/test-electron": "^2.5.2", + "esbuild": "^0.28.0", "eslint": "^10.3.0", "globals": "^17.6.0", "reflect-metadata": "^0.2.2", "semver": "^7.7.4", + "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" } }, diff --git a/package.json b/package.json index ed6f419..6b886a8 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@vscode/test-electron": "^2.5.2", + "esbuild": "^0.28.0", "eslint": "^10.3.0", "globals": "^17.6.0", "reflect-metadata": "^0.2.2", "semver": "^7.7.4", + "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" } } diff --git a/test/vscode-e2e/runTests.mjs b/test/vscode-e2e/runTests.mjs index 9146e63..196e6e9 100644 --- a/test/vscode-e2e/runTests.mjs +++ b/test/vscode-e2e/runTests.mjs @@ -156,7 +156,11 @@ async function main() { extensionTestsEnv: { // Redirects getDotNetStorePath / getDotNetRootStorePath / // getKestrelDefaultCertPath, which are all os.homedir()-relative. + // BOTH are required: Node's os.homedir() reads $HOME on POSIX and + // %USERPROFILE% on Windows. Setting only HOME leaves a Windows run + // writing the Root-store PFX into the developer's real profile. HOME: sandbox.home, + USERPROFILE: sandbox.home, DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY: sandbox.trustDir, // The remote-gate seam. Only honored outside ExtensionMode.Production // — see isRemoteContext in the workspace extension. @@ -177,7 +181,11 @@ async function main() { } else { console.error(`Sandbox left in place for inspection: ${sandbox.root}`); } - process.exit(exitCode); + // Setting exitCode and returning, rather than process.exit(), so Node drains + // stdout/stderr before exiting. process.exit() truncates pending writes on a + // pipe — which is exactly what CI gives us, and exactly when the stack trace + // and the sandbox path above are the only things worth having. + process.exitCode = exitCode; } await main(); diff --git a/test/vscode-e2e/src/suites/certFlow.ts b/test/vscode-e2e/src/suites/certFlow.ts index 19183ce..1a3ff8e 100644 --- a/test/vscode-e2e/src/suites/certFlow.ts +++ b/test/vscode-e2e/src/suites/certFlow.ts @@ -37,6 +37,16 @@ import { assert, assertEqual, test } from "../runner"; import { sandbox } from "../env"; import { activateBoth } from "./activation"; +/** + * True when `candidate` sits inside `root`. Uses path.relative rather than + * startsWith so it survives Windows separators and drive-letter casing, and + * so `/tmp/sandbox-evil` isn't read as being inside `/tmp/sandbox`. + */ +function isInside(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); +} + /** Strip PEM armor and whitespace, leaving the base64 DER body. */ function pemBody(pem: string): string { const begin = pem.indexOf("-----BEGIN CERTIFICATE-----"); @@ -91,6 +101,24 @@ export function registerCertFlowTests(): void { const bundle = await fetchBundle(); const material = userCert(bundle, userCertName); + // Precondition, checked BEFORE anything is written. These three + // directories are where the install lands, and two of them are derived + // from os.homedir() — which reads $HOME on POSIX but %USERPROFILE% on + // Windows. If the redirect ever fails, the install would plant a + // certificate in the developer's real profile and a check made afterwards + // would report the damage rather than prevent it. + for (const [label, dir] of [ + [".NET My store", getDotNetStorePath()], + [".NET Root store", getDotNetRootStorePath()], + ["OpenSSL trust dir", trustDir], + ] as const) { + assert( + isInside(home, dir), + `refusing to run: ${label} resolves to ${dir}, which is outside the ` + + `sandbox ${home}. The HOME/USERPROFILE redirect did not take effect.` + ); + } + // This is the command the activation path calls; auto-inject is turned // off for the run so the flow is driven explicitly rather than racing // startup. @@ -175,11 +203,5 @@ export function registerCertFlowTests(): void { false, `${myPfx} should NOT exist — the cert did not opt into the My store` ); - - // --- the sandbox actually contained the writes --- - assert( - rootPfx.startsWith(home), - `install wrote outside the sandbox: ${rootPfx} is not under ${home}` - ); }); }