diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index 5e7833d..df3a875 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -76,6 +76,69 @@ 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 + + # ~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 + # 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 + windows-certificate-validation: name: Windows Certificate Validation runs-on: windows-latest diff --git a/AGENTS.md b/AGENTS.md index dd5a1dd..6c3ba19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,39 @@ 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 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. + +**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..99f24a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,13 @@ ], "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" } }, @@ -2116,6 +2120,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 +2762,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 +2851,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 +3739,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 +4055,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 +4090,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 +4168,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 +4191,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 +4220,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 +4359,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 +4475,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 +4845,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 +5029,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 +5268,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 +5321,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 +5428,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 +5715,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 +5917,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 +6089,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 +6202,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 +6355,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 +6876,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..6b886a8 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,21 @@ "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", + "check:e2e-load": "node test/vscode-e2e/loadCheck.cjs", + "test:e2e": "node test/vscode-e2e/runTests.mjs" }, "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/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/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/runTests.mjs b/test/vscode-e2e/runTests.mjs new file mode 100644 index 0000000..196e6e9 --- /dev/null +++ b/test/vscode-e2e/runTests.mjs @@ -0,0 +1,191 @@ +#!/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. + // 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. + 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}`); + } + // 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/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..08149be --- /dev/null +++ b/test/vscode-e2e/src/index.ts @@ -0,0 +1,31 @@ +/** + * 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. + */ +// 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"; +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..1a3ff8e --- /dev/null +++ b/test/vscode-e2e/src/suites/certFlow.ts @@ -0,0 +1,207 @@ +/** + * 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"; + +/** + * 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-----"); + 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); + + // 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. + 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` + ); + }); +} 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"] +}