@rightkit/release 0.3.0 → 0.4.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.
Files changed (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
package/sign-updater.mjs CHANGED
@@ -1,68 +1,65 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "./background-process.mjs";
3
- import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { existsSync, rmSync } from "node:fs";
4
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
5
  import { signMacUpdaterFiles } from "./macos-signing-session.mjs";
6
- import { refuseManagedEnvSecrets, MANAGED_HOST_ENV_SECRET_REFUSED } from "./managed-host-secrets.mjs";
6
+ import { refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
7
+ import { execWithUpdaterCredential } from "./protected-credentials.mjs";
8
+ import { resolvePnpmCli } from "./development.mjs";
7
9
 
8
- const args = process.argv.slice(2);
9
- const files = [];
10
- let dryRun = false;
11
- let keyFile = process.env.RIGHT_UPDATER_KEY_FILE || "";
12
- for (let i = 0; i < args.length; i++) {
13
- if (args[i] === "--file") files.push(path.resolve(args[++i]));
14
- else if (args[i] === "--key-file") keyFile = args[++i] ?? "";
15
- else if (args[i] === "--dry-run") dryRun = true;
16
- else fail(`unknown argument: ${args[i]}`);
17
- }
18
- if (!files.length) fail("at least one --file is required");
10
+ export const UPDATER_SIGNING_SECRET = "updater-signing-key";
19
11
 
20
- const env = { ...process.env };
21
- if (!dryRun) {
22
- try { refuseManagedEnvSecrets(process.env, ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"]); }
23
- catch (error) { fail(`[${MANAGED_HOST_ENV_SECRET_REFUSED}] ${error.message}`); }
24
- }
25
- if (!dryRun && !env.TAURI_SIGNING_PRIVATE_KEY) {
26
- if (process.platform === "win32") {
27
- if (!keyFile) fail("updater key path required on win32: pass --key-file or set RIGHT_UPDATER_KEY_FILE (release config: targets.win.updater.keyFile), or set TAURI_SIGNING_PRIVATE_KEY");
28
- const key = path.resolve(keyFile.replace(/%([^%]+)%/g, (m, name) => process.env[name] ?? m));
29
- if (!existsSync(key)) fail(`updater key not found: ${key}`);
30
- env.TAURI_SIGNING_PRIVATE_KEY = readFileSync(key, "utf8");
31
- } else if (process.platform !== "darwin") {
32
- fail("TAURI_SIGNING_PRIVATE_KEY is required on this platform");
12
+ export function runSignUpdater(args, {
13
+ env = process.env, platform = process.platform, cwd = process.cwd(),
14
+ execCredential = execWithUpdaterCredential, resolvePnpm = resolvePnpmCli,
15
+ signMac = signMacUpdaterFiles, exists = existsSync, remove = rmSync, log = console.log,
16
+ } = {}) {
17
+ const files = [];
18
+ let dryRun = false;
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i] === "--file") {
21
+ if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error("--file requires an artifact path");
22
+ files.push(path.resolve(cwd, args[++i]));
23
+ } else if (args[i] === "--key-file") throw new Error("--key-file is removed: updater keys come from the OS credential provider");
24
+ else if (args[i] === "--dry-run") dryRun = true;
25
+ else throw new Error(`unknown argument: ${args[i]}`);
33
26
  }
34
- }
35
- env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ??= "";
36
-
37
- for (const file of files) {
38
- if (!dryRun && !existsSync(file)) fail(`updater artifact not found: ${file}`);
39
- rmSync(`${file}.sig`, { force: true });
40
- console.log(`${dryRun ? "[dry-run] " : ""}pnpm exec tauri signer sign ${file}`);
41
- if (dryRun) continue;
42
- if (process.platform === "darwin") continue;
43
- const result = process.platform === "win32"
44
- ? spawnSync(
45
- process.env.ComSpec || "cmd.exe",
46
- ["/d", "/s", "/c", `pnpm exec tauri signer sign ${file}`],
47
- {
48
- cwd: process.cwd(),
49
- env,
50
- stdio: "inherit",
51
- },
52
- )
53
- : spawnSync("pnpm", ["exec", "tauri", "signer", "sign", file], {
54
- cwd: process.cwd(),
55
- env,
56
- stdio: "inherit",
57
- });
58
- if (result.status !== 0) process.exit(result.status ?? 1);
59
- }
60
- if (!dryRun && process.platform === "darwin") {
61
- try { signMacUpdaterFiles(files, { env }); }
62
- catch (error) { fail(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
27
+ if (!files.length) throw new Error("at least one --file is required");
28
+ if (!dryRun) {
29
+ refuseManagedEnvSecrets(env, ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"]);
30
+ for (const key of Object.keys(env)) {
31
+ if (["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"].includes(key.toUpperCase()) && env[key]) {
32
+ throw new Error("updater signing key/password must come from the OS credential provider");
33
+ }
34
+ }
35
+ for (const file of files) if (!exists(file)) throw new Error(`updater artifact not found: ${file}`);
36
+ }
37
+ const signingEnv = { ...env };
38
+ for (const key of Object.keys(signingEnv)) {
39
+ if (["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"].includes(key.toUpperCase())) delete signingEnv[key];
40
+ }
41
+ for (const file of files) {
42
+ log(`${dryRun ? "[dry-run] " : ""}pnpm exec tauri signer sign ${file}`);
43
+ if (dryRun) continue;
44
+ remove(`${file}.sig`, { force: true });
45
+ if (platform === "darwin") continue;
46
+ const result = execCredential(process.execPath,
47
+ [resolvePnpm({ root: cwd, env: signingEnv }), "exec", "tauri", "signer", "sign", file],
48
+ { cwd, env: signingEnv });
49
+ if (result.error || result.status !== 0) {
50
+ const error = new Error(`updater credential provider/signing failed (exit ${result.status ?? "unavailable"})`);
51
+ error.exitCode = result.status ?? 1;
52
+ throw error;
53
+ }
54
+ }
55
+ if (!dryRun && platform === "darwin") signMac(files, { env: signingEnv, cwd });
56
+ return { files, dryRun };
63
57
  }
64
58
 
65
- function fail(message) {
66
- console.error(`sign-updater: ${message}`);
67
- process.exit(1);
59
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
60
+ try { runSignUpdater(process.argv.slice(2)); }
61
+ catch (error) {
62
+ console.error(`sign-updater: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
63
+ process.exitCode = error.exitCode ?? 1;
64
+ }
68
65
  }
package/source-gate.mjs CHANGED
@@ -30,11 +30,18 @@ export function stripOwnLockRecords(status) {
30
30
  return kept.length ? `${kept.join("\0")}\0` : "";
31
31
  }
32
32
 
33
+ /** Path of one porcelain v1 record ("XY path"). Tolerates a record whose leading status column was
34
+ * trimmed away by a caller ("M path"), so the first path never loses its first character (#782). */
35
+ function porcelainPath(entry) {
36
+ const m = /^([ MADRCU?!]{2} |[MADRCU?!] )/.exec(entry);
37
+ return m ? entry.slice(m[0].length) : entry;
38
+ }
39
+
33
40
  export function assertCleanSource({ status, commandId = "release" }) {
34
41
  if (typeof status !== "string") throw new Error("release source status is required");
35
42
  status = stripOwnLockRecords(status);
36
43
  if (status.length !== 0) {
37
- const files = status.split("\0").filter(Boolean).map((entry) => entry.slice(3)).slice(0, 10);
44
+ const files = status.split("\0").filter(Boolean).map(porcelainPath).slice(0, 10);
38
45
  throw new Error(`${commandId} requires a clean working tree; commit or stash first:\n${files.map((file) => `- ${file}`).join("\n")}`);
39
46
  }
40
47
  }
package/stage-sidecar.mjs CHANGED
@@ -11,8 +11,8 @@
11
11
  // { kind: "resource", from, to, sha256?, machO?, clean?, platforms? }, // `from` may be a directory (copied recursively, every file hashed)
12
12
  // { kind: "command", name, cmd, args?, cwd?, output, externalBin: true | (false with dest), dest?, clean?, platforms? },
13
13
  // { kind: "remote", name, url (https), sha256, dest | to, size?, extract?, memberSha256?, executable?, clean?, platforms? },
14
- // { kind: "cmake", name, repo (https), commit (40 hex), target?, output?, cmakeArgs?, platformArgs?, generator?, smoke?, sha256?, dest?, clean?, platforms? },
15
- // { kind: "prebuilt", name, from?, manifest: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }, dest?, platforms? },
14
+ // { kind: "cmake", name, repo (https), commit (40 hex), target?, output?, cmakeArgs?, platformArgs?, generator?, platformGenerators?, architecture?, cmakeBin?, smoke?, sha256?, dest?, clean?, platforms? },
15
+ // { kind: "prebuilt", name, from? (a path, or { aarch64, x86_64 } for universal-apple-darwin), manifest: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }, dest?, platforms? },
16
16
  // ]
17
17
  //
18
18
  // `dest` (cargo-bin, command, remote) is a contained app-relative path (never the app root, the stage dir, the receipt, the cache, .git, src, src-tauri, node_modules or package.json; outputs may not overlap) used instead of <stageDir>/<name>-<triple>; the file is
@@ -24,7 +24,9 @@
24
24
  // profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
25
25
  // both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`. `ffmpeg` and `onnxruntime` do the same from their
26
26
  // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged), a `cmake` entry builds with
27
- // CMAKE_OSX_ARCHITECTURES="arm64;x86_64".
27
+ // CMAKE_OSX_ARCHITECTURES="arm64;x86_64". A `prebuilt` entry does it from per-arch host-built inputs (`from: { aarch64, x86_64 }`, or the
28
+ // staged `<name>-<slice triple>` files), each verified against its own manifest entry and then lipo-merged. An `ffmpeg` source may be a
29
+ // local path (`sources.ffmpeg: { path: "../vendor/ffmpeg-lgpl" }`) instead of a pinned https archive.
28
30
  //
29
31
  // It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
30
32
  import { createHash } from "node:crypto";
@@ -38,6 +40,7 @@ import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest }
38
40
  import {
39
41
  planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
40
42
  } from "./media-sidecar.mjs";
43
+ import { ContentDigestError, contentIdentity, digestFor } from "./content-digest.mjs";
41
44
 
42
45
  export const STAGE_SIDECAR_CONTRACT = "rightkit-stage-sidecar-v1";
43
46
  export const STAGE_SIDECAR_RECEIPT_NAME = "stage-sidecar.json";
@@ -70,6 +73,28 @@ const MODES = new Set(["dev", "release"]);
70
73
  const PROFILE = /^[a-z][a-z0-9_-]*$/;
71
74
  const PLATFORMS = new Set(["mac", "win", "linux"]);
72
75
  const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
76
+ /** One path segment of an archive member path: no glob metacharacters, no separators, no drive colon. */
77
+ const MEMBER_SEGMENT = /^[A-Za-z0-9_.+@~-]+$/;
78
+
79
+ /**
80
+ * Normalize and guard a requested archive member. A bare file name (no `/`) selects the one member with that base name; a path
81
+ * (contains `/`) selects the one member with exactly that archive path. A leading `./` is stripped. Absolute paths, drive
82
+ * prefixes, backslashes, empty, `.` and `..` segments and glob metacharacters are refused. Returns { value, byPath } or
83
+ * { error }. The member never influences where the extracted file is written.
84
+ */
85
+ export function normalizeArchiveMember(member) {
86
+ if (typeof member !== "string" || !member) return { error: "must be a non-empty string" };
87
+ if (member.includes("\\")) return { error: "must not contain a backslash" };
88
+ const value = member.startsWith("./") ? member.slice(2) : member;
89
+ if (value.startsWith("/")) return { error: "must not be an absolute path" };
90
+ if (/^[A-Za-z]:/.test(value)) return { error: "must not start with a drive prefix" };
91
+ const segments = value.split("/");
92
+ if (segments.some((segment) => segment === "")) return { error: "must not contain an empty segment" };
93
+ if (segments.some((segment) => segment === "." || segment === "..")) return { error: "must not contain a '.' or '..' segment" };
94
+ // A bare file name keeps today's matching (any characters but the refused ones above); a path is held to a strict segment alphabet so it can never act as a tar glob.
95
+ if (segments.length > 1 && !segments.every((segment) => MEMBER_SEGMENT.test(segment))) return { error: "has a segment with characters outside A-Z a-z 0-9 _ . + @ ~ -" };
96
+ return { value, byPath: segments.length > 1 };
97
+ }
73
98
  const KEYS = Object.freeze({
74
99
  "cargo-bin": ["kind", "package", "bin", "name", "profile", "features", "manifestPath", "sha256", "dest", "clean", "platforms"],
75
100
  ffmpeg: ["kind", "variant", "variants", "tools", "notices", "catalog", "stage", "bundleDir", "sources", "probe", "platforms"],
@@ -77,7 +102,7 @@ const KEYS = Object.freeze({
77
102
  resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
78
103
  command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
79
104
  remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
80
- cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "smoke", "sha256", "dest", "clean", "platforms"],
105
+ cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "platformGenerators", "architecture", "cmakeBin", "smoke", "sha256", "dest", "clean", "platforms"],
81
106
  prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
82
107
  });
83
108
  const TRIPLES = Object.freeze({
@@ -190,6 +215,8 @@ function parseCatalogSpec(value, label, errors, kind) {
190
215
  return undefined;
191
216
  }
192
217
 
218
+ const CMAKE_GENERATOR = /^[A-Za-z0-9][A-Za-z0-9 .+-]{0,60}$/;
219
+ const CMAKE_ARCHITECTURE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
193
220
  const CMAKE_DEFINE = /^-D[A-Za-z0-9_]+(?::[A-Z]+)?=[^\s]*$/;
194
221
  const SOURCE_ARCHES = ["aarch64", "x86_64"];
195
222
 
@@ -205,8 +232,25 @@ function parseArchiveSource(value, label, errors) {
205
232
  }
206
233
 
207
234
  /**
208
- * `ffmpeg.sources`: where to download the bytes of a catalog row (the catalog pins digests, not URLs). A tool maps to one
209
- * archive source, or to `{ aarch64: source, x86_64: source }`; a universal target needs the per-arch form for every tool.
235
+ * A local ffmpeg source: `{ path, member? }`. `path` is relative to the app root and MAY step out of it (a vendored build such as
236
+ * ../vendor/ffmpeg-lgpl); it names the binary itself, or a directory holding `<member>` (or `bin/<member>`). Nothing is downloaded and no
237
+ * archive digest exists: the file's digest must still equal the APPROVED catalog row, exactly like a fetched member.
238
+ */
239
+ function parseLocalSource(value, label, errors) {
240
+ for (const key of Object.keys(value)) if (!["path", "member"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} for a local { path, member? } source`);
241
+ const raw = value.path;
242
+ const normalized = typeof raw === "string" ? raw.replaceAll("\\", "/") : "";
243
+ if (typeof raw !== "string" || !raw.trim() || path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized)) { errors.push(`${label}.path must be a relative path (it may start with ../); a URL belongs in { url, sha256, member }`); return undefined; }
244
+ if (!normalized.split("/").some((segment) => segment !== "" && segment !== "." && segment !== "..")) { errors.push(`${label}.path must name a file or directory, not the app root or a bare parent: ${raw}`); return undefined; }
245
+ if (value.member !== undefined && (typeof value.member !== "string" || !NAME.test(value.member))) errors.push(`${label}.member must be the bare file name of the binary inside the directory`);
246
+ return { path: normalized.replace(/\/+$/, ""), ...(typeof value.member === "string" ? { member: value.member } : {}) };
247
+ }
248
+
249
+ const parseFfmpegSource = (value, label, errors) => (isObject(value) && "path" in value ? parseLocalSource(value, label, errors) : parseArchiveSource(value, label, errors));
250
+
251
+ /**
252
+ * `ffmpeg.sources`: where the bytes of a catalog row come from (the catalog pins digests, not locations). A tool maps to one
253
+ * source (a pinned https archive, or a local path), or to `{ aarch64: source, x86_64: source }`; a universal target needs the per-arch form for every tool.
210
254
  */
211
255
  function parseFfmpegSources(raw, where, errors, { universal }) {
212
256
  if (raw === undefined) return undefined;
@@ -215,12 +259,12 @@ function parseFfmpegSources(raw, where, errors, { universal }) {
215
259
  for (const [tool, value] of Object.entries(raw)) {
216
260
  const label = `${where}: sources.${tool}`;
217
261
  if (!["ffmpeg", "ffprobe"].includes(tool)) { errors.push(`${label}: not ffmpeg or ffprobe`); continue; }
218
- const perArch = isObject(value) && SOURCE_ARCHES.some((arch) => arch in value) && !("url" in value);
262
+ const perArch = isObject(value) && SOURCE_ARCHES.some((arch) => arch in value) && !("url" in value) && !("path" in value);
219
263
  if (universal && !perArch) { errors.push(`${label} needs { aarch64, x86_64 } sources for universal-apple-darwin (each slice is a thin file)`); continue; }
220
264
  if (perArch) {
221
265
  for (const key of Object.keys(value)) if (!SOURCE_ARCHES.includes(key)) errors.push(`${label}: unknown arch ${JSON.stringify(key)}`);
222
- out[tool] = Object.fromEntries(SOURCE_ARCHES.filter((arch) => arch in value).map((arch) => [arch, parseArchiveSource(value[arch], `${label}.${arch}`, errors)]));
223
- } else out[tool] = parseArchiveSource(value, label, errors);
266
+ out[tool] = Object.fromEntries(SOURCE_ARCHES.filter((arch) => arch in value).map((arch) => [arch, parseFfmpegSource(value[arch], `${label}.${arch}`, errors)]));
267
+ } else out[tool] = parseFfmpegSource(value, label, errors);
224
268
  }
225
269
  return out;
226
270
  }
@@ -368,7 +412,7 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
368
412
  if (item.dest === undefined && item.to === undefined) errors.push(`${where}: dest (or to) is required`);
369
413
  const dest = destOf(item.dest ?? item.to, item.dest === undefined ? "to" : "dest");
370
414
  if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
371
- if (item.extract !== undefined && (typeof item.extract !== "string" || !NAME.test(item.extract))) errors.push(`${where}: extract must be the bare file name of one archive member (no path)`);
415
+ if (item.extract !== undefined) { const member = normalizeArchiveMember(item.extract); if (member.error || (!member.byPath && !NAME.test(member.value))) errors.push(`${where}: extract must be the bare file name of one archive member or its archive path (posix, no leading /, no .. segment): ${member.error ?? "invalid file name"}`); }
372
416
  if (item.memberSha256 !== undefined && (item.extract === undefined || !SHA256.test(item.memberSha256 ?? ""))) errors.push(`${where}: memberSha256 must be a lowercase 64-hex SHA-256 and needs extract`);
373
417
  if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
374
418
  const clean = cleanOf();
@@ -397,19 +441,40 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
397
441
  else platformArgs[key] = flagList(list, `platformArgs.${key}`);
398
442
  }
399
443
  }
400
- if (item.generator !== undefined && (typeof item.generator !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 .+-]{0,60}$/.test(item.generator))) errors.push(`${where}: generator must be a generator name such as "Ninja"`);
444
+ if (item.generator !== undefined && (typeof item.generator !== "string" || !CMAKE_GENERATOR.test(item.generator))) errors.push(`${where}: generator must be a generator name such as "Ninja"`);
445
+ const platformGenerators = {};
446
+ if (item.platformGenerators !== undefined) {
447
+ if (!isObject(item.platformGenerators)) errors.push(`${where}: platformGenerators must be an object keyed by mac, win or linux`);
448
+ else for (const [key, generator] of Object.entries(item.platformGenerators)) {
449
+ if (!PLATFORMS.has(key)) errors.push(`${where}: platformGenerators.${key}: platforms are ${[...PLATFORMS].join(", ")}`);
450
+ else if (typeof generator !== "string" || !CMAKE_GENERATOR.test(generator)) errors.push(`${where}: platformGenerators.${key} must be a generator name such as "Visual Studio 17 2022"`);
451
+ else platformGenerators[key] = generator;
452
+ }
453
+ }
454
+ if (item.architecture !== undefined && (typeof item.architecture !== "string" || !CMAKE_ARCHITECTURE.test(item.architecture))) errors.push(`${where}: architecture must be a cmake -A platform such as x64, Win32 or ARM64`);
455
+ if (item.cmakeBin !== undefined && (typeof item.cmakeBin !== "string" || !item.cmakeBin.trim() || /[\0\r\n]/.test(item.cmakeBin))) errors.push(`${where}: cmakeBin must be a cmake executable name or path`);
401
456
  if (item.smoke !== undefined && (!Array.isArray(item.smoke) || item.smoke.some((arg) => typeof arg !== "string"))) errors.push(`${where}: smoke must be a list of argument strings`);
402
457
  sha(item.sha256, "sha256");
403
458
  const dest = destOf(item.dest);
404
459
  const clean = cleanOf();
405
460
  if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
406
- Object.assign(entry, { name, repo: item.repo, commit: item.commit, target, ...(output ? { output } : {}), cmakeArgs, platformArgs, ...(item.generator ? { generator: item.generator } : {}), ...(item.smoke ? { smoke: [...item.smoke] } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
461
+ Object.assign(entry, { name, repo: item.repo, commit: item.commit, target, ...(output ? { output } : {}), cmakeArgs, platformArgs, ...(item.generator ? { generator: item.generator } : {}), ...(Object.keys(platformGenerators).length ? { platformGenerators } : {}), ...(item.architecture ? { architecture: item.architecture } : {}), ...(item.cmakeBin ? { cmakeBin: item.cmakeBin.trim() } : {}), ...(item.smoke ? { smoke: [...item.smoke] } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
407
462
  } else if (item.kind === "prebuilt") {
408
463
  const name = item.name;
409
464
  if (typeof name !== "string" || !NAME.test(name) || /\.exe$/i.test(name) || name !== path.basename(name)) errors.push(`${where}: name must be a bare sidecar name (no path, no .exe, no target triple)`);
410
- const from = item.from === undefined ? undefined : relativePath(item.from, `${where}: from`, errors);
465
+ let from;
466
+ if (isObject(item.from)) {
467
+ // Per-arch inputs: each slice is verified against its own manifest entry; universal-apple-darwin lipo-merges them.
468
+ from = {};
469
+ for (const [arch, value] of Object.entries(item.from)) {
470
+ if (!SOURCE_ARCHES.includes(arch)) errors.push(`${where}: from.${arch}: arches are ${SOURCE_ARCHES.join(", ")}`);
471
+ else { const file = relativePath(value, `${where}: from.${arch}`, errors); if (file) from[arch] = file; }
472
+ }
473
+ if (!Object.keys(item.from).length) errors.push(`${where}: from must name at least one of ${SOURCE_ARCHES.join(", ")}`);
474
+ if (!skipped && t.arch === "universal" && !SOURCE_ARCHES.every((arch) => arch in item.from)) errors.push(`${where}: from needs both aarch64 and x86_64 for universal-apple-darwin (or omit from to use the staged <name>-<slice triple> files)`);
475
+ } else from = item.from === undefined ? undefined : relativePath(item.from, `${where}: from`, errors);
411
476
  const manifest = parsePrebuiltManifest(item.manifest, where, errors);
412
- if (!skipped && t.arch === "universal") errors.push(`${where}: a prebuilt binary is verified per architecture; there is no universal manifest entry`);
477
+ if (!skipped && t.arch === "universal" && typeof item.from === "string") errors.push(`${where}: from must be { aarch64, x86_64 } for universal-apple-darwin (each slice is verified against its own manifest entry, then lipo-merged)`);
413
478
  const dest = destOf(item.dest);
414
479
  if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
415
480
  Object.assign(entry, { name, ...(from ? { from } : {}), ...(manifest ? { manifest } : {}), ...(dest ? { dest } : {}) });
@@ -434,6 +499,17 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
434
499
  }
435
500
 
436
501
  const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
502
+ /**
503
+ * Signature-independent catalog digest of `bytes` for `arch` (content-digest.mjs, port of
504
+ * rightkit-ffmpeg content_digest.rs). Catalog `contentSha256` rows pin THIS digest, never a
505
+ * whole-file SHA-256: signed & unsigned copies of one build agree. A malformed recognized
506
+ * format throws ContentDigestError (never a plain-hash fallback). Whole-file hashes elsewhere
507
+ * (archive/download pins, verifyStagedSidecars preSignSha256 signing evidence) stay separate.
508
+ */
509
+ function catalogContentDigest(bytes, arch) {
510
+ const identity = contentIdentity(bytes);
511
+ return digestFor(identity, arch) ?? (identity.slices.length === 1 ? identity.slices[0].sha256 : null);
512
+ }
437
513
  const profileDirectory = (profile) => (profile === "dev" || profile === "test" ? "debug" : profile === "bench" ? "release" : profile);
438
514
  const tail = (text, lines = 12) => String(text ?? "").trim().split("\n").slice(-lines).join("\n");
439
515
 
@@ -445,15 +521,28 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
445
521
  });
446
522
  }
447
523
 
448
- /** Default archive member extraction (bsdtar reads tgz and zip/nupkg): the one member whose basename is `fileName`. */
524
+ /**
525
+ * Default archive member extraction (bsdtar reads tgz and zip/nupkg). A bare `fileName` selects the one member whose basename it is;
526
+ * a `fileName` containing `/` selects the one member whose full archive path equals it (case-sensitive, leading `./` ignored on both
527
+ * sides), so archives with the same base name in several directories (ORT DirectML nupkgs) can be addressed. The returned file is
528
+ * always under `destDir`; callers copy it to their own declared output name, so the member path never decides where anything is written.
529
+ */
449
530
  export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun }) {
531
+ const wanted = normalizeArchiveMember(fileName);
532
+ if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
450
533
  const listing = run("tar", ["-tf", archive], { cwd: path.dirname(archive) });
451
534
  if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
452
- const members = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && path.posix.basename(line) === fileName);
453
- if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members named ${fileName}, expected exactly one`);
535
+ const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
536
+ const members = wanted.byPath
537
+ ? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
538
+ : lines.filter((line) => path.posix.basename(line) === wanted.value);
539
+ if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members ${wanted.byPath ? "at the path" : "named"} ${wanted.value}, expected exactly one`);
454
540
  const extracted = run("tar", ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
455
541
  if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
456
- return path.join(destDir, ...members[0].split("/"));
542
+ const out = path.join(destDir, ...members[0].split("/"));
543
+ const inside = path.relative(path.resolve(destDir), path.resolve(out));
544
+ if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `member ${members[0]} of ${archive} resolves outside the extraction directory`);
545
+ return out;
457
546
  }
458
547
 
459
548
  async function defaultFetchBytes(url) {
@@ -673,10 +762,22 @@ function stageCargoBuild(entry, ctx, triple) {
673
762
  }
674
763
 
675
764
  /** The archive source for one tool at one arch: a single source, or the per-arch form. */
676
- const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool]; return spec?.url ? spec : spec?.[arch]; };
765
+ const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool]; return spec?.url || spec?.path ? spec : spec?.[arch]; };
766
+
767
+ /** The binary a local ffmpeg source names: the file itself, or `<dir>/<member>` / `<dir>/bin/<member>` (`.exe` added for a Windows target). */
768
+ function resolveLocalFfmpegSource(ctx, spec, tool) {
769
+ const { fs } = ctx;
770
+ const base = path.resolve(ctx.appRoot, spec.path);
771
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
772
+ const member = spec.member ?? `${tool}${ctx.windows ? ".exe" : ""}`;
773
+ const candidates = [path.join(base, member), path.join(base, "bin", member)];
774
+ const found = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
775
+ if (!found) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the local ${tool} source ${spec.path} has no ${member} (looked in ${candidates.join(", ")}); vendor the build first, nothing is downloaded for a path source`, { tool, candidates });
776
+ return found;
777
+ }
677
778
 
678
779
  /**
679
- * Download the bytes of the catalog rows that `ffmpeg.sources` names: an archive pinned by digest, one member unpacked, the
780
+ * Fetch the bytes of the catalog rows that `ffmpeg.sources` names (a local `path` is copied, never downloaded): an archive pinned by digest, one member unpacked, the
680
781
  * member's digest checked against the APPROVED catalog row (so a swapped archive can never stage). A staged file that already
681
782
  * matches its row is left alone.
682
783
  */
@@ -686,12 +787,19 @@ async function fetchFfmpegSources(entry, ctx, plan, arch) {
686
787
  for (const file of plan.files) {
687
788
  const spec = ffmpegSourceFor(entry.sources, file.tool, arch);
688
789
  if (!spec) continue;
689
- if (fs.existsSync(file.stagedPath) && sha256Of(fs.readFileSync(file.stagedPath)) === file.contentSha256) { ctx.log(`stage-sidecar: ${rel(ctx, file.stagedPath)} already staged`); continue; }
790
+ if (fs.existsSync(file.stagedPath) && catalogContentDigest(fs.readFileSync(file.stagedPath), arch) === file.contentSha256) { ctx.log(`stage-sidecar: ${rel(ctx, file.stagedPath)} already staged`); continue; }
791
+ if (spec.path) {
792
+ const local = resolveLocalFfmpegSource(ctx, spec, file.tool);
793
+ const actual = catalogContentDigest(fs.readFileSync(local), arch);
794
+ if (actual !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${local} is ${actual}, the approved catalog row ${file.artifactId} says ${file.contentSha256}; nothing staged`, { expected: file.contentSha256, actual });
795
+ copyInto(ctx, local, file.stagedPath, { executable: true });
796
+ continue;
797
+ }
690
798
  const { cached } = await ensureCachedDownload(ctx, spec);
691
799
  const scratch = ctx.makeTempDir();
692
800
  try {
693
801
  const member = await ctx.extractMember({ archive: cached, fileName: spec.member, destDir: scratch });
694
- const actual = sha256Of(fs.readFileSync(member));
802
+ const actual = catalogContentDigest(fs.readFileSync(member), arch);
695
803
  if (actual !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${spec.member} from ${spec.url} is ${actual}, the approved catalog row ${file.artifactId} says ${file.contentSha256}; nothing staged`, { expected: file.contentSha256, actual });
696
804
  copyInto(ctx, member, file.stagedPath, { executable: true });
697
805
  } finally {
@@ -746,7 +854,9 @@ async function stageFfmpegSlice(entry, ctx, resolved, { config, t, writeNotices
746
854
  const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
747
855
  const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
748
856
  const rows = verified.map((file) => {
749
- if (file.preSignSha256 !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged as ${file.preSignSha256}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: file.preSignSha256 });
857
+ // Catalog rows pin the signature-independent content digest; preSignSha256 stays whole-file signing evidence.
858
+ const contentDigest = catalogContentDigest(fs.readFileSync(file.stagedPath), t.arch);
859
+ if (contentDigest !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged with content digest ${contentDigest}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: contentDigest });
750
860
  const probe = entry.probe ? probeFfmpegBinary(ctx, { file: file.stagedPath, tool: file.tool, target: t }) : undefined;
751
861
  return { kind: "ffmpeg", name: file.tool, path: rel(ctx, file.stagedPath), sha256: file.preSignSha256, sizeBytes: file.preSignSizeBytes, changed: null, catalogRow: file.artifactId, version: file.version, catalogContentSha256: file.contentSha256, ...(file.variant ? { variant: file.variant } : {}), externalBin: true, license: "LGPL-2.1-or-later", notices, ...(probe ? { probe } : {}) };
752
862
  });
@@ -797,7 +907,7 @@ async function stageFfmpeg(entry, ctx) {
797
907
  if (target.arch === "universal") return await stageFfmpegUniversal(entry, ctx, resolved);
798
908
  return (await stageFfmpegSlice(entry, ctx, resolved, { config, t: target, writeNotices: true })).rows;
799
909
  } catch (error) {
800
- if (error instanceof StageSidecarError) throw error;
910
+ if (error instanceof StageSidecarError || error instanceof ContentDigestError) throw error;
801
911
  return fail("STAGE_SIDECAR_FFMPEG_FAILED", `ffmpeg sidecar (${target.triple}): ${error.message}`, { cause: error, causeCode: error.code });
802
912
  }
803
913
  }
@@ -825,6 +935,10 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
825
935
  });
826
936
  for (const { source } of notices) if (!fs.existsSync(source)) fail("STAGE_SIDECAR_NOTICE_MISSING", `notice file not found: ${source}`);
827
937
 
938
+ // The archive member is the catalog's archive.member (an archive path) when present, else the library file name; the output name never comes from it.
939
+ const member = row.archive.member ?? row.artifactFileName;
940
+ const memberCheck = normalizeArchiveMember(member);
941
+ if (memberCheck.error) fail("STAGE_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} has an invalid archive member ${JSON.stringify(member)}: ${memberCheck.error}`);
828
942
  const outName = entry.as ?? row.artifactFileName;
829
943
  const dest = path.resolve(appRoot, destDir ?? entry.resourceDir, outName);
830
944
  if (fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === row.sha256) {
@@ -840,9 +954,9 @@ async function stageOnnxRuntimeSlice(entry, ctx, loaded, t, { destDir } = {}) {
840
954
  }
841
955
  const scratch = ctx.makeTempDir();
842
956
  try {
843
- const library = await ctx.extractMember({ archive, fileName: row.artifactFileName, destDir: scratch });
957
+ const library = await ctx.extractMember({ archive, fileName: member, destDir: scratch });
844
958
  const actual = sha256Of(fs.readFileSync(library));
845
- if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${row.artifactFileName} extracted as ${actual}, the catalog pins ${row.sha256}; nothing staged`, { expected: row.sha256, actual });
959
+ if (actual !== row.sha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${member} extracted as ${actual}, the catalog pins ${row.sha256}; nothing staged`, { expected: row.sha256, actual });
846
960
  copyInto(ctx, library, dest, { executable: false });
847
961
  } finally {
848
962
  ctx.removeDir(scratch);
@@ -967,7 +1081,7 @@ async function ensureCachedDownload(ctx, { url, sha256, size }) {
967
1081
 
968
1082
  /**
969
1083
  * A pinned https download. `dest` is copied only from the verified cache entry. With `extract` the download is an archive
970
- * (zip, tgz): the one member whose base name is `extract` is unpacked (digest-checked against `memberSha256` when given) and
1084
+ * (zip, tgz): the one member whose base name is `extract` (or, when `extract` contains `/`, whose archive path equals it) is unpacked (digest-checked against `memberSha256` when given) and
971
1085
  * only that member is staged, so no `command` entry or shell is needed to unpack it.
972
1086
  */
973
1087
  async function stageRemote(entry, ctx) {
@@ -1041,6 +1155,32 @@ function checkoutPinnedSource(ctx, entry, srcDir) {
1041
1155
  }
1042
1156
  }
1043
1157
 
1158
+ /** Fixed Visual Studio Installer (vswhere) location under %ProgramFiles(x86)%, and the cmake Visual Studio bundles under an installationPath. */
1159
+ const VSWHERE_PARTS = ["Microsoft Visual Studio", "Installer", "vswhere.exe"];
1160
+ const VS_CMAKE_PARTS = ["Common7", "IDE", "CommonExtensions", "Microsoft", "CMake", "CMake", "bin", "cmake.exe"];
1161
+
1162
+ /**
1163
+ * Which cmake to run. Order: the entry's `cmakeBin` (a bare name is looked up on PATH; a path with separators is resolved against the
1164
+ * app root); off a Windows host plain `cmake`; on a Windows host `cmake` when `where cmake` finds one, else the Visual Studio-bundled
1165
+ * cmake that vswhere locates (installationPath\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe), else plain `cmake`
1166
+ * (which then fails with the usual spawn error naming it).
1167
+ */
1168
+ function resolveCmakeBinary(ctx, entry) {
1169
+ if (entry.cmakeBin) return /[\\/]/.test(entry.cmakeBin) ? path.resolve(ctx.appRoot, entry.cmakeBin) : entry.cmakeBin;
1170
+ if (ctx.host.os !== "windows") return "cmake";
1171
+ const probe = ctx.run("where", ["cmake"], { cwd: ctx.appRoot, env: ctx.env });
1172
+ if (!probe?.error && probe?.status === 0 && String(probe.stdout ?? "").trim()) return "cmake";
1173
+ const programFiles = ctx.env["ProgramFiles(x86)"];
1174
+ if (typeof programFiles !== "string" || !programFiles) return "cmake";
1175
+ const vswhere = path.win32.join(programFiles, ...VSWHERE_PARTS);
1176
+ if (!ctx.fs.existsSync(vswhere)) return "cmake";
1177
+ const found = ctx.run(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.CMake.Project", "-property", "installationPath"], { cwd: ctx.appRoot, env: ctx.env });
1178
+ const installation = found?.error || found?.status !== 0 ? "" : String(found.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
1179
+ if (!installation) return "cmake";
1180
+ const bundled = path.win32.join(installation, ...VS_CMAKE_PARTS);
1181
+ return ctx.fs.existsSync(bundled) ? bundled : "cmake";
1182
+ }
1183
+
1044
1184
  /**
1045
1185
  * Build a pinned git commit with the build tool (no shell) and stage the result as <name>-<triple>[.exe]: fetch the commit, configure,
1046
1186
  * build `target`, locate the executable, check it carries both archs for universal-apple-darwin, optionally pin its digest and
@@ -1049,6 +1189,8 @@ function checkoutPinnedSource(ctx, entry, srcDir) {
1049
1189
  */
1050
1190
  function stageCmake(entry, ctx) {
1051
1191
  const { appRoot, fs, run, env, target } = ctx;
1192
+ const cmake = resolveCmakeBinary(ctx, entry);
1193
+ const generator = entry.platformGenerators?.[target.platform] ?? entry.generator;
1052
1194
  const root = path.resolve(appRoot, CMAKE_ROOT, entry.name);
1053
1195
  const srcDir = path.join(root, "src");
1054
1196
  const buildDir = path.join(root, "build");
@@ -1058,10 +1200,10 @@ function stageCmake(entry, ctx) {
1058
1200
  const archFlag = target.platform !== "mac" ? undefined
1059
1201
  : target.arch === "universal" ? "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64"
1060
1202
  : `-DCMAKE_OSX_ARCHITECTURES=${target.arch === "aarch64" ? "arm64" : "x86_64"}`;
1061
- const configure = ["-S", srcDir, "-B", buildDir, "-DCMAKE_BUILD_TYPE=Release", ...(entry.generator ? ["-G", entry.generator] : []), ...(archFlag ? [archFlag] : []), ...(entry.platformArgs[target.platform] ?? []), ...entry.cmakeArgs];
1203
+ const configure = ["-S", srcDir, "-B", buildDir, "-DCMAKE_BUILD_TYPE=Release", ...(generator ? ["-G", generator] : []), ...(entry.architecture ? ["-A", entry.architecture] : []), ...(archFlag ? [archFlag] : []), ...(entry.platformArgs[target.platform] ?? []), ...entry.cmakeArgs];
1062
1204
  const step = (args, label) => {
1063
- ctx.log(`stage-sidecar: cmake ${args.join(" ")}`);
1064
- const result = run("cmake", args, { cwd: appRoot, env });
1205
+ ctx.log(`stage-sidecar: ${cmake} ${args.join(" ")}`);
1206
+ const result = run(cmake, args, { cwd: appRoot, env });
1065
1207
  if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `cmake ${label} of ${entry.name} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { name: entry.name, stderr: tail(result?.stderr) });
1066
1208
  };
1067
1209
  step(configure, "configure");
@@ -1081,22 +1223,21 @@ function stageCmake(entry, ctx) {
1081
1223
  const smoked = run(dest, entry.smoke, { cwd: appRoot, env });
1082
1224
  if (smoked?.error || smoked?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `${entry.name} does not run (${entry.smoke.join(" ")}): ${smoked?.error?.message ?? `exit ${smoked?.status}`}`, { name: entry.name });
1083
1225
  }
1084
- return [{ kind: "cmake", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, repo: entry.repo, commit: entry.commit, cmakeTarget: entry.target, flags: configure.filter((arg) => arg.startsWith("-D")), externalBin: !entry.dest }];
1226
+ return [{ kind: "cmake", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, repo: entry.repo, commit: entry.commit, cmakeTarget: entry.target, ...(cmake !== "cmake" ? { cmakeBin: cmake } : {}), ...(generator ? { generator } : {}), flags: configure.filter((arg) => arg.startsWith("-D")), externalBin: !entry.dest }];
1085
1227
  }
1086
1228
 
1087
1229
  /** `{platform}` in a prebuilt manifest entry: macos-aarch64, macos-x86_64, windows-x86_64, linux-x86_64, ... */
1088
1230
  const prebuiltPlatformKey = (t) => `${t.os}-${t.arch}`;
1089
1231
 
1090
1232
  /**
1091
- * Verify a host-built binary (already staged at <stageDir>/<name>-<triple>, or copied from `from`) against the digest in an app
1092
- * manifest file: its raw sha256, and when the manifest names an approval hash, the signing-independent approval digest
1233
+ * Verify one host-built binary `source` for `slice` (a parsed target: its os and arch name the manifest entry) against the digest in
1234
+ * an app manifest file: its raw sha256, and when the manifest names an approval hash, the signing-independent approval digest
1093
1235
  * (`macho-codesign-strip-linkedit-v1` on macOS, `raw-sha256` elsewhere). Nothing is built or downloaded: a missing file or
1094
- * a mismatch fails closed.
1236
+ * a mismatch fails closed. Returns { platformKey, actual, approval? }.
1095
1237
  */
1096
- async function stagePrebuilt(entry, ctx) {
1097
- const { appRoot, fs, target } = ctx;
1098
- const dest = destFor(entry, ctx);
1099
- const platformKey = prebuiltPlatformKey(target);
1238
+ async function verifyPrebuilt(entry, ctx, source, slice) {
1239
+ const { appRoot, fs } = ctx;
1240
+ const platformKey = prebuiltPlatformKey(slice);
1100
1241
  const manifestFile = path.resolve(appRoot, entry.manifest.file);
1101
1242
  let manifest;
1102
1243
  try { manifest = JSON.parse(String(fs.readFileSync(manifestFile))); } catch (error) { fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `cannot read the prebuilt manifest ${manifestFile} as JSON`, { cause: error }); }
@@ -1105,7 +1246,6 @@ async function stagePrebuilt(entry, ctx) {
1105
1246
  if (!isObject(node)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} has no ${entry.manifest.entry.replaceAll("{platform}", platformKey)} entry for ${entry.name} on ${platformKey}`);
1106
1247
  const pinned = node[entry.manifest.sha256];
1107
1248
  if (typeof pinned !== "string" || !SHA256.test(pinned)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.sha256} is not a lowercase 64-hex SHA-256`);
1108
- const source = entry.from ? path.resolve(appRoot, entry.from) : dest;
1109
1249
  if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `prebuilt ${entry.name} for ${platformKey} is missing: ${source}. Build it on its native host first; packaging never substitutes a stock binary.`, { name: entry.name, source });
1110
1250
  const actual = sha256Of(fs.readFileSync(source));
1111
1251
  if (actual !== pinned) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} integrity failure: ${source} is ${actual}, ${entry.manifest.file} pins ${pinned}`, { expected: pinned, actual });
@@ -1113,13 +1253,40 @@ async function stagePrebuilt(entry, ctx) {
1113
1253
  if (entry.manifest.approvalSha256) {
1114
1254
  const expected = node[entry.manifest.approvalSha256];
1115
1255
  if (typeof expected !== "string" || !SHA256.test(expected)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalSha256} is not a lowercase 64-hex SHA-256`);
1116
- const algorithm = entry.manifest.approvalAlgorithm ? node[entry.manifest.approvalAlgorithm] : (target.os === "macos" ? APPROVAL_MACHO_STRIP_LINKEDIT_V1 : APPROVAL_RAW_SHA256);
1256
+ const algorithm = entry.manifest.approvalAlgorithm ? node[entry.manifest.approvalAlgorithm] : (slice.os === "macos" ? APPROVAL_MACHO_STRIP_LINKEDIT_V1 : APPROVAL_RAW_SHA256);
1117
1257
  if (typeof algorithm !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalAlgorithm} does not name an approval hash algorithm`);
1118
1258
  let digest;
1119
1259
  try { digest = await ctx.approvalDigest(source, algorithm); } catch (error) { fail("STAGE_SIDECAR_APPROVAL_FAILED", `${entry.name} approval hash (${algorithm}): ${error.message}`, { cause: error, causeCode: error.code }); }
1120
1260
  if (digest !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} signing-independent integrity failure: ${algorithm} is ${digest}, ${entry.manifest.file} pins ${expected}`, { expected, actual: digest });
1121
1261
  approval = { algorithm, sha256: digest };
1122
1262
  }
1263
+ return { platformKey, actual, ...(approval ? { approval } : {}) };
1264
+ }
1265
+
1266
+ /**
1267
+ * Verify a host-built binary (already staged at <stageDir>/<name>-<triple>, or copied from `from`) against the app manifest and stage it.
1268
+ * `from` is a path, or `{ aarch64, x86_64 }` (the entry for this arch is used). `universal-apple-darwin`: each slice (its `from.<arch>`, else the
1269
+ * staged `<stageDir>/<name>-<slice triple>`) is verified against its own manifest entry (macos-aarch64, macos-x86_64), then the two are
1270
+ * lipo-merged by the same helper the ffmpeg, onnxruntime and cmake kinds use into `<name>-universal-apple-darwin`; a thin merge result is refused.
1271
+ */
1272
+ async function stagePrebuilt(entry, ctx) {
1273
+ const { fs, target, appRoot } = ctx;
1274
+ const dest = destFor(entry, ctx);
1275
+ if (target.arch === "universal") {
1276
+ const parts = [];
1277
+ for (const triple of SLICES) {
1278
+ const slice = parseTargetTriple(triple);
1279
+ const from = entry.from?.[slice.arch];
1280
+ const source = from ? path.resolve(appRoot, from) : path.resolve(appRoot, ctx.stageDir, stagedName(entry.name, slice));
1281
+ parts.push({ triple, source, ...(await verifyPrebuilt(entry, ctx, source, slice)) });
1282
+ }
1283
+ const merged = lipoMergeInto(ctx, parts.map((part) => part.source), dest, `prebuilt ${entry.name}`, { executable: true });
1284
+ return [{ kind: "prebuilt", name: entry.name, path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, manifest: entry.manifest.file, platform: "macos-universal", externalBin: !entry.dest, slices: parts.map((part) => ({ triple: part.triple, sha256: part.actual, platform: part.platformKey, ...(part.approval ? { approvalAlgorithm: part.approval.algorithm, approvalSha256: part.approval.sha256 } : {}) })) }];
1285
+ }
1286
+ const declared = isObject(entry.from) ? entry.from[target.arch] : entry.from;
1287
+ if (isObject(entry.from) && !declared) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `prebuilt ${entry.name} declares from for ${Object.keys(entry.from).join(", ")}, not ${target.arch}`, { name: entry.name });
1288
+ const source = declared ? path.resolve(appRoot, declared) : dest;
1289
+ const { platformKey, actual, approval } = await verifyPrebuilt(entry, ctx, source, target);
1123
1290
  let copied;
1124
1291
  if (path.resolve(source) === path.resolve(dest)) {
1125
1292
  if (!ctx.windows) fs.chmodSync(dest, 0o755);