@rightkit/release 0.2.173 → 0.3.0

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 (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
@@ -1,5 +1,5 @@
1
1
  // Directory entries (.app bundles) in `sign.prePackageFiles`: sign nested code inside-out first
2
- // (no entitlements), then the bundle itself, reusing the swift-mac-app inside-out discovery.
2
+ // (no entitlements unless declared per path), then the bundle itself, reusing the swift-mac-app inside-out discovery.
3
3
  import { existsSync, readFileSync, statSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { discoverNestedCode } from "./swift-mac-app.mjs";
@@ -31,3 +31,145 @@ export function expandMacSigningEntries(files, { isDirectory = (f) => statSync(f
31
31
  }
32
32
  return { nested: [...new Set(nested)], files };
33
33
  }
34
+
35
+ // ---------------------------------------------------------------------------------------------------------------------
36
+ // Declarative per-binary signing settings, shared by the dev installer (development-signing.mjs) and release nested code.
37
+ //
38
+ // identifiers: { "<bundle-relative path or glob>": "<--identifier value>" }
39
+ // identifierTemplate: "<bundleId>.runtime.<relpath>" (default identifier for the remaining plain Mach-O files)
40
+ // entitlements: { "<bundle-relative path or glob>": "<entitlements plist path>" }
41
+ //
42
+ // Paths are relative to the .app bundle with `/` separators (`Contents/MacOS/engine`); "." names the bundle itself.
43
+ // An exact key beats a glob; among globs the first declared wins. `*` and `?` stay inside one path segment, `**` crosses
44
+ // segments. Nothing declared means nothing changes: no `--identifier`, no entitlements (release nested code stays unentitled).
45
+ // ---------------------------------------------------------------------------------------------------------------------
46
+
47
+ /** A codesign `--identifier` value. Must not start with `-` (argv safety). */
48
+ export const SIGNING_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/; // same charset as release validation and the signing agent
49
+ const TEMPLATE_TOKENS = new Set(["bundleId", "relpath", "basename"]);
50
+ const BUNDLE_DIRECTORY = /\.(app|framework|xpc|appex|bundle)$/;
51
+ const sanitizePart = (value) => String(value).replace(/[^A-Za-z0-9.-]+/g, ".");
52
+
53
+ export const isGlobPattern = (pattern) => /[*?]/.test(pattern);
54
+
55
+ export function globToRegExp(glob) {
56
+ let out = "";
57
+ for (let i = 0; i < glob.length; i++) {
58
+ const c = glob[i];
59
+ if (c === "*") {
60
+ if (glob[i + 1] === "*") {
61
+ if (glob[i + 2] === "/") { out += "(?:.*/)?"; i += 2; } else { out += ".*"; i += 1; }
62
+ } else out += "[^/]*";
63
+ } else if (c === "?") out += "[^/]";
64
+ else out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
65
+ }
66
+ return new RegExp(`^${out}$`);
67
+ }
68
+
69
+ /** Bundle-relative POSIX path of `file` inside `app`; "" for the bundle itself. */
70
+ export const bundleRelativePath = (app, file) => path.relative(app, file).split(path.sep).join("/");
71
+
72
+ export function validateBundleRelativeKey(key, label, reject) {
73
+ if (typeof key !== "string" || !key.trim() || key !== key.trim()) reject(`${label}: keys must be non-empty bundle-relative paths`);
74
+ if (key !== "." && (key.startsWith("/") || /^[A-Za-z]:/.test(key) || key.includes("\\") || key.split("/").includes(".."))) {
75
+ reject(`${label}: ${JSON.stringify(key)} must be a path inside the .app bundle (relative, no "..", "/" separators)`);
76
+ }
77
+ }
78
+
79
+ export function validateSigningIdentifier(value, label, reject) {
80
+ if (typeof value !== "string" || !SIGNING_IDENTIFIER_PATTERN.test(value)) reject(`${label} must be a codesign identifier (letters, digits, "." "_" "-"; not starting with "-"), got ${JSON.stringify(value)}`);
81
+ return value;
82
+ }
83
+
84
+ /** `<relpath>` and `<basename>` are the bundle-relative path / file name with runs outside [A-Za-z0-9.-] replaced by ".". */
85
+ export function expandIdentifierTemplate(template, { bundleId, relpath }) {
86
+ return template.replace(/<(bundleId|relpath|basename)>/g, (_, token) => {
87
+ if (token === "bundleId") {
88
+ if (!bundleId) throw fail("identifier template uses <bundleId> but the bundle identifier is unknown");
89
+ return bundleId;
90
+ }
91
+ return sanitizePart(token === "relpath" ? relpath : path.posix.basename(relpath));
92
+ });
93
+ }
94
+
95
+ export function validateIdentifierTemplate(template, label, reject) {
96
+ if (typeof template !== "string" || !template) reject(`${label} must be a non-empty string such as "<bundleId>.runtime.<relpath>"`);
97
+ const tokens = [...template.matchAll(/<([^<>]*)>/g)].map((match) => match[1]);
98
+ if (tokens.some((token) => !TEMPLATE_TOKENS.has(token))) reject(`${label} may only use <bundleId>, <relpath> and <basename>`);
99
+ if (!tokens.includes("relpath") && !tokens.includes("basename")) reject(`${label} must contain <relpath> or <basename> so each binary gets its own identifier`);
100
+ const rest = template.replace(/<[^<>]*>/g, "");
101
+ if (rest.includes("<") || rest.includes(">")) reject(`${label} has an unbalanced "<" or ">"`);
102
+ validateSigningIdentifier(expandIdentifierTemplate(template, { bundleId: "a.b", relpath: "Contents/x" }), `${label} (expanded)`, reject);
103
+ return template;
104
+ }
105
+
106
+ /** Compile `{ pattern: value }` into lookup entries. `value(raw, label)` validates and transforms each value. */
107
+ export function compileBundlePathMap(map, label, { reject, value = (raw) => raw }) {
108
+ if (map === undefined) return [];
109
+ if (!map || typeof map !== "object" || Array.isArray(map)) reject(`${label} must be an object mapping bundle-relative paths or globs to values`);
110
+ return Object.entries(map).map(([pattern, raw]) => {
111
+ validateBundleRelativeKey(pattern, label, reject);
112
+ return { pattern, regex: isGlobPattern(pattern) ? globToRegExp(pattern) : null, value: value(raw, `${label}[${JSON.stringify(pattern)}]`) };
113
+ });
114
+ }
115
+
116
+ /** Exact key first, then the first matching glob. undefined when nothing matches. */
117
+ export function lookupBundlePath(entries, relative) {
118
+ const exact = entries.find((entry) => !entry.regex && entry.pattern === relative);
119
+ if (exact) return exact.value;
120
+ return entries.find((entry) => entry.regex?.test(relative))?.value;
121
+ }
122
+
123
+ /**
124
+ * The `--identifier` for one path, or undefined (codesign then derives it as before). The map applies to anything,
125
+ * including "." for the bundle; the template applies only to plain Mach-O files (`plain`), never to bundles.
126
+ */
127
+ export function resolveBundleIdentifier({ relative, identifiers = [], template, bundleId, plain }) {
128
+ const explicit = lookupBundlePath(identifiers, relative || ".");
129
+ if (explicit !== undefined) return explicit;
130
+ if (!template || !plain || !relative) return undefined;
131
+ const expanded = expandIdentifierTemplate(template, { bundleId, relpath: relative });
132
+ if (!SIGNING_IDENTIFIER_PATTERN.test(expanded)) throw fail(`identifier template produced an invalid identifier for ${relative}: ${JSON.stringify(expanded)}`);
133
+ return expanded;
134
+ }
135
+
136
+ /** CFBundleIdentifier of a bundle (XML plist only; undefined when unreadable). */
137
+ export function bundleIdentifierOf(app) {
138
+ try {
139
+ const plist = readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
140
+ return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1];
141
+ } catch { return undefined; }
142
+ }
143
+
144
+ /**
145
+ * Release nested-code settings from `sign.identifiers`, `sign.identifierTemplate` and a per-path `sign.entitlements` object.
146
+ * Returns Map<absolute nested file, { identifier?, entitlements?: absolute }> holding only files with something declared;
147
+ * every other file keeps today's behaviour (no entitlements, no --identifier). A string `sign.entitlements` is the
148
+ * app-level file and is ignored here. Paths in the map are resolved against `root`; a missing file is an error.
149
+ * The caller (release signing) adds the flags with `nestedSigningFlags`.
150
+ */
151
+ export function resolveNestedSigningSettings(nested, { app, sign = {}, bundleId = bundleIdentifierOf(app), root, exists = existsSync }) {
152
+ const reject = (message) => { throw fail(message); };
153
+ const identifiers = compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject, value: (raw, label) => validateSigningIdentifier(raw, label, reject) });
154
+ const template = sign.identifierTemplate === undefined ? undefined : validateIdentifierTemplate(sign.identifierTemplate, "sign.identifierTemplate", reject);
155
+ const perPath = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
156
+ const entitlements = compileBundlePathMap(perPath, "sign.entitlements", { reject, value: (raw, label) => {
157
+ if (typeof raw !== "string" || !raw.trim()) reject(`${label} must be an entitlements plist path`);
158
+ const file = path.resolve(root, raw);
159
+ if (!exists(file)) reject(`${label} entitlements file not found: ${file}`);
160
+ return file;
161
+ } });
162
+ const settings = new Map();
163
+ if (!identifiers.length && !template && !entitlements.length) return settings;
164
+ for (const file of nested) {
165
+ const relative = bundleRelativePath(app, file);
166
+ const identifier = resolveBundleIdentifier({ relative, identifiers, template, bundleId, plain: !BUNDLE_DIRECTORY.test(file) });
167
+ const entitlementsFile = lookupBundlePath(entitlements, relative);
168
+ if (identifier !== undefined || entitlementsFile !== undefined) {
169
+ settings.set(file, { ...(identifier !== undefined ? { identifier } : {}), ...(entitlementsFile !== undefined ? { entitlements: entitlementsFile } : {}) });
170
+ }
171
+ }
172
+ return settings;
173
+ }
174
+
175
+ export const nestedSigningFlags = (settings) => [...(settings?.identifier ? ["--identifier", settings.identifier] : []), ...(settings?.entitlements ? ["--entitlements", settings.entitlements] : [])];
@@ -11,7 +11,7 @@
11
11
  // inside-out plan. It never runs codesign: sign-macos.mjs (via mac-sign-core.mjs) executes the plan.
12
12
  import { existsSync, lstatSync, openSync, closeSync, readSync, realpathSync, statSync } from "node:fs";
13
13
  import path from "node:path";
14
- import { expandMacSigningEntries, mainExecutableName } from "./mac-bundle-signing.mjs";
14
+ import { expandMacSigningEntries, mainExecutableName, resolveNestedSigningSettings } from "./mac-bundle-signing.mjs";
15
15
 
16
16
  const invalid = (message) => Object.assign(new Error(message), { code: "MAC_SIGN_CONFIG_INVALID" });
17
17
 
@@ -85,11 +85,57 @@ function validateNestedKey(key) {
85
85
  return null;
86
86
  }
87
87
 
88
+ // Per-path nested signing maps: `sign.identifiers`, `sign.identifierTemplate` and an object-valued `sign.entitlements`.
89
+ // Same strictness as the per-item settings above: reverse-DNS identifiers, contained relative plist paths that exist.
90
+ function validateNestedSigningMaps(sign, { root, errors, isFile }) {
91
+ const declared = {};
92
+ const check = (map, label, value) => {
93
+ if (!isPlainObject(map)) { errors.push(`${label} must be an object mapping bundle-relative paths or globs to values`); return; }
94
+ for (const [key, raw] of Object.entries(map)) {
95
+ const entry = `${label}[${JSON.stringify(key)}]`;
96
+ if (!key.trim() || key !== key.trim() || key.includes("\0") || key.includes("\\") || path.posix.isAbsolute(key) || /^[A-Za-z]:/.test(key) || key.split("/").includes("..")) {
97
+ errors.push(`${entry}: key must be a bundle-relative path (no "..", no absolute path, "/" separators)`);
98
+ }
99
+ value(raw, entry);
100
+ }
101
+ };
102
+ if (sign.identifiers !== undefined) {
103
+ check(sign.identifiers, "sign.identifiers", (raw, entry) => {
104
+ if (typeof raw !== "string" || !IDENTIFIER_PATTERN.test(raw)) errors.push(`${entry} must be 1-255 characters of letters, digits, '.' and '-', starting with a letter or digit`);
105
+ });
106
+ declared.identifiers = sign.identifiers;
107
+ }
108
+ if (sign.identifierTemplate !== undefined) {
109
+ const template = sign.identifierTemplate;
110
+ if (typeof template !== "string" || !template) errors.push("sign.identifierTemplate must be a non-empty string such as \"<bundleId>.runtime.<relpath>\"");
111
+ else if (!/^[A-Za-z0-9.-]*$/.test(template.replace(/<[^<>]*>/g, ""))) errors.push("sign.identifierTemplate literal text may only contain letters, digits, '.' and '-' around <bundleId>, <relpath> and <basename>");
112
+ else declared.identifierTemplate = template;
113
+ }
114
+ if (isPlainObject(sign.entitlements)) {
115
+ check(sign.entitlements, "sign.entitlements", (raw, entry) => {
116
+ if (typeof raw !== "string" || !raw.trim()) { errors.push(`${entry} must be a non-empty path string`); return; }
117
+ if (path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || raw.includes("\0")) { errors.push(`${entry} must be relative to the release config directory: ${raw}`); return; }
118
+ const resolved = path.resolve(root, raw);
119
+ const relative = path.relative(root, resolved);
120
+ if (relative.startsWith("..") || path.isAbsolute(relative)) errors.push(`${entry} must stay inside the release config directory: ${raw}`);
121
+ else if (!isFile(resolved)) errors.push(`${entry} entitlements file not found: ${raw}`);
122
+ else if (!looksLikePlist(resolved)) errors.push(`${entry} is not a property list (expected an XML or binary plist): ${raw}`);
123
+ });
124
+ declared.entitlements = sign.entitlements;
125
+ }
126
+ if (errors.length || !Object.keys(declared).length) return undefined;
127
+ // Delegate key globs and template tokens to the shared resolver so release and development read them identically.
128
+ try { resolveNestedSigningSettings([], { app: "x.app", sign: declared, bundleId: "a.b", root, exists: () => true }); }
129
+ catch (error) { errors.push(error.message); return undefined; }
130
+ return declared;
131
+ }
132
+
88
133
  /**
89
134
  * Validate and normalize `sign` for a macOS target. Collects every problem, then throws one
90
- * MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode } where
91
- * entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
92
- * nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
135
+ * MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode, nestedSigning? } where
136
+ * entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
137
+ * nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
138
+ * nestedSigning: { identifiers?, identifierTemplate?, entitlements? } as declared; present only when one is declared
93
139
  * Injectable: isFile.
94
140
  */
95
141
  export function validateMacSigningConfig(sign, { root, isFile = (file) => existsSync(file) && statSync(file).isFile() } = {}) {
@@ -136,8 +182,10 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
136
182
  }
137
183
  }
138
184
  }
185
+ const nestedSigning = validateNestedSigningMaps(sign, context);
186
+ if (nestedSigning && !entries.some((entry) => /\.app\/?$/.test(entry.file))) errors.push("sign.identifiers, sign.identifierTemplate and per-path sign.entitlements need a .app entry in sign.prePackageFiles to resolve their paths against");
139
187
  if (errors.length) throw invalid(`invalid macOS signing configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
140
- return { entries, nestedCode };
188
+ return { entries, nestedCode, ...(nestedSigning ? { nestedSigning } : {}) };
141
189
  }
142
190
 
143
191
  /** True when an entry or nested item carries anything beyond the default (hardened runtime, no entitlements). */
@@ -154,7 +202,7 @@ const depth = (file) => file.split(path.sep).length;
154
202
  * a nestedCode path is missing, is a symlink, escapes the bundle, or names the bundle's main executable.
155
203
  * Injectable: expand, lstat, realpath, mainName.
156
204
  */
157
- export function planMacSigning({ entries, nestedCode = [], expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
205
+ export function planMacSigning({ entries, nestedCode = [], nestedSigning, root, resolveSettings = resolveNestedSigningSettings, expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
158
206
  const { nested: discovered } = expand(entries.map((entry) => entry.file));
159
207
  const byPath = new Map(discovered.map((file) => [file, { file, options: [] }]));
160
208
  if (nestedCode.length) {
@@ -174,6 +222,25 @@ export function planMacSigning({ entries, nestedCode = [], expand = expandMacSig
174
222
  byPath.set(file, { file, ...(item.identifier ? { identifier: item.identifier } : {}), ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
175
223
  }
176
224
  }
225
+ // Per-path maps (sign.identifiers / identifierTemplate / per-path sign.entitlements) fill in only what a nestedCode item left
226
+ // unset. Nothing declared: this block is skipped and the items stay exactly as discovered.
227
+ if (nestedSigning && Object.keys(nestedSigning).length) {
228
+ const apps = entries.map((entry) => entry.file).filter((file) => /\.app\/?$/.test(file)).map((file) => file.replace(/\/+$/, ""));
229
+ for (const app of apps) {
230
+ const own = [...byPath.values()].filter((item) => item.file.startsWith(app + path.sep) || item.file.startsWith(`${app}/`));
231
+ let settings;
232
+ try { settings = resolveSettings(own.map((item) => item.file), { app, sign: nestedSigning, root: root ?? process.cwd() }); }
233
+ catch (error) { throw invalid(error.message); }
234
+ for (const item of own) {
235
+ const found = settings.get(item.file);
236
+ if (!found) continue;
237
+ const identifier = item.identifier ?? found.identifier;
238
+ if (identifier !== undefined && !IDENTIFIER_PATTERN.test(identifier)) throw invalid(`signing identifier for ${path.relative(app, item.file)} must be letters, digits, '.' and '-': ${JSON.stringify(identifier)}`);
239
+ const entitlements = item.entitlements ?? found.entitlements;
240
+ byPath.set(item.file, { ...item, ...(identifier !== undefined ? { identifier } : {}), ...(entitlements ? { entitlements } : {}) });
241
+ }
242
+ }
243
+ }
177
244
  const nested = [...byPath.values()].sort((a, b) => depth(b.file) - depth(a.file) || a.file.localeCompare(b.file));
178
245
  return { nested, top: entries };
179
246
  }
@@ -0,0 +1,181 @@
1
+ // Signing-independent approval hashes for staged prebuilt binaries (Orthic-Labs/rightkit#490).
2
+ //
3
+ // `raw-sha256` the sha256 of the file bytes (Windows and Linux binaries).
4
+ // `macho-codesign-strip-linkedit-v1` the sha256 of a thin little-endian 64-bit Mach-O after its code signature is removed:
5
+ // LC_CODE_SIGNATURE and the trailing signature blob are gone, the __LINKEDIT filesize is
6
+ // whatever `codesign --remove-signature` leaves, and the __LINKEDIT vmsize is normalised to
7
+ // the page-rounded filesize, so a signed and an unsigned copy of the same build hash alike.
8
+ //
9
+ // This is the algorithm GenRight's `local-image-approval-hash.mjs` recorded as `approval_hash_algorithm` in its runtime
10
+ // manifest, moved here so apps stop carrying it. Every layout surprise (a fat or big-endian file, a duplicate or non-terminal
11
+ // signature, a load command that changed, padding that is not zero) is a typed refusal, never a guess. The signature is removed
12
+ // from a private copy with `/usr/bin/codesign --remove-signature`; the input file is never modified. macOS only for Mach-O.
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { chmodSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { spawnSync } from "./background-process.mjs";
18
+
19
+ export const APPROVAL_RAW_SHA256 = "raw-sha256";
20
+ export const APPROVAL_MACHO_STRIP_LINKEDIT_V1 = "macho-codesign-strip-linkedit-v1";
21
+ export const APPROVAL_ALGORITHMS = Object.freeze([APPROVAL_RAW_SHA256, APPROVAL_MACHO_STRIP_LINKEDIT_V1]);
22
+
23
+ const LC_CODE_SIGNATURE = 0x1d;
24
+ const LC_SEGMENT_64 = 0x19;
25
+ const MH_MAGIC_64 = 0xfeedfacf;
26
+ const CPU_TYPE_X86_64 = 0x01000007;
27
+ const CPU_TYPE_ARM64 = 0x0100000c;
28
+
29
+ export class ApprovalHashError extends Error {
30
+ constructor(code, message) { super(message); this.name = "ApprovalHashError"; this.code = code; }
31
+ }
32
+ const fail = (message, code = "APPROVAL_HASH_INVALID_MACHO") => { throw new ApprovalHashError(code, `backend integrity: ${message}`); };
33
+
34
+ /** Parse a thin 64-bit little-endian Mach-O. Exported for tests. */
35
+ export function parseMachO(bytes) {
36
+ if (bytes.length < 32 || bytes.readUInt32LE(0) !== MH_MAGIC_64) fail("only little-endian 64-bit thin Mach-O is supported");
37
+ const cpuType = bytes.readUInt32LE(4);
38
+ if (![CPU_TYPE_X86_64, CPU_TYPE_ARM64].includes(cpuType)) fail("unsupported Mach-O CPU type");
39
+ const ncmds = bytes.readUInt32LE(16);
40
+ const sizeofcmds = bytes.readUInt32LE(20);
41
+ const commandEnd = 32 + sizeofcmds;
42
+ if (ncmds > 65_536 || commandEnd > bytes.length || sizeofcmds > bytes.length / 2) fail("invalid Mach-O load command table");
43
+ let cursor = 32;
44
+ let signature = null;
45
+ let linkedit = null;
46
+ const normalizedCommands = [];
47
+ for (let index = 0; index < ncmds; index += 1) {
48
+ if (cursor + 8 > commandEnd) fail("truncated Mach-O load command");
49
+ const command = bytes.readUInt32LE(cursor);
50
+ const commandSize = bytes.readUInt32LE(cursor + 4);
51
+ if (commandSize < 8 || commandSize % 4 !== 0 || cursor + commandSize > commandEnd) fail("invalid Mach-O load command size");
52
+ const commandBytes = Buffer.from(bytes.subarray(cursor, cursor + commandSize));
53
+ if (command === LC_CODE_SIGNATURE) {
54
+ if (commandSize !== 16 || signature) fail("invalid duplicate code signature command");
55
+ const offset = bytes.readUInt32LE(cursor + 8);
56
+ const size = bytes.readUInt32LE(cursor + 12);
57
+ if (!size || offset + size > bytes.length) fail("invalid code signature extent");
58
+ signature = { offset, size };
59
+ } else if (command === LC_SEGMENT_64) {
60
+ if (commandSize < 72) fail("truncated Mach-O segment command");
61
+ const sectionCount = commandBytes.readUInt32LE(64);
62
+ if (sectionCount > 65_536 || 72 + sectionCount * 80 > commandSize) fail("invalid Mach-O segment sections");
63
+ const segmentName = commandBytes.subarray(8, 24).toString("utf8").replace(/\0.*$/s, "");
64
+ if (segmentName === "__LINKEDIT") {
65
+ if (linkedit) fail("duplicate __LINKEDIT segment");
66
+ const vmsize = commandBytes.readBigUInt64LE(32);
67
+ const fileoff = commandBytes.readBigUInt64LE(40);
68
+ const filesize = commandBytes.readBigUInt64LE(48);
69
+ if (fileoff + filesize > BigInt(bytes.length) || vmsize < filesize) fail("invalid __LINKEDIT extent");
70
+ // vmsize and filesize legitimately differ after the signature is removed: they are normalised, never compared raw.
71
+ commandBytes.fill(0, 32, 40);
72
+ commandBytes.fill(0, 48, 56);
73
+ linkedit = { fileoff, filesize, vmsize, vmsizeOffset: cursor + 32 };
74
+ }
75
+ }
76
+ if (command !== LC_CODE_SIGNATURE) normalizedCommands.push(commandBytes);
77
+ cursor += commandSize;
78
+ }
79
+ if (cursor !== commandEnd) fail("load commands do not fill declared table");
80
+ if (!linkedit) fail("missing __LINKEDIT segment");
81
+ if (signature && (BigInt(signature.offset) < linkedit.fileoff
82
+ || signature.offset + signature.size !== bytes.length
83
+ || linkedit.fileoff + linkedit.filesize !== BigInt(bytes.length))) {
84
+ fail("signature is not terminal __LINKEDIT data");
85
+ }
86
+ return { bytes, cpuType, ncmds, sizeofcmds, signature, linkedit, normalizedCommands };
87
+ }
88
+
89
+ /**
90
+ * Every byte outside the explicitly permitted ranges must be identical between the captured original and the stripped copy.
91
+ * Permitted: header ncmds/sizeofcmds (checked by the caller), the load-command table (compared command by command, with only
92
+ * the signature command removed and __LINKEDIT vmsize/filesize normalised), the zeroed tail the shorter table leaves, and the
93
+ * signature blob plus its alignment padding at the end of __LINKEDIT. Code, data and all __LINKEDIT content before the
94
+ * signature are compared byte for byte.
95
+ */
96
+ function assertOnlySignatureDiffers(before, after) {
97
+ const a = before.bytes;
98
+ const b = after.bytes;
99
+ if (!before.signature) {
100
+ if (a.length !== b.length || !a.equals(b)) fail("unsigned Mach-O changed during normalization");
101
+ return;
102
+ }
103
+ const same = (from, to, label) => {
104
+ if (from === to) return;
105
+ if (!a.subarray(from, to).equals(b.subarray(from, to))) fail(`signature removal changed ${label}`);
106
+ };
107
+ const beforeTable = 32 + before.sizeofcmds;
108
+ const afterTable = 32 + after.sizeofcmds;
109
+ same(0, 16, "Mach-O header");
110
+ same(24, 32, "Mach-O header");
111
+ if (b.subarray(afterTable, beforeTable).some((byte) => byte !== 0)) fail("signature removal left nonzero bytes after the load commands");
112
+ const keptEnd = Number(after.linkedit.fileoff + after.linkedit.filesize);
113
+ if (b.length !== keptEnd) fail("stripped Mach-O does not end at __LINKEDIT");
114
+ same(beforeTable, keptEnd, "executable or data bytes");
115
+ }
116
+
117
+ /**
118
+ * The approval digest of an already-unsigned copy and its signed original: verifies that signature removal changed
119
+ * nothing but the signature, then hashes the unsigned bytes with the normalised __LINKEDIT vmsize. Exported for tests.
120
+ */
121
+ export function machoApprovalDigest(beforeBytes, afterBytes) {
122
+ const before = parseMachO(beforeBytes);
123
+ const after = parseMachO(Buffer.from(afterBytes));
124
+ if (after.signature) fail("signature remains after normalization");
125
+ if (before.signature && (before.ncmds !== after.ncmds + 1 || before.sizeofcmds !== after.sizeofcmds + 16)) fail("signature removal changed Mach-O command layout");
126
+ if (before.signature) {
127
+ const beforeLinkeditEnd = before.linkedit.fileoff + before.linkedit.filesize;
128
+ const afterLinkeditEnd = after.linkedit.fileoff + after.linkedit.filesize;
129
+ const removedPadding = BigInt(before.signature.offset) - afterLinkeditEnd;
130
+ if (beforeLinkeditEnd !== BigInt(before.signature.offset + before.signature.size)
131
+ || after.linkedit.fileoff !== before.linkedit.fileoff
132
+ || afterLinkeditEnd > BigInt(before.signature.offset)
133
+ || removedPadding < 0n || removedPadding > 15n
134
+ || BigInt(after.bytes.length) !== afterLinkeditEnd) {
135
+ fail("code signature is outside trailing __LINKEDIT data");
136
+ }
137
+ const start = Number(afterLinkeditEnd);
138
+ const end = before.signature.offset;
139
+ if (before.bytes.subarray(start, end).some((byte) => byte !== 0)) fail("nonzero alignment padding before code signature");
140
+ }
141
+ assertOnlySignatureDiffers(before, after);
142
+ if (before.normalizedCommands.length !== after.normalizedCommands.length
143
+ || before.normalizedCommands.some((command, index) => !command.equals(after.normalizedCommands[index]))) {
144
+ fail("signature removal changed non-signature load commands");
145
+ }
146
+ const pageSize = before.cpuType === CPU_TYPE_ARM64 ? 16n * 1024n : 4096n;
147
+ const roundedSize = ((after.linkedit.filesize + pageSize - 1n) / pageSize) * pageSize;
148
+ if (after.linkedit.vmsize < roundedSize) fail("invalid __LINKEDIT virtual size");
149
+ after.bytes.writeBigUInt64LE(roundedSize, after.linkedit.vmsizeOffset);
150
+ return createHash("sha256").update(after.bytes).digest("hex");
151
+ }
152
+
153
+ /** Default signature removal: `/usr/bin/codesign --remove-signature <copy>` (macOS only). */
154
+ function removeSignatureWithCodesign(copy) {
155
+ const result = spawnSync("/usr/bin/codesign", ["--remove-signature", copy], { stdio: "ignore", timeout: 15_000, windowsHide: true });
156
+ if (result.error || result.status !== 0) throw new ApprovalHashError("APPROVAL_HASH_CODESIGN_FAILED", `backend integrity: codesign normalization failed (${result.error?.message ?? result.status})`);
157
+ }
158
+
159
+ /**
160
+ * The approval digest of `file` under `algorithm`. `removeSignature(copyPath)` is injectable for tests and for hosts that
161
+ * strip signatures another way; the default needs macOS. The input file is never modified.
162
+ */
163
+ export function approvalDigest(file, algorithm, { platform = process.platform, removeSignature = removeSignatureWithCodesign, tmpdir = os.tmpdir() } = {}) {
164
+ if (algorithm === APPROVAL_RAW_SHA256) return createHash("sha256").update(readFileSync(file)).digest("hex");
165
+ if (algorithm !== APPROVAL_MACHO_STRIP_LINKEDIT_V1) throw new ApprovalHashError("APPROVAL_HASH_ALGORITHM_UNKNOWN", `unknown approval hash algorithm ${JSON.stringify(algorithm)} (supported: ${APPROVAL_ALGORITHMS.join(", ")})`);
166
+ if (platform !== "darwin") throw new ApprovalHashError("APPROVAL_HASH_HOST_UNSUPPORTED", `${APPROVAL_MACHO_STRIP_LINKEDIT_V1} needs macOS (codesign); this host is ${platform}`);
167
+ if (!lstatSync(file).isFile()) fail("expected regular Mach-O file");
168
+ const original = readFileSync(file);
169
+ const before = parseMachO(original);
170
+ const directory = mkdtempSync(path.join(tmpdir, `rightkit-macho-${process.pid}-${randomBytes(8).toString("hex")}-`), { mode: 0o700 });
171
+ const copy = path.join(directory, "binary");
172
+ try {
173
+ // The copy is written from the captured bytes, never re-read from the path, so the validated snapshot is the one stripped.
174
+ writeFileSync(copy, original, { mode: 0o700 });
175
+ chmodSync(copy, 0o700);
176
+ if (before.signature) removeSignature(copy);
177
+ return machoApprovalDigest(original, readFileSync(copy));
178
+ } finally {
179
+ rmSync(directory, { recursive: true, force: true });
180
+ }
181
+ }
@@ -5,7 +5,7 @@ import { spawnSync } from "./background-process.mjs";
5
5
 
6
6
  import { requestSigningAgentSync, validateNotaryAgentReport } from "./signing-agent-client.mjs";
7
7
  import { installSigningAgent, requireMacGuiSession } from "./signing-agent-install.mjs";
8
- import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within, NOTARIZATION_TIMEOUT_MS, MAX_NOTARIZATION_TIMEOUT_MS } from "./rightkit-signing-agent.mjs";
8
+ import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within, NOTARIZATION_TIMEOUT_MS, MAX_NOTARIZATION_TIMEOUT_MS, AGENT_IDENTIFIER_PATTERN, CODESIGN_IDENTIFIER_SCHEMA } from "./rightkit-signing-agent.mjs";
9
9
  import { refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
10
10
 
11
11
  export class MacSigningSessionError extends Error {
@@ -48,11 +48,13 @@ export function codesignAgentItems(commands) {
48
48
  else if (option === "--timestamp" && item.timestamp === undefined) item.timestamp = true;
49
49
  else if (option === "--options" && args[i + 1] === "runtime" && item.runtime === undefined) { item.runtime = true; i++; }
50
50
  else if (option === "--sign" && item.identity === undefined) item.identity = args[++i];
51
+ else if (option === "--identifier" && item.identifier === undefined) item.identifier = args[++i];
51
52
  else if (option === "--entitlements" && item.entitlements === undefined) item.entitlements = args[++i];
52
53
  else if (option === "--preserve-metadata=entitlements" && item.preserveEntitlements === undefined) item.preserveEntitlements = true;
53
54
  else throw new TypeError(`Unsupported agent codesign option: ${option}`);
54
55
  }
55
56
  item.path = args.at(-1);
57
+ if (item.identifier !== undefined && !AGENT_IDENTIFIER_PATTERN.test(item.identifier)) throw new TypeError("Agent signing requires a reverse-DNS codesign identifier");
56
58
  if (!item.identity || item.identity === "-" || !path.isAbsolute(item.path ?? "")
57
59
  || (item.entitlements !== undefined && !path.isAbsolute(item.entitlements))) throw new TypeError("Agent signing requires real identity & absolute paths");
58
60
  return item;
@@ -76,6 +78,8 @@ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), e
76
78
  try {
77
79
  if (kind === "codesign") {
78
80
  plan.items = codesignAgentItems(commands);
81
+ // Only a request that carries a typed identifier uses schema 2; every other codesign request stays schema 1.
82
+ if (plan.items.some((item) => item.identifier !== undefined)) plan.schema = CODESIGN_IDENTIFIER_SCHEMA;
79
83
  // Development signing generates entitlements in TMPDIR. Copy only those
80
84
  // plist bytes into managed private staging, never broaden signing roots.
81
85
  for (const item of plan.items) {
@@ -99,7 +103,13 @@ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), e
99
103
  let report;
100
104
  try { report = request(plan, clientOptions); }
101
105
  catch (error) {
102
- if (error.code !== "MAC_SIGNING_AGENT_UNAVAILABLE") throw error;
106
+ let missing = error.code === "MAC_SIGNING_AGENT_UNAVAILABLE";
107
+ // An installed agent that predates typed identifiers refuses schema 2: upgrade it once, never loop on a rejected request.
108
+ if (!missing && plan.schema === CODESIGN_IDENTIFIER_SCHEMA && error.code === "MAC_SIGNING_AGENT_PROTOCOL") {
109
+ const health = request({ schema: 1, type: "health" }, { home, timeoutMs: 1500 });
110
+ missing = !health.capabilities?.includes("codesign-identifier");
111
+ }
112
+ if (!missing) throw error;
103
113
  requireMacGuiSession({ run, uid });
104
114
  install({ home, username, uid, run, io, managedRoot: env.RIGHTKIT_MANAGED_ROOT });
105
115
  report = request(plan, clientOptions); // One install attempt, one retry.
package/managed-cargo.mjs CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
- import { spawnSync } from "./background-process.mjs";
6
+ import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
7
7
 
8
8
  export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
9
9
  const roots = managedControlCandidates({ env, platform });
@@ -72,3 +72,51 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
72
72
  function unavailable(message) {
73
73
  return { status: null, stdout: "", stderr: message, error: Object.assign(new Error(message), { code: 'RK_BROKER_UNAVAILABLE' }) };
74
74
  }
75
+
76
+ /**
77
+ * Async `spawnSync` look-alike for the batch publisher: runs the command through the release spawn boundary
78
+ * (background-process.mjs), collects stdout and stderr, and resolves `{ status, signal, stdout, stderr, error }`.
79
+ * It never rejects: a spawn failure, a timeout or an exceeded `maxBuffer` resolves with `error` set, which is
80
+ * exactly how callers already read a failed `spawnSync`.
81
+ */
82
+ export function spawnCollect(command, args, options = {}, { spawnAsync = spawnProcess } = {}) {
83
+ const { timeout, maxBuffer = 512 * 1024 * 1024, encoding: _encoding, stdio: _stdio, ...rest } = options;
84
+ return new Promise((resolve) => {
85
+ let child;
86
+ try { child = spawnAsync(command, args, { ...rest, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { resolve({ status: null, signal: null, stdout: "", stderr: "", error }); return; }
87
+ const out = []; const err = [];
88
+ let size = 0; let settled = false; let timer; let failure;
89
+ const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); };
90
+ const take = (chunks) => (chunk) => {
91
+ size += chunk.length;
92
+ if (size > maxBuffer) { failure ??= new Error(`output exceeded maxBuffer (${maxBuffer} bytes)`); child.kill("SIGKILL"); return; }
93
+ chunks.push(chunk);
94
+ };
95
+ child.stdout.on("data", take(out));
96
+ child.stderr.on("data", take(err));
97
+ child.on("error", (error) => finish({ status: null, signal: null, stdout: "", stderr: "", error }));
98
+ child.on("close", (status, signal) => finish({
99
+ status, signal, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8"), error: failure,
100
+ }));
101
+ if (timeout) timer = setTimeout(() => { failure ??= new Error(`timed out after ${timeout}ms`); child.kill("SIGKILL"); }, timeout);
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Async twin of `runCargoSync`: the same client, broker-socket and darwin-never-direct selection (it asks
107
+ * `runCargoSync` to choose, with a spawn that only records the invocation), then runs the chosen invocation
108
+ * without blocking the event loop so independent crates can publish concurrently. The broker still
109
+ * serializes the builds themselves.
110
+ */
111
+ export async function runCargoAsync(args, options = {}, deps = {}) {
112
+ const { spawnAsync = spawnCollect, ...syncDeps } = deps;
113
+ let chosen;
114
+ const probe = runCargoSync(args, options, { ...syncDeps, spawn: (command, commandArgs, commandOptions) => { chosen = { command, args: commandArgs, options: commandOptions }; return { status: 0, stdout: "", stderr: "" }; } });
115
+ if (!chosen) return probe;
116
+ const result = await spawnAsync(chosen.command, chosen.args, chosen.options);
117
+ const platform = syncDeps.platform ?? process.platform;
118
+ if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
119
+ return { ...result, error: unavailable(String(result.stderr).trim()).error };
120
+ }
121
+ return result;
122
+ }
package/media-sidecar.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // targets.mac.mediaSidecar = {
4
4
  // arch: "aarch64", // catalog arch (default: build host); one arch per build
5
5
  // variant: "decode", // catalog variant (default "decode")
6
+ // variants: { ffmpeg: "export-ass" }, // optional per-tool variant override (a tool not named uses `variant`)
6
7
  // tools: ["ffmpeg", "ffprobe"], // default both
7
8
  // stageDir: "src-tauri/binaries", // Tauri externalBin directory (default)
8
9
  // bundleDir: "Contents/MacOS", // mac only: where Tauri puts externalBin (or "Contents/Resources")
@@ -26,7 +27,7 @@ export const MEDIA_SIDECAR_TOOLS = Object.freeze(["ffmpeg", "ffprobe"]);
26
27
  const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
27
28
  const ARCHES = new Set(["aarch64", "x86_64"]);
28
29
  const SHA256 = /^[0-9a-f]{64}$/;
29
- const CONFIG_KEYS = new Set(["arch", "variant", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
30
+ const CONFIG_KEYS = new Set(["arch", "variant", "variants", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
30
31
  const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
31
32
 
32
33
  const invalid = (code, message) => Object.assign(new Error(message), { code });
@@ -87,6 +88,18 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
87
88
  if (!Array.isArray(tools) || !tools.length || new Set(tools).size !== tools.length || tools.some((tool) => !MEDIA_SIDECAR_TOOLS.includes(tool))) {
88
89
  errors.push(`tools must be a non-empty list without duplicates drawn from ${MEDIA_SIDECAR_TOOLS.join(", ")}`);
89
90
  }
91
+ // One entry may mix variants (for example the export-ass ffmpeg with the decode ffprobe): `variants` maps a tool to its variant.
92
+ let variants;
93
+ if (raw.variants !== undefined) {
94
+ if (!isObject(raw.variants) || !Object.keys(raw.variants).length) errors.push("variants must be an object mapping a tool to a catalog variant slug");
95
+ else {
96
+ for (const [tool, slug] of Object.entries(raw.variants)) {
97
+ if (!MEDIA_SIDECAR_TOOLS.includes(tool) || (Array.isArray(tools) && !tools.includes(tool))) errors.push(`variants.${tool}: not one of the configured tools`);
98
+ if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.push(`variants.${tool} must be a catalog variant slug: ${JSON.stringify(slug)}`);
99
+ }
100
+ variants = { ...raw.variants };
101
+ }
102
+ }
90
103
  const stageDir = relativePath(raw.stageDir ?? "src-tauri/binaries", "stageDir");
91
104
  let bundleDir;
92
105
  if (os === "macos") {
@@ -109,7 +122,7 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
109
122
  else stage = raw.stage;
110
123
  }
111
124
  if (errors.length) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `invalid mediaSidecar configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
112
- return { os, arch, variant, tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
125
+ return { os, arch, variant, ...(variants ? { variants } : {}), tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
113
126
  }
114
127
 
115
128
  /** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
@@ -126,9 +139,10 @@ export function mediaSidecarNestedCode(config) {
126
139
  export function selectSidecarRows(catalog, config) {
127
140
  if (!isObject(catalog) || !Array.isArray(catalog.artifacts)) throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", "catalog has no artifacts array");
128
141
  return config.tools.map((tool) => {
129
- const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === config.variant
142
+ const variant = config.variants?.[tool] ?? config.variant;
143
+ const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === variant
130
144
  && String(row.os).toLowerCase() === config.os && String(row.arch).toLowerCase() === config.arch);
131
- const where = `${tool} ${config.variant} ${config.os}/${config.arch}`;
145
+ const where = `${tool} ${variant} ${config.os}/${config.arch}`;
132
146
  if (matches.length === 0) throw invalid("MEDIA_SIDECAR_NO_ROW", `the rightkit-ffmpeg catalog has no ${where} row`);
133
147
  if (matches.length > 1) throw invalid("MEDIA_SIDECAR_AMBIGUOUS_ROW", `the rightkit-ffmpeg catalog has ${matches.length} ${where} rows`);
134
148
  const [row] = matches;
@@ -152,6 +166,7 @@ export function planMediaSidecar({ config, catalog, crateDir, crateVersion, cata
152
166
  artifactId: row.id,
153
167
  version: row.version,
154
168
  contentSha256: row.contentSha256,
169
+ ...(config.variants ? { variant: row.variant ?? "decode" } : {}),
155
170
  stagedPath: path.resolve(root, config.stageDir, stagedFileName(row.tool, config.os, config.arch)),
156
171
  bundledName: bundledFileName(row.tool, config.os),
157
172
  ...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(row.tool, config.os)}` } : {}),
@@ -242,6 +257,7 @@ export function buildMediaSidecarReceipt(plan, preSign, signed, { notices = [],
242
257
  tool: file.tool,
243
258
  artifactId: file.artifactId,
244
259
  version: file.version,
260
+ ...(file.variant ? { variant: file.variant } : {}),
245
261
  catalogContentSha256: file.contentSha256,
246
262
  preSignSha256: file.preSignSha256,
247
263
  signedSha256: after.sha256,