|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google LLC All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.dev/license |
| 7 | + */ |
| 8 | + |
| 9 | +import * as fs from 'node:fs/promises'; |
| 10 | +import * as path from 'path'; |
| 11 | +import {pathExists} from './utils.js'; |
| 12 | + |
| 13 | +/** |
| 14 | + * Guards the napi-less deployment path. |
| 15 | + * |
| 16 | + * The compiler also runs where N-API is unavailable, driven by `--sidecar <path>`, |
| 17 | + * which talks to an out-of-process engine over stdio. That works only because engine |
| 18 | + * resolution is never reached by a *static* import: `main.js` pulls the loader in |
| 19 | + * behind a dynamic `import()` inside its non-sidecar branch, and `api.js` exposes it |
| 20 | + * only through `createAnalyzer()`. |
| 21 | + * |
| 22 | + * A single static `import` added to `api.ts`, `hybrid_compiler.ts` or |
| 23 | + * `analyzer_sidecar.ts` would drag the loader into module evaluation and break those |
| 24 | + * deployments at import time. This walks the emitted static import graph from each |
| 25 | + * entry below and fails if it can reach a forbidden module. |
| 26 | + */ |
| 27 | +describe('sidecar isolation', () => { |
| 28 | + const packageDir = path.resolve(import.meta.dirname, '..'); |
| 29 | + |
| 30 | + /** Entry points that must stay free of engine-resolution code. */ |
| 31 | + const ENTRIES = ['src/analyzer_sidecar.js', 'src/hybrid_compiler.js']; |
| 32 | + |
| 33 | + /** Modules that must not be statically reachable from those entries. */ |
| 34 | + const FORBIDDEN = ['analyzer_loader', 'analyzer_napi', 'analyzer_wasm', 'wasm_host_fs']; |
| 35 | + |
| 36 | + /** |
| 37 | + * Static `import ... from '...'` / `export ... from '...'` specifiers. |
| 38 | + * Deliberately does not match dynamic `import(...)`, which is the permitted escape hatch. |
| 39 | + */ |
| 40 | + const STATIC_IMPORT = /(?:^|\n)\s*(?:import|export)\b[^;\n]*?\sfrom\s*['"]([^'"]+)['"]/g; |
| 41 | + |
| 42 | + async function resolveSpecifier(fromFile: string, specifier: string): Promise<string | null> { |
| 43 | + if (!specifier.startsWith('.')) { |
| 44 | + return null; // bare specifier: an external package, not our graph |
| 45 | + } |
| 46 | + const resolved = path.resolve(path.dirname(fromFile), specifier); |
| 47 | + for (const candidate of [resolved, `${resolved}.js`, path.join(resolved, 'index.js')]) { |
| 48 | + const stat = await fs.stat(candidate).catch(() => null); |
| 49 | + if (stat?.isFile()) { |
| 50 | + return candidate; |
| 51 | + } |
| 52 | + } |
| 53 | + return null; |
| 54 | + } |
| 55 | + |
| 56 | + /** Walks the static import graph, returning the first offending path found. */ |
| 57 | + async function findForbiddenPath(entry: string): Promise<string[] | null> { |
| 58 | + const seen = new Set<string>(); |
| 59 | + const stack: Array<[string, string[]]> = [[entry, [path.relative(packageDir, entry)]]]; |
| 60 | + |
| 61 | + while (stack.length > 0) { |
| 62 | + const [file, chain] = stack.pop()!; |
| 63 | + if (seen.has(file)) { |
| 64 | + continue; |
| 65 | + } |
| 66 | + seen.add(file); |
| 67 | + |
| 68 | + const source = await fs.readFile(file, 'utf-8'); |
| 69 | + for (const match of source.matchAll(STATIC_IMPORT)) { |
| 70 | + const target = await resolveSpecifier(file, match[1]); |
| 71 | + if (target === null) { |
| 72 | + continue; |
| 73 | + } |
| 74 | + const nextChain = [...chain, path.relative(packageDir, target)]; |
| 75 | + const base = path.basename(target, '.js'); |
| 76 | + if (FORBIDDEN.includes(base)) { |
| 77 | + return nextChain; |
| 78 | + } |
| 79 | + stack.push([target, nextChain]); |
| 80 | + } |
| 81 | + } |
| 82 | + return null; |
| 83 | + } |
| 84 | + |
| 85 | + for (const entry of ENTRIES) { |
| 86 | + it(`does not statically reach engine-resolution modules from ${entry}`, async () => { |
| 87 | + const file = path.join(packageDir, entry); |
| 88 | + expect(await pathExists(file)) |
| 89 | + .withContext(`Missing build output: ${entry}`) |
| 90 | + .toBe(true); |
| 91 | + |
| 92 | + const chain = await findForbiddenPath(file); |
| 93 | + expect(chain) |
| 94 | + .withContext( |
| 95 | + chain |
| 96 | + ? `Engine-resolution code is statically reachable:\n ${chain.join('\n -> ')}` |
| 97 | + : '', |
| 98 | + ) |
| 99 | + .toBeNull(); |
| 100 | + }); |
| 101 | + } |
| 102 | +}); |
0 commit comments