@specific.dev/spectest 0.91.2 → 0.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { generateHostCert as mintHostCert } from "./harness/certificate.js";
1
2
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
3
  import { waitForAll } from "./harness/concurrency.js";
3
4
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
@@ -89,7 +90,7 @@ import {
89
90
  serviceDockerignore as composeServiceDockerignore,
90
91
  unionDockerignore,
91
92
  } from "./harness/build-context.js";
92
- import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
93
+ import { startServices } from "./harness/service-graph.js";
93
94
  import {
94
95
  casesMetadata as catalogueCases,
95
96
  groupsMetadata as catalogueGroups,
@@ -103,7 +104,9 @@ import {
103
104
  sanitizeSegment,
104
105
  } from "./harness/volume-paths.js";
105
106
  import { pollUntilReady } from "./harness/ready-poll.js";
106
- import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
107
+ import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
108
+ import { runTypecheck, type TypecheckReport } from "./harness/typecheck.js";
109
+ import { evaluateChecked } from "./harness/eval-typecheck.js";
107
110
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
108
111
  import { cpus, tmpdir } from "node:os";
109
112
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
@@ -1857,35 +1860,6 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
1857
1860
  }
1858
1861
  }
1859
1862
 
1860
- /** Validate the `dependsOn` graph and return the name→service map used to
1861
- * walk it. Rules live in `harness/service-graph.ts`. */
1862
- function validateServiceGraph(services: NamedService[]): Map<string, NamedService> {
1863
- return validateGraph(services);
1864
- }
1865
-
1866
- async function startServices(
1867
- services: NamedService[],
1868
- startOne: (svc: NamedService) => Promise<void>,
1869
- ): Promise<void> {
1870
- const byName = validateServiceGraph(services);
1871
- const started = new Map<string, Promise<void>>();
1872
- const start = (svc: NamedService): Promise<void> => {
1873
- const existing = started.get(svc.name);
1874
- if (existing) return existing;
1875
- const p = (async () => {
1876
- await Promise.all(
1877
- (svc.dependsOn ?? [])
1878
- .filter((d) => byName.has(d))
1879
- .map((d) => start(byName.get(d)!)),
1880
- );
1881
- await startOne(svc);
1882
- })();
1883
- started.set(svc.name, p);
1884
- return p;
1885
- };
1886
- await Promise.all(services.map(start));
1887
- }
1888
-
1889
1863
  // ────────────────────────────────────────────────────────────────────────
1890
1864
  // Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
1891
1865
  //
@@ -2055,72 +2029,6 @@ function buildIngress(project: Project): void {
2055
2029
  }
2056
2030
  }
2057
2031
 
2058
- /**
2059
- * Generate a leaf cert + key for one ingress route (fake or service
2060
- * proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
2061
- * hostname the route answers to, so a client connecting with TLS
2062
- * verifies cleanly regardless of which hostname it used. Shells out
2063
- * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
2064
- * bookworm ships 3.0.x).
2065
- *
2066
- * `label` is a short tag baked into the cert Subject CN and the temp
2067
- * file names — only used for diagnostics, not for TLS verification.
2068
- */
2069
- async function generateHostCert(
2070
- label: string,
2071
- hostnames: string[],
2072
- ): Promise<{ cert: string; key: string }> {
2073
- const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
2074
- const keyPath = path.join("/tmp", `${id}.key`);
2075
- const crtPath = path.join("/tmp", `${id}.crt`);
2076
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
2077
- const args = [
2078
- "req",
2079
- "-newkey",
2080
- "rsa:2048",
2081
- "-nodes",
2082
- "-keyout",
2083
- keyPath,
2084
- "-out",
2085
- crtPath,
2086
- "-x509",
2087
- "-CA",
2088
- CA_PATH,
2089
- "-CAkey",
2090
- CA_KEY_PATH,
2091
- "-days",
2092
- "3650",
2093
- "-subj",
2094
- `/CN=spectest-${label}`,
2095
- "-addext",
2096
- `subjectAltName=${sans}`,
2097
- "-addext",
2098
- "basicConstraints=CA:FALSE",
2099
- "-addext",
2100
- "extendedKeyUsage=serverAuth",
2101
- "-addext",
2102
- "keyUsage=digitalSignature,keyEncipherment",
2103
- ];
2104
- const r = await shx("openssl", args, 30_000);
2105
- if (r.code !== 0) {
2106
- throw new Error(
2107
- `openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`,
2108
- );
2109
- }
2110
- try {
2111
- const [cert, key] = await Promise.all([
2112
- fs.readFile(crtPath, "utf8"),
2113
- fs.readFile(keyPath, "utf8"),
2114
- ]);
2115
- return { cert, key };
2116
- } finally {
2117
- await Promise.all([
2118
- fs.unlink(keyPath).catch(() => {}),
2119
- fs.unlink(crtPath).catch(() => {}),
2120
- ]);
2121
- }
2122
- }
2123
-
2124
2032
  /** Resolve the spectest-net bridge gateway IP — the address containers
2125
2033
  * use to reach the VM host. Asks dockerd via the docker CLI; cached for
2126
2034
  * the daemon's life because the network is recreated only on reload. */
@@ -2163,7 +2071,11 @@ type Route = IngressRoute<FakeRuntime>;
2163
2071
  */
2164
2072
  const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
2165
2073
 
2166
- /** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
2074
+ /** Mint ingress and container certificates with the environment CA. */
2075
+ function generateHostCert(label: string, hostnames: string[]): Promise<{ cert: string; key: string }> {
2076
+ return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
2077
+ }
2078
+
2167
2079
  async function startIngress(): Promise<void> {
2168
2080
  const hasIngress =
2169
2081
  FAKES.size > 0 ||
@@ -3517,9 +3429,13 @@ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
3517
3429
  // hook's — into the boot log, so GET /boot-log can explain a slow or
3518
3430
  // finished boot. Restored in the finally at the end.
3519
3431
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3432
+ // One dispatcher for the whole bootstrap: service hooks run concurrently.
3433
+ // Only each hook's async scope opts into wrapped fetch responses.
3434
+ const restoreFetch = installFetchWrapper();
3520
3435
  try {
3521
3436
  return await bootstrapInner(prepared);
3522
3437
  } finally {
3438
+ restoreFetch();
3523
3439
  restoreBootLog();
3524
3440
  }
3525
3441
  }
@@ -3693,20 +3609,25 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3693
3609
  await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
3694
3610
  if (svc.setup) {
3695
3611
  progressService(svc.name, { status: "probing", detail: "running setup" });
3696
- const helpers = await ensureHelpers(svc.name, svc);
3612
+ const fetchScope = createInstrumentationScope(false);
3697
3613
  try {
3698
- await svc.setup({
3699
- name: svc.name,
3700
- helpers,
3701
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3702
- ...{ [COMPONENT_RUNTIME]: {
3703
- images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
3704
- gateway: await bridgeGatewayIp(),
3705
- caBundle: await ensureCaBundle(),
3706
- } },
3614
+ await runInstrumented(fetchScope, async () => {
3615
+ const helpers = await ensureHelpers(svc.name, svc);
3616
+ await svc.setup!({
3617
+ name: svc.name,
3618
+ helpers,
3619
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3620
+ ...{ [COMPONENT_RUNTIME]: {
3621
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
3622
+ gateway: await bridgeGatewayIp(),
3623
+ caBundle: await ensureCaBundle(),
3624
+ } },
3625
+ });
3707
3626
  });
3708
3627
  } catch (err) {
3709
3628
  throw await withContainerPostMortem(svc.name, err);
3629
+ } finally {
3630
+ fetchScope.active = false;
3710
3631
  }
3711
3632
  }
3712
3633
  })()]);
@@ -4855,7 +4776,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
4855
4776
  exec: (service, command, opts) =>
4856
4777
  componentExec(service, command, opts, execTimeoutMs),
4857
4778
  // Read `globalThis.fetch` at call time: the wrapper is installed for
4858
- // the duration of a test / eval / project setup, so a context built
4779
+ // the duration of bootstrap / a test / eval / project setup, so a context built
4859
4780
  // before it lands still gets the wrapped one.
4860
4781
  fetch: ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
4861
4782
  globalThis.fetch(input, init)) as unknown as SpectestContext["fetch"],
@@ -6246,7 +6167,12 @@ async function evalCode(code: string): Promise<EvalResult> {
6246
6167
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
6247
6168
  await fs.writeFile(filePath, prepared.code);
6248
6169
  const moduleUrl = pathToFileURL(filePath).href;
6249
- const mod = (await runInstrumented(fetchScope, () => import(moduleUrl))) as { default?: unknown };
6170
+ const mod = await evaluateChecked(
6171
+ { file: filePath, appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
6172
+ projectEntry: loaded ? resolveEntry() : undefined },
6173
+ (text) => { chunks.push(text); },
6174
+ () => runInstrumented(fetchScope, () => import(moduleUrl)) as Promise<{ default?: unknown }>,
6175
+ );
6250
6176
  outcome = { ok: true, result: safeSerialize(mod.default) };
6251
6177
  } catch (err) {
6252
6178
  const e = err as Error;
@@ -6327,46 +6253,11 @@ async function evalCode(code: string): Promise<EvalResult> {
6327
6253
  * blocking rules, which always use THIS copy rather than the project's own —
6328
6254
  * can be driven outside a VM. */
6329
6255
  const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
6330
- /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
6331
- const TYPECHECK_ERROR_CAP = 50;
6332
-
6333
- interface TypecheckError {
6334
- /** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
6335
- file: string;
6336
- line: number;
6337
- column: number;
6338
- /** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
6339
- code: string;
6340
- message: string;
6341
- }
6342
-
6343
- interface TypecheckReport {
6344
- status: "ok" | "errors" | "skipped" | "failed";
6345
- errors: TypecheckError[];
6346
- totalErrors: number;
6347
- durationMs: number;
6348
- /** Why the check was skipped / how it failed. */
6349
- detail?: string;
6350
- /**
6351
- * Findings that FAIL the run, as opposed to `errors`, which only advise.
6352
- * A diagnostic qualifies only when it is true of the program that runs
6353
- * rather than of the declared types alone — see `harness/wrapper-rules.ts`
6354
- * for why no `tsc` code can be in here and why these can.
6355
- *
6356
- * The list is the rollout mechanism as well as the payload: an older SDK
6357
- * never sends the field, `#[serde(default)]` leaves it empty on the server,
6358
- * and the gate is inert for every project that has not moved to a version
6359
- * carrying it. The version a project runs is already its own choice
6360
- * (`sdk.rs` honours the declared lower bound), so the bump is the consent.
6361
- */
6362
- blocking?: TypecheckError[];
6363
- }
6364
-
6365
6256
  let TYPECHECK: Promise<TypecheckReport> | null = null;
6366
6257
 
6367
6258
  /** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
6368
6259
  function startTypecheck(): void {
6369
- TYPECHECK = runTypecheck().catch((err) => ({
6260
+ TYPECHECK = runProjectTypecheck().catch((err) => ({
6370
6261
  status: "failed" as const,
6371
6262
  errors: [],
6372
6263
  totalErrors: 0,
@@ -6377,149 +6268,9 @@ function startTypecheck(): void {
6377
6268
  TYPECHECK.catch(() => {});
6378
6269
  }
6379
6270
 
6380
- /** The tsconfig used when the project doesn't ship its own. `lib` is left to
6381
- * the target default (which includes DOM — browser.evaluate callbacks
6382
- * reference `document`); `types` pulls Bun's ambient globals from the baked
6383
- * install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
6384
- function generatedTsconfig(): string {
6385
- return JSON.stringify(
6386
- {
6387
- compilerOptions: {
6388
- target: "esnext",
6389
- module: "esnext",
6390
- moduleResolution: "bundler",
6391
- strict: true,
6392
- noEmit: true,
6393
- skipLibCheck: true,
6394
- esModuleInterop: true,
6395
- resolveJsonModule: true,
6396
- jsx: "react-jsx",
6397
- types: ["bun"],
6398
- typeRoots: [
6399
- path.join(APP_DIR, "node_modules", "@types"),
6400
- path.join(TYPECHECK_DIR, "node_modules", "@types"),
6401
- ],
6402
- },
6403
- include: ["**/*.ts", "**/*.tsx"],
6404
- exclude: ["node_modules"],
6405
- },
6406
- null,
6407
- 2,
6408
- );
6409
- }
6410
-
6411
- /** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
6412
- * with indented elaboration lines folded into the preceding error. */
6413
- function parseTscOutput(output: string): {
6414
- errors: TypecheckError[];
6415
- total: number;
6416
- suppressed: number;
6417
- } {
6418
- const errors: TypecheckError[] = [];
6419
- let total = 0;
6420
- let suppressed = 0;
6421
- let last: TypecheckError | null = null;
6422
- for (const line of output.split("\n")) {
6423
- const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
6424
- if (!m) {
6425
- // Elaboration lines are indented; fold them into the last kept error.
6426
- if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
6427
- continue;
6428
- }
6429
- const [, rawFile, ln, col, code, message] = m;
6430
- // Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
6431
- // (the daemon's cwd), so project files come out relative ("tests/x.ts")
6432
- // and anything outside — the SDK at /opt/spectest/sdk, node_modules —
6433
- // is `../…` or absolute. Errors in our own SDK are ours to fix, not the
6434
- // user's to read.
6435
- const abs = path.resolve(APP_DIR, rawFile!);
6436
- const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
6437
- if (!inApp) {
6438
- suppressed += 1;
6439
- last = null;
6440
- continue;
6441
- }
6442
- total += 1;
6443
- const err: TypecheckError = {
6444
- file: path.relative(APP_DIR, abs),
6445
- line: Number(ln),
6446
- column: Number(col),
6447
- code: code!,
6448
- message: message!,
6449
- };
6450
- if (errors.length < TYPECHECK_ERROR_CAP) {
6451
- errors.push(err);
6452
- last = err;
6453
- } else {
6454
- last = null;
6455
- }
6456
- }
6457
- return { errors, total, suppressed };
6458
- }
6459
-
6460
- async function runTypecheck(): Promise<TypecheckReport> {
6461
- const started = Date.now();
6462
- // The project's own typescript wins over the baked copy.
6463
- const tsc = [
6464
- path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
6465
- path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
6466
- ].find((p) => existsSync(p));
6467
- if (!tsc) {
6468
- return {
6469
- status: "skipped",
6470
- errors: [],
6471
- totalErrors: 0,
6472
- durationMs: 0,
6473
- detail: "typescript is not installed (base image predates the baked typechecker)",
6474
- };
6475
- }
6476
- let config = path.join(APP_DIR, "tsconfig.json");
6477
- if (!existsSync(config)) {
6478
- config = path.join(APP_DIR, ".spectest-tsconfig.json");
6479
- await fs.writeFile(config, generatedTsconfig());
6480
- }
6481
- const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
6482
- const durationMs = Date.now() - started;
6483
- if (res.code === 124) {
6484
- return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
6485
- }
6486
- const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
6487
- const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
6488
- const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
6489
- file,
6490
- line,
6491
- column,
6492
- code,
6493
- message,
6494
- });
6495
- const blocking = wrapper.filter((d) => d.blocking).map(strip);
6496
- const advisory = wrapper.filter((d) => !d.blocking).map(strip);
6497
- if (errors.length === 0 && wrapper.length === 0) {
6498
- // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
6499
- // all-suppressed (still ok from the user's perspective) or a compiler
6500
- // crash (config not found, OOM) — surface the latter.
6501
- if (res.code !== 0 && suppressed === 0) {
6502
- const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
6503
- return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
6504
- }
6505
- if (suppressed > 0) {
6506
- console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
6507
- }
6508
- return { status: "ok", errors: [], totalErrors: 0, durationMs };
6509
- }
6510
- // The wrapper findings ride the same list the CLI and the dashboard already
6511
- // render; `blocking` is a second view of the ones that also fail the run, so
6512
- // nothing has to learn a new shape to show them. Blocking leads, because
6513
- // `errors` is capped and the entries that stopped the run must never be the
6514
- // ones the cap drops.
6515
- const all = [...blocking, ...advisory, ...errors];
6516
- return {
6517
- status: "errors",
6518
- errors: all.slice(0, TYPECHECK_ERROR_CAP),
6519
- totalErrors: total + wrapper.length,
6520
- durationMs,
6521
- ...(blocking.length > 0 ? { blocking } : {}),
6522
- };
6271
+ async function runProjectTypecheck(): Promise<TypecheckReport> {
6272
+ return runTypecheck({ appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
6273
+ projectEntry: loaded ? resolveEntry() : undefined, extraDiagnostics: rawFsReport });
6523
6274
  }
6524
6275
 
6525
6276
  /**
@@ -6545,37 +6296,6 @@ async function rawFsReport(): Promise<WrapperDiagnostic[]> {
6545
6296
  }
6546
6297
  }
6547
6298
 
6548
- /**
6549
- * The blocking rules, run against the BAKED compiler whatever the project
6550
- * pins. Best-effort in every direction: an install that predates the API, a
6551
- * config the rules cannot open, or a throw from `typescript/unstable/*` all
6552
- * yield no findings, so a run proceeds exactly as it does today. Only a
6553
- * definite finding can stop one.
6554
- */
6555
- async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
6556
- const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
6557
- if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
6558
- try {
6559
- const run = await runWrapperRules({
6560
- typescriptDir,
6561
- configFile: config,
6562
- appDir: APP_DIR,
6563
- readFile: (f) => fs.readFile(f, "utf8"),
6564
- relative: path.relative,
6565
- join: path.join,
6566
- dirname: path.dirname,
6567
- });
6568
- if (run.status !== "ok") {
6569
- console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
6570
- return [];
6571
- }
6572
- return run.diagnostics;
6573
- } catch (err) {
6574
- console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
6575
- return [];
6576
- }
6577
- }
6578
-
6579
6299
  // ────────────────────────────────────────────────────────────────────────
6580
6300
  // HTTP server
6581
6301
  // ────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import { X509Certificate } from "node:crypto";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { generateHostCert } from "./certificate.js";
7
+
8
+ test("long ingress names retain their full SAN while the diagnostic CN stays bounded", async () => {
9
+ const dir = await mkdtemp(join(tmpdir(), "spectest-leaf-"));
10
+ const ca = join(dir, "ca.crt");
11
+ const key = join(dir, "ca.key");
12
+ try {
13
+ const root = Bun.spawnSync(["openssl", "req", "-x509", "-newkey", "rsa:2048",
14
+ "-nodes", "-days", "1", "-subj", "/CN=test-ca", "-keyout", key, "-out", ca]);
15
+ expect(root.exitCode, root.stderr.toString()).toBe(0);
16
+ // Long enough to exceed both the former CN and temporary filename limits.
17
+ const host = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
18
+ const { cert, key: leafKey } = await generateHostCert(host, [host, "*.example.test"], ca, key);
19
+ const leaf = new X509Certificate(cert);
20
+ expect(leaf.subject.length).toBeLessThanOrEqual(67); // "CN=" + 64 bytes
21
+ expect(leaf.checkHost(host)).toBe(host);
22
+ expect(leaf.checkHost("api.example.test")).toBe("*.example.test");
23
+ expect(leafKey).toContain("PRIVATE KEY");
24
+ const file = join(dir, "leaf.crt");
25
+ await writeFile(file, cert);
26
+ const verified = Bun.spawnSync(["openssl", "verify", "-CAfile", ca, "-verify_hostname", host, file]);
27
+ expect(verified.exitCode, verified.stderr.toString()).toBe(0);
28
+ } finally {
29
+ await rm(dir, { recursive: true, force: true });
30
+ }
31
+ }, 10_000);
@@ -0,0 +1,73 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ /**
10
+ * Generate a leaf cert + key for one ingress route (fake or service
11
+ * proxy), signed by the in-VM root CA. SANs cover every
12
+ * hostname the route answers to, so a client connecting with TLS
13
+ * verifies cleanly regardless of which hostname it used. Shells out
14
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
15
+ * bookworm ships 3.0.x).
16
+ *
17
+ * `label` is a diagnostic tag baked into the cert Subject CN and the temp
18
+ * file names — only used for diagnostics, not for TLS verification.
19
+ */
20
+ export async function generateHostCert(
21
+ label: string,
22
+ hostnames: string[],
23
+ caPath: string,
24
+ caKeyPath: string,
25
+ ): Promise<{ cert: string; key: string }> {
26
+ // CN is diagnostic only; SANs retain the full hostnames. Bound the label
27
+ // for both OpenSSL's 64-byte CN limit and filesystem component limits.
28
+ const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
29
+ const id = `${commonName}-${randomUUID()}`;
30
+ const keyPath = path.join("/tmp", `${id}.key`);
31
+ const crtPath = path.join("/tmp", `${id}.crt`);
32
+ const sans = hostnames.map((h) => `DNS:${h}`).join(",");
33
+ const args = [
34
+ "req",
35
+ "-newkey",
36
+ "rsa:2048",
37
+ "-nodes",
38
+ "-keyout",
39
+ keyPath,
40
+ "-out",
41
+ crtPath,
42
+ "-x509",
43
+ "-CA",
44
+ caPath,
45
+ "-CAkey",
46
+ caKeyPath,
47
+ "-days",
48
+ "3650",
49
+ "-subj",
50
+ `/CN=${commonName}`,
51
+ "-addext",
52
+ `subjectAltName=${sans}`,
53
+ "-addext",
54
+ "basicConstraints=CA:FALSE",
55
+ "-addext",
56
+ "extendedKeyUsage=serverAuth",
57
+ "-addext",
58
+ "keyUsage=digitalSignature,keyEncipherment",
59
+ ];
60
+ try {
61
+ await execFileAsync("openssl", args, { timeout: 30_000 });
62
+ const [cert, key] = await Promise.all([
63
+ fs.readFile(crtPath, "utf8"),
64
+ fs.readFile(keyPath, "utf8"),
65
+ ]);
66
+ return { cert, key };
67
+ } finally {
68
+ await Promise.all([
69
+ fs.unlink(keyPath).catch(() => {}),
70
+ fs.unlink(crtPath).catch(() => {}),
71
+ ]);
72
+ }
73
+ }