@rightkit/release 0.2.70 → 0.2.72

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/release.mjs CHANGED
@@ -9,10 +9,19 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
9
9
  import { assertQaBackdoorContract } from "./qa-contract.mjs";
10
10
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
11
11
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
12
+ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
13
+ import {
14
+ canonicalPackageIdentity,
15
+ createNativeFinalizationReceipt,
16
+ mintNativeProvenance,
17
+ resolveNativeFinalizerConfig,
18
+ validateNativeFinalizationOutput,
19
+ } from "./native-release-finalization.mjs";
12
20
  import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
13
21
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
14
22
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
15
23
  import { terminateProcessTree } from "./heavy-command.mjs";
24
+ import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
16
25
 
17
26
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
18
27
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -41,7 +50,6 @@ const opts = {
41
50
  install: false,
42
51
  dryRun: false,
43
52
  skipChecks: false,
44
- skipHardening: false,
45
53
  doctor: false,
46
54
  tier: null,
47
55
  };
@@ -56,7 +64,7 @@ for (let i = 0; i < args.length; i++) {
56
64
  else if (arg === "--install") opts.install = true;
57
65
  else if (arg === "--dry-run") opts.dryRun = true;
58
66
  else if (arg === "--skip-checks") opts.skipChecks = true;
59
- else if (arg === "--skip-hardening") opts.skipHardening = true;
67
+ else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
60
68
  else if (arg === "--doctor") opts.doctor = true;
61
69
  else if (arg === "--tier") opts.tier = args[++i];
62
70
  else if (arg.startsWith("--tier=")) opts.tier = arg.slice("--tier=".length);
@@ -92,6 +100,9 @@ if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<m
92
100
 
93
101
  const root = path.dirname(configPath);
94
102
  const workdir = path.resolve(root, config.workdir ?? ".");
103
+ const nativeLayout = resolveNativeCargoLayout({ appRoot: root, config });
104
+ const nativeAssembly = config.nativeAssembly;
105
+ const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
95
106
  await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
96
107
  validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
97
108
  const target = config.targets?.[opts.platform];
@@ -118,7 +129,7 @@ if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.pl
118
129
  * Assemble preflight inputs from the app's own files. Kept here (not in
119
130
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
120
131
  */
121
- function releasePreflight({ config, configPath, root, repoRoot, platform }) {
132
+ function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout }) {
122
133
  let version;
123
134
  try {
124
135
  version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
@@ -128,10 +139,9 @@ function releasePreflight({ config, configPath, root, repoRoot, platform }) {
128
139
  // Every Cargo.lock the build compiles from; a vendored-OpenSSL edge in any of
129
140
  // them means the build needs a real Windows Perl.
130
141
  const cargoLockPaths = [
131
- path.join(root, "src-tauri", "Cargo.lock"),
132
- path.join(root, "Cargo.lock"),
142
+ ...(suppliedNativeLayout?.lockCandidates ?? []),
133
143
  ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
134
- ];
144
+ ].filter((entry, index, entries) => entries.indexOf(entry) === index);
135
145
  return collectPreflight({
136
146
  platform,
137
147
  appRoot: root,
@@ -140,11 +150,13 @@ function releasePreflight({ config, configPath, root, repoRoot, platform }) {
140
150
  version,
141
151
  configPath,
142
152
  cargoLockPaths,
153
+ nativeLayout: suppliedNativeLayout,
154
+ targetLink: suppliedNativeLayout?.targetLink,
143
155
  });
144
156
  }
145
157
 
146
158
  if (opts.doctor) {
147
- const { buildInputs } = resolveReleaseBuildInputs({
159
+ const { buildInputs, nativeLayout: resolvedNativeLayout } = resolveReleaseBuildInputs({
148
160
  repoRoot: doctorInvocation.repoRoot,
149
161
  appRoot: root,
150
162
  configPath,
@@ -174,7 +186,7 @@ if (opts.doctor) {
174
186
 
175
187
  // Config above, MACHINE below. Printing config never told anyone whether this
176
188
  // box could finish a build; these checks do.
177
- const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform });
189
+ const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout });
178
190
  console.log("preflight:");
179
191
  console.log(formatPreflight(checks));
180
192
  const failures = preflightFailures(checks);
@@ -200,6 +212,7 @@ if (!opts.skipChecks) {
200
212
  for (const script of config.checks ?? []) await runPackageScript(config.packageManager, script, workdir);
201
213
  }
202
214
 
215
+ let nativeFinalization = null;
203
216
  let bundleMarkerReceipts = [];
204
217
  if (opts.platform === "win") {
205
218
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
@@ -215,6 +228,29 @@ if (opts.platform === "win") {
215
228
  console.log(`right-release: bundle marker ${receipt.bundle} ${receipt.alreadyPatched ? "already applied" : "applied"} at offset ${receipt.offset} in ${path.basename(receipt.file)}`);
216
229
  }
217
230
  await signWindows(rawFiles, "raw-exe", root);
231
+ if (nativeAssembly?.packageHook && !hasNativeTargetSpecificPrePackage(target, nativeAssembly.packageHook)) {
232
+ await runCommand(nativeAssembly.packageHook, root);
233
+ }
234
+ if (nativeFinalizer) {
235
+ nativeFinalization = await runNativeFinalizer({
236
+ finalizer: nativeFinalizer,
237
+ nativeAssembly,
238
+ target,
239
+ root,
240
+ signedFiles: rawFiles,
241
+ });
242
+ }
243
+ } else if (nativeAssembly) {
244
+ if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
245
+ if (nativeFinalizer) {
246
+ nativeFinalization = await runNativeFinalizer({
247
+ finalizer: nativeFinalizer,
248
+ nativeAssembly,
249
+ target,
250
+ root,
251
+ signedFiles: target.sign?.prePackageFiles ?? target.sign?.files ?? [],
252
+ });
253
+ }
218
254
  }
219
255
 
220
256
  await runCommand(command, root);
@@ -223,6 +259,8 @@ for (const rel of target.artifacts ?? []) {
223
259
  await mustExist(path.resolve(root, rel), `missing release artifact: ${rel}`);
224
260
  }
225
261
 
262
+ if (nativeFinalization) await completeNativeFinalization(nativeFinalization, { root, target, nativeAssembly });
263
+
226
264
  if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT) {
227
265
  const files = target.sign.files.map((p) => path.resolve(root, p));
228
266
  for (const file of files) await mustExist(file, `missing signing artifact: ${file}`);
@@ -241,10 +279,21 @@ if (target.postSign && opts.platform !== "win") {
241
279
  await runCommand(target.postSign, root);
242
280
  }
243
281
 
244
- if (!opts.skipHardening) {
282
+ {
245
283
  const scanTargets = target.hardening ?? target.artifacts ?? [];
246
284
  if (scanTargets.length) {
247
- await run("node", [HARDENING_SCAN, ...scanTargets.map((p) => path.resolve(root, p))], root);
285
+ let hardeningEvidence = null;
286
+ if (target.hardeningAllowances?.length) {
287
+ const receiptRoot = path.resolve(root, ".right-release", "receipts");
288
+ mkdirSync(receiptRoot, { recursive: true });
289
+ hardeningEvidence = path.join(receiptRoot, opts.platform + "-hardening-evidence.json");
290
+ materializeHardeningEvidence({ root, allowances: target.hardeningAllowances, outputPath: hardeningEvidence });
291
+ }
292
+ await run("node", [
293
+ HARDENING_SCAN,
294
+ ...(hardeningEvidence ? ["--allow-evidence", hardeningEvidence] : []),
295
+ ...scanTargets.map((p) => path.resolve(root, p)),
296
+ ], root);
248
297
  }
249
298
  }
250
299
 
@@ -311,13 +360,180 @@ async function mustExist(file, message) {
311
360
  await access(file).catch(() => fail(message));
312
361
  }
313
362
 
363
+ async function runNativeFinalizer({ finalizer, nativeAssembly, target, root, signedFiles }) {
364
+ if (!finalizer?.cmd) fail(`${config.app} ${opts.platform} nativeAssembly.finalizer must declare a command`);
365
+ const signedArtifacts = nativeSignedArtifacts(signedFiles, root);
366
+ const packageIdentity = nativePackageIdentity({ nativeAssembly, target });
367
+ const provenance = mintNativeProvenance({
368
+ app: config.app,
369
+ version: config.version,
370
+ platform: opts.platform,
371
+ architecture: target.selectedArchitecture ?? target.architecture ?? nativeAssembly?.architecture ?? process.arch,
372
+ targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
373
+ signedArtifacts,
374
+ packageIdentity,
375
+ });
376
+ const outputPath = nativeFinalizerOutputPath({ finalizer, target, root });
377
+ const finalizerCommand = {
378
+ ...finalizer,
379
+ args: finalizer.args.map((arg) => expandFinalizerToken(arg, { provenance, outputPath, packageIdentity })),
380
+ };
381
+ const stdout = await runCommand(finalizerCommand, root, {
382
+ captureStdout: true,
383
+ env: {
384
+ RIGHT_RELEASE_NATIVE_PROVENANCE: provenance,
385
+ RIGHT_RELEASE_NATIVE_FINALIZATION_RECEIPT: outputPath,
386
+ RIGHT_RELEASE_NATIVE_PACKAGE_IDENTITY: JSON.stringify(packageIdentity),
387
+ RIGHT_RELEASE_NATIVE_SIGNED_ARTIFACTS: JSON.stringify(signedArtifacts),
388
+ },
389
+ });
390
+ if (opts.dryRun) return { outputPath, provenance, signedArtifacts, packageIdentity, output: null };
391
+
392
+ let output = null;
393
+ if (existsSync(outputPath)) {
394
+ try { output = JSON.parse(readFileSync(outputPath, "utf8")); } catch (error) { fail(`native finalizer output is not valid JSON: ${outputPath}: ${error.message}`); }
395
+ } else if (stdout?.trim()) {
396
+ try { output = JSON.parse(stdout); } catch { fail(`native finalizer output missing: ${outputPath}`); }
397
+ } else {
398
+ fail(`native finalizer output missing: ${outputPath}`);
399
+ }
400
+ output = enrichNativeFinalizerOutput(output, root);
401
+ try {
402
+ output = validateNativeFinalizationOutput(output, { expectedProvenance: provenance });
403
+ } catch (error) {
404
+ fail(`invalid native finalizer output: ${error.message}`);
405
+ }
406
+ return { outputPath, provenance, signedArtifacts, packageIdentity, output };
407
+ }
408
+
409
+ function enrichNativeFinalizerOutput(output, root) {
410
+ if (output?.provenance) return output;
411
+ if (output?.runtime?.provenance) return { ...output, provenance: output.runtime.provenance };
412
+ if (!output?.output) return output;
413
+ const manifestPath = path.join(path.resolve(root, output.output), "share", "legion", "release.json");
414
+ if (!existsSync(manifestPath)) return output;
415
+ try {
416
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
417
+ const provenance = manifest.runtime?.provenance;
418
+ if (provenance) return { ...output, provenance };
419
+ } catch {
420
+ return output;
421
+ }
422
+ return output;
423
+ }
424
+
425
+ async function completeNativeFinalization(finalization, { root, target, nativeAssembly }) {
426
+ if (opts.dryRun) {
427
+ console.log(`dry-run: native finalization receipt binds archive identity at ${finalization.outputPath}`);
428
+ return;
429
+ }
430
+ const archivePath = nativeArchiveCandidates({ root, target, nativeAssembly }).find((file) => existsSync(file));
431
+ if (!archivePath) fail(`${config.app} ${opts.platform} native finalization cannot find final archive`);
432
+ let receipt;
433
+ try {
434
+ receipt = createNativeFinalizationReceipt({
435
+ app: config.app,
436
+ version: config.version,
437
+ platform: opts.platform,
438
+ architecture: target.selectedArchitecture ?? target.architecture ?? nativeAssembly?.architecture ?? process.arch,
439
+ targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
440
+ signedArtifacts: finalization.signedArtifacts,
441
+ packageIdentity: finalization.packageIdentity,
442
+ archivePath,
443
+ finalizerOutput: finalization.output,
444
+ root,
445
+ });
446
+ } catch (error) {
447
+ fail(`native finalization archive binding failed: ${error.message}`);
448
+ }
449
+ mkdirSync(path.dirname(finalization.outputPath), { recursive: true });
450
+ writeFileSync(finalization.outputPath, `${JSON.stringify(receipt, null, 2)}\n`);
451
+ console.log(`right-release: native finalization verified ${receipt.provenance} archive=${receipt.archiveSha256}`);
452
+ }
453
+
454
+ function nativeSignedArtifacts(files, root) {
455
+ const values = Array.isArray(files) ? files : [];
456
+ return values.map((value) => {
457
+ const declared = typeof value === "string" ? value : value?.path ?? value?.file;
458
+ if (!declared) fail("native finalizer signed artifact path is missing");
459
+ const absolute = path.resolve(root, declared);
460
+ if (opts.dryRun) return { path: path.relative(root, absolute).replaceAll("\\", "/") };
461
+ if (!existsSync(absolute)) fail(`native finalizer signed artifact missing: ${absolute}`);
462
+ return {
463
+ path: path.relative(root, absolute).replaceAll("\\", "/"),
464
+ sha256: createHash("sha256").update(readFileSync(absolute)).digest("hex"),
465
+ sizeBytes: readFileSync(absolute).length,
466
+ };
467
+ });
468
+ }
469
+
470
+ function nativePackageIdentity({ nativeAssembly, target }) {
471
+ const declared = nativeAssembly?.packageIdentity ?? target.packageIdentity;
472
+ if (declared) return canonicalPackageIdentity(declared);
473
+ const artifacts = [
474
+ ...(target.artifacts ?? []),
475
+ ...(target.installer?.artifacts ?? []).map((artifact) => artifact.file),
476
+ ...(target.updater?.artifacts ?? []).map((artifact) => artifact.file),
477
+ ].filter(Boolean);
478
+ return canonicalPackageIdentity({
479
+ kind: target.packageKind ?? "release",
480
+ platform: opts.platform,
481
+ architecture: target.selectedArchitecture ?? target.architecture ?? nativeAssembly?.architecture ?? process.arch,
482
+ ...(artifacts.length ? { artifacts } : {}),
483
+ });
484
+ }
485
+
486
+ function nativeArchiveCandidates({ root, target, nativeAssembly }) {
487
+ const declared = [
488
+ nativeAssembly?.archive,
489
+ nativeAssembly?.archivePath,
490
+ nativeAssembly?.packageIdentity?.path,
491
+ target.archive,
492
+ target.archivePath,
493
+ ...(target.artifacts ?? []),
494
+ ...(target.installer?.artifacts ?? []).map((artifact) => artifact.file),
495
+ ...(target.updater?.artifacts ?? []).map((artifact) => artifact.file),
496
+ ].filter((value) => typeof value === "string" && value.trim());
497
+ return [...new Set(declared.map((value) => path.resolve(root, value)))];
498
+ }
499
+
500
+ function nativeFinalizerOutputPath({ finalizer, target, root }) {
501
+ const declared = finalizer.output
502
+ ?? target.evidence?.provenance
503
+ ?? target.provenance
504
+ ?? `.right-release/receipts/native-finalization-${opts.platform}.json`;
505
+ return path.resolve(root, declared);
506
+ }
507
+
508
+ function expandFinalizerToken(value, { provenance, outputPath, packageIdentity }) {
509
+ const replacements = {
510
+ "{{provenance}}": provenance,
511
+ "{provenance}": provenance,
512
+ "${RIGHT_RELEASE_NATIVE_PROVENANCE}": provenance,
513
+ "$RIGHT_RELEASE_NATIVE_PROVENANCE": provenance,
514
+ "{{receipt}}": outputPath,
515
+ "{receipt}": outputPath,
516
+ "${RIGHT_RELEASE_NATIVE_FINALIZATION_RECEIPT}": outputPath,
517
+ "$RIGHT_RELEASE_NATIVE_FINALIZATION_RECEIPT": outputPath,
518
+ "{{packageIdentity}}": JSON.stringify(packageIdentity),
519
+ "{packageIdentity}": JSON.stringify(packageIdentity),
520
+ };
521
+ return Object.entries(replacements).reduce((result, [token, replacement]) => result.replaceAll(token, replacement), String(value));
522
+ }
523
+
524
+ function hasNativeTargetSpecificPrePackage(target, packageHook) {
525
+ if (!target?.prePackage || !packageHook || target.prePackage.cmd !== packageHook.cmd) return false;
526
+ const args = target.prePackage.args ?? [];
527
+ return args.some((arg) => ["--platform", "--architecture", "--target", "--out"].includes(arg));
528
+ }
529
+
314
530
  // Post-release target/ hygiene: `cargo sweep --installed` deletes artifacts left
315
531
  // by toolchains rustup no longer has (each update orphans a multi-GB pile per
316
532
  // app) and never touches anything the current toolchains produced. Runs after
317
533
  // publish so it cannot race an artifact, and is best-effort: a missing
318
534
  // cargo-sweep or a sweep failure must never fail a release.
319
535
  function sweepStaleRustArtifacts() {
320
- const candidates = [...new Set([root, workdir].flatMap((base) => [base, path.join(base, "src-tauri")]))];
536
+ const candidates = [...new Set([root, workdir, nativeLayout.manifestDir, nativeLayout.workspaceRoot])];
321
537
  const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
322
538
  for (const dir of projects) {
323
539
  if (opts.dryRun) {
@@ -348,10 +564,11 @@ async function runPackageScript(pm, script, cwd) {
348
564
  await run(pm, ["run", script], cwd);
349
565
  }
350
566
 
351
- async function runCommand(command, root) {
567
+ async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
352
568
  const cwd = path.resolve(root, command.cwd ?? ".");
353
- await run(command.cmd, command.args ?? [], cwd, await commandEnv(command, root), {
569
+ return run(command.cmd, command.args ?? [], cwd, { ...(await commandEnv(command, root)), ...env }, {
354
570
  ...command,
571
+ captureStdout,
355
572
  timeoutMs: commandTimeoutMs(command),
356
573
  });
357
574
  }
@@ -550,7 +767,7 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
550
767
  const timeout = options.timeoutMs ? ` timeout=${options.timeoutMs}ms` : "";
551
768
  const tier = opts.tier ? `RIGHT_RELEASE_TIER=${opts.tier} ` : "";
552
769
  console.log(`dry-run: (${cwd}) ${tier}${printable}${timeout}`);
553
- return Promise.resolve();
770
+ return Promise.resolve("");
554
771
  }
555
772
  const started = Date.now();
556
773
  return new Promise((resolve) => {
@@ -558,10 +775,12 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
558
775
  const child = spawn(cmd, runArgs, {
559
776
  cwd,
560
777
  env: { ...process.env, ...releaseEnv, ...env },
561
- stdio: "inherit",
778
+ stdio: options.captureStdout ? ["inherit", "pipe", "inherit"] : "inherit",
562
779
  shell: useShell,
563
780
  windowsHide: true,
564
781
  });
782
+ let stdout = "";
783
+ if (options.captureStdout) child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
565
784
  const timer = options.timeoutMs
566
785
  ? setTimeout(() => {
567
786
  killProcessTree(child.pid);
@@ -579,7 +798,7 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
579
798
  if (timer) clearTimeout(timer);
580
799
  console.log(`right-release step: ${printable} (${Date.now() - started}ms)`);
581
800
  if (code !== 0) process.exit(code ?? 1);
582
- resolve();
801
+ resolve(options.captureStdout ? stdout : "");
583
802
  });
584
803
  });
585
804
  }
@@ -13,7 +13,7 @@
13
13
  "@rightkit/updates": "0.2.3"
14
14
  },
15
15
  "stagedNpm": {
16
- "@rightkit/ax": "0.2.0",
16
+ "@rightkit/ax": "0.2.1",
17
17
  "@rightkit/git": "0.2.1",
18
18
  "@rightkit/hooks": "0.1.1",
19
19
  "@rightkit/legal": "0.3.1",
@@ -22,11 +22,14 @@
22
22
  "@rightkit/logs": "0.1.4",
23
23
  "@rightkit/platform-ui": "0.1.1",
24
24
  "@rightkit/qa": "0.2.1",
25
- "@rightkit/release": "0.2.70",
25
+ "@rightkit/release": "0.2.72",
26
26
  "@rightkit/tauri": "0.1.1",
27
27
  "@rightkit/updates": "0.2.4"
28
28
  },
29
29
  "legacyNpm": {
30
+ "@rightkit/ax": [
31
+ "0.2.0"
32
+ ],
30
33
  "@rightkit/legal-ui": [
31
34
  "0.1.0"
32
35
  ],
@@ -65,7 +68,9 @@
65
68
  "0.2.66",
66
69
  "0.2.67",
67
70
  "0.2.68",
68
- "0.2.69"
71
+ "0.2.69",
72
+ "0.2.70",
73
+ "0.2.71"
69
74
  ],
70
75
  "@rightkit/qa": [
71
76
  "0.1.0",
@@ -0,0 +1,223 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+
7
+ export const RIGHTRELEASE_MANIFEST_SIGNER = Object.freeze({
8
+ id: "azure-artifact-signing-damned-ventures-v1",
9
+ subject: "CN=Damned Ventures LLC",
10
+ algorithm: "cms-sha256",
11
+ });
12
+
13
+ const EXCLUDE_CREDENTIALS = [
14
+ "ManagedIdentityCredential",
15
+ "WorkloadIdentityCredential",
16
+ "SharedTokenCacheCredential",
17
+ "VisualStudioCredential",
18
+ "VisualStudioCodeCredential",
19
+ "AzurePowerShellCredential",
20
+ "AzureDeveloperCliCredential",
21
+ "InteractiveBrowserCredential",
22
+ ];
23
+
24
+ const CERTIFICATE_SHA256 = /^[a-f0-9]{64}$/i;
25
+
26
+ /**
27
+ * Verify detached CMS over exact manifest bytes & bind signer identity to the
28
+ * shared Azure Artifact Signing result. `commandRunner` is an injectable
29
+ * system-process seam; it cannot replace CMS verification because production
30
+ * verification script/arguments are always issued here.
31
+ */
32
+ export function verifyDetachedCmsSignature({
33
+ manifestPath,
34
+ signaturePath,
35
+ expectedSigner,
36
+ commandRunner = spawnSync,
37
+ platform = process.platform,
38
+ powershellPath = "powershell",
39
+ opensslPath = "openssl",
40
+ }) {
41
+ const manifest = resolve(manifestPath);
42
+ const signature = resolve(signaturePath);
43
+ if (!existsSync(manifest) || !statSync(manifest).isFile()) throw new Error("CMS manifest bytes are missing");
44
+ if (!existsSync(signature) || !statSync(signature).isFile() || statSync(signature).size < 1) throw new Error("detached CMS signature is missing");
45
+ if (!expectedSigner || expectedSigner.id !== RIGHTRELEASE_MANIFEST_SIGNER.id || expectedSigner.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || expectedSigner.algorithm !== RIGHTRELEASE_MANIFEST_SIGNER.algorithm || !CERTIFICATE_SHA256.test(expectedSigner.certificateSha256 ?? "")) {
46
+ throw new Error("CMS verification requires exact Azure signer identity");
47
+ }
48
+ const inspection = platform === "win32"
49
+ ? verifyCmsWithPowerShell({ manifestPath: manifest, signaturePath: signature, commandRunner, powershellPath })
50
+ : verifyCmsWithOpenSsl({ manifestPath: manifest, signaturePath: signature, commandRunner, opensslPath });
51
+ if (!inspection.verified) throw new Error("detached CMS signature is invalid");
52
+ if (normalizeSubject(inspection.subject) !== normalizeSubject(expectedSigner.subject)) throw new Error("CMS signer subject mismatch");
53
+ const certificateSha256 = String(inspection.certificateSha256 ?? "").replaceAll(":", "").toLowerCase();
54
+ if (!CERTIFICATE_SHA256.test(certificateSha256) || certificateSha256 !== String(expectedSigner.certificateSha256).toLowerCase()) throw new Error("CMS signer certificate fingerprint mismatch");
55
+ return { verified: true, manifestPath: manifest, signaturePath: signature, signer: { ...expectedSigner, certificateSha256 } };
56
+ }
57
+
58
+ export function signReleaseManifestWithAzure({
59
+ manifestPath,
60
+ signaturePath,
61
+ commandRunner = spawnSync,
62
+ environment = process.env,
63
+ signtoolPath,
64
+ dlibPath,
65
+ metadataPath,
66
+ }) {
67
+ if (process.platform !== "win32" && commandRunner === spawnSync) {
68
+ throw new Error("RightRelease manifest signing must run on protected Windows release host");
69
+ }
70
+ const userEnv = (name) => readUserEnvironment(name, commandRunner);
71
+ const env = (name) => environment[name] || userEnv(name);
72
+ const signtool = signtoolPath || firstExisting([
73
+ env("AZURE_SIGNTOOL_PATH"),
74
+ env("SIGNTOOL_PATH"),
75
+ ...signtoolCandidates(env),
76
+ ]);
77
+ if (!signtool) throw new Error("RightRelease manifest signer could not find signtool.exe");
78
+ const dlib = dlibPath || firstExisting([
79
+ env("AZURE_CODESIGN_DLIB_PATH"),
80
+ env("AZURE_ARTIFACT_SIGNING_DLIB_PATH"),
81
+ join(env("LOCALAPPDATA") || "", "AzureArtifactSigningTools", "Microsoft.ArtifactSigning.Client", "bin", "x64", "Azure.CodeSigning.Dlib.dll"),
82
+ "C:\\Program Files\\Microsoft Azure Artifact Signing Client Tools\\x64\\Azure.CodeSigning.Dlib.dll",
83
+ "C:\\Program Files (x86)\\Microsoft\\ArtifactSigningClientTools\\bin\\x64\\Azure.CodeSigning.Dlib.dll",
84
+ ]);
85
+ if (!dlib) throw new Error("RightRelease manifest signer could not find Azure Artifact Signing dlib");
86
+
87
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-manifest-sign-"));
88
+ const metadata = metadataPath || materializeMetadata(temp, env);
89
+ const outputDir = join(temp, "signature");
90
+ mkdirSync(outputDir, { recursive: true });
91
+ try {
92
+ const result = commandRunner(signtool, [
93
+ "sign", "/v", "/fd", "SHA256", "/tr", "http://timestamp.acs.microsoft.com", "/td", "SHA256",
94
+ "/dlib", dlib, "/dmdf", metadata,
95
+ "/p7", outputDir, "/p7ce", "DetachedSignedData", "/p7co", "1.2.840.113549.1.7.1",
96
+ resolve(manifestPath),
97
+ ], { encoding: "utf8", windowsHide: true, env: environment });
98
+ if (result?.status !== 0) throw new Error(`RightRelease manifest signing failed: ${String(result?.stderr ?? "").trim()}`);
99
+ const generated = join(outputDir, `${basename(manifestPath)}.p7`);
100
+ if (!existsSync(generated)) throw new Error("RightRelease manifest signer did not produce detached CMS signature");
101
+ const inspector = join(temp, "inspect-signer.ps1");
102
+ writeFileSync(inspector, [
103
+ "param([string]$ManifestPath, [string]$SignaturePath)",
104
+ "Add-Type -AssemblyName System.Security",
105
+ "$Content = New-Object Security.Cryptography.Pkcs.ContentInfo(,[IO.File]::ReadAllBytes($ManifestPath))",
106
+ "$Cms = New-Object Security.Cryptography.Pkcs.SignedCms($Content, $true)",
107
+ "$Cms.Decode([IO.File]::ReadAllBytes($SignaturePath))",
108
+ "$Cms.CheckSignature($true)",
109
+ "if ($Cms.SignerInfos.Count -ne 1) { throw 'Expected exactly one manifest signer' }",
110
+ "$Certificate = $Cms.SignerInfos[0].Certificate",
111
+ "$Sha256 = [Security.Cryptography.SHA256]::Create()",
112
+ "try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
113
+ "@{ subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
114
+ ].join("\r\n") + "\r\n");
115
+ const inspection = commandRunner("powershell", ["-NoProfile", "-File", inspector, resolve(manifestPath), generated], { encoding: "utf8", windowsHide: true, env: environment });
116
+ if (inspection?.status !== 0) throw new Error(`RightRelease could not inspect manifest signer: ${String(inspection?.stderr ?? "").trim()}`);
117
+ let certificate;
118
+ try { certificate = JSON.parse(String(inspection.stdout ?? "").trim()); } catch { throw new Error("RightRelease manifest signer inspection returned invalid JSON"); }
119
+ if (certificate.subject !== RIGHTRELEASE_MANIFEST_SIGNER.subject || !/^[a-f0-9]{64}$/.test(certificate.certificateSha256 ?? "")) {
120
+ throw new Error("RightRelease manifest signer identity does not match protected Azure profile");
121
+ }
122
+ mkdirSync(dirname(signaturePath), { recursive: true });
123
+ writeFileSync(signaturePath, readFileSync(generated));
124
+ return { signaturePath: resolve(signaturePath), signer: { ...RIGHTRELEASE_MANIFEST_SIGNER, certificateSha256: certificate.certificateSha256 } };
125
+ } finally {
126
+ rmSync(temp, { recursive: true, force: true });
127
+ }
128
+ }
129
+
130
+ function verifyCmsWithPowerShell({ manifestPath, signaturePath, commandRunner, powershellPath }) {
131
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-cms-verify-"));
132
+ const verifier = join(temp, "verify-cms.ps1");
133
+ writeFileSync(verifier, [
134
+ "param([string]$ManifestPath, [string]$SignaturePath)",
135
+ "Add-Type -AssemblyName System.Security",
136
+ "$Content = New-Object Security.Cryptography.Pkcs.ContentInfo(,[IO.File]::ReadAllBytes($ManifestPath))",
137
+ "$Cms = New-Object Security.Cryptography.Pkcs.SignedCms($Content, $true)",
138
+ "$Cms.Decode([IO.File]::ReadAllBytes($SignaturePath))",
139
+ "$Cms.CheckSignature($true)",
140
+ "if ($Cms.SignerInfos.Count -ne 1) { throw 'Expected exactly one manifest signer' }",
141
+ "$Certificate = $Cms.SignerInfos[0].Certificate",
142
+ "$Sha256 = [Security.Cryptography.SHA256]::Create()",
143
+ "try { $Fingerprint = ([BitConverter]::ToString($Sha256.ComputeHash($Certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $Sha256.Dispose() }",
144
+ "@{ verified=$true; subject=$Certificate.Subject; certificateSha256=$Fingerprint } | ConvertTo-Json -Compress",
145
+ ].join("\r\n") + "\r\n");
146
+ try {
147
+ const result = commandRunner(powershellPath, ["-NoProfile", "-File", verifier, manifestPath, signaturePath], { encoding: "utf8", windowsHide: true });
148
+ if (result?.status !== 0) throw new Error(`CMS signature verification failed: ${String(result?.stderr ?? "").trim()}`);
149
+ return parseCmsInspection(result?.stdout);
150
+ } finally {
151
+ rmSync(temp, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ function verifyCmsWithOpenSsl({ manifestPath, signaturePath, commandRunner, opensslPath }) {
156
+ const temp = mkdtempSync(join(tmpdir(), "rightrelease-cms-verify-"));
157
+ const verified = join(temp, "verified-manifest");
158
+ const certificate = join(temp, "signer.pem");
159
+ try {
160
+ const result = commandRunner(opensslPath, ["cms", "-verify", "-binary", "-inform", "DER", "-in", signaturePath, "-content", manifestPath, "-noverify", "-certsout", certificate, "-out", verified], { encoding: "utf8", windowsHide: true });
161
+ if (result?.status !== 0) throw new Error(`CMS signature verification failed: ${String(result?.stderr ?? "").trim()}`);
162
+ if (!existsSync(verified) || !readFileSync(verified).equals(readFileSync(manifestPath))) throw new Error("CMS signature content does not match manifest bytes");
163
+ if (!existsSync(certificate)) throw new Error("CMS signer certificate is missing");
164
+ const inspection = commandRunner(opensslPath, ["x509", "-in", certificate, "-noout", "-subject", "-fingerprint", "-sha256"], { encoding: "utf8", windowsHide: true });
165
+ if (inspection?.status !== 0) throw new Error(`CMS signer inspection failed: ${String(inspection?.stderr ?? "").trim()}`);
166
+ const output = String(inspection?.stdout ?? "");
167
+ const subject = output.match(/^\s*subject\s*=\s*(.+)$/im)?.[1]?.trim();
168
+ const certificateSha256 = output.match(/^\s*sha256\s+Fingerprint\s*=\s*([0-9a-f:]+)\s*$/im)?.[1];
169
+ return parseCmsInspection(JSON.stringify({ verified: true, subject, certificateSha256 }));
170
+ } finally {
171
+ rmSync(temp, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ function parseCmsInspection(output) {
176
+ let inspection;
177
+ try { inspection = JSON.parse(String(output ?? "").trim()); } catch { throw new Error("CMS verifier returned invalid JSON"); }
178
+ if (inspection.verified !== true || typeof inspection.subject !== "string" || typeof inspection.certificateSha256 !== "string") throw new Error("CMS verifier returned incomplete identity");
179
+ return inspection;
180
+ }
181
+
182
+ function normalizeSubject(subject) {
183
+ return String(subject).trim().replace(/\s*=\s*/g, "=").replace(/\s*,\s*/g, ",");
184
+ }
185
+
186
+ function materializeMetadata(dir, env) {
187
+ const existing = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
188
+ if (existing) {
189
+ if (!existsSync(existing)) throw new Error(`Azure signing metadata file not found: ${existing}`);
190
+ return existing;
191
+ }
192
+ const Endpoint = env("AZURE_ARTIFACT_SIGNING_ENDPOINT") || env("AZURE_SIGNING_ENDPOINT") || env("AZURE_ENDPOINT");
193
+ const CodeSigningAccountName = env("AZURE_ARTIFACT_SIGNING_ACCOUNT") || env("AZURE_SIGNING_ACCOUNT_NAME") || env("AZURE_ACCOUNT");
194
+ const CertificateProfileName = env("AZURE_ARTIFACT_SIGNING_PROFILE") || env("AZURE_CERTIFICATE_PROFILE_NAME") || env("AZURE_PROFILE");
195
+ if (!Endpoint || !CodeSigningAccountName || !CertificateProfileName) {
196
+ throw new Error("RightRelease manifest signer requires existing Azure Artifact Signing configuration");
197
+ }
198
+ const path = join(dir, "metadata.json");
199
+ writeFileSync(path, `${JSON.stringify({ Endpoint, CodeSigningAccountName, CertificateProfileName, ExcludeCredentials: EXCLUDE_CREDENTIALS }, null, 2)}\n`);
200
+ return path;
201
+ }
202
+
203
+ function signtoolCandidates(env) {
204
+ const roots = [
205
+ join(env("LOCALAPPDATA") || "", "AzureArtifactSigningTools", "Microsoft.Windows.SDK.BuildTools", "bin"),
206
+ join(env("ProgramFiles(x86)") || "C:\\Program Files (x86)", "Windows Kits", "10", "bin"),
207
+ ];
208
+ return roots.flatMap((root) => existsSync(root)
209
+ ? readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name, "x64", "signtool.exe")).sort().reverse()
210
+ : []);
211
+ }
212
+
213
+ function firstExisting(candidates) {
214
+ return candidates.find((candidate) => candidate && existsSync(candidate));
215
+ }
216
+
217
+ function readUserEnvironment(name, commandRunner) {
218
+ const result = commandRunner("powershell", ["-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('${name.replaceAll("'", "''")}','User')`], {
219
+ encoding: "utf8",
220
+ windowsHide: true,
221
+ });
222
+ return result?.status === 0 ? String(result.stdout ?? "").trim() : "";
223
+ }