@rightkit/release 0.6.7 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -10
- package/admission-files.mjs +1 -1
- package/app-check.mjs +94 -20
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +957 -83
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +2 -1
- package/cargo-contract.mjs +3 -1
- package/cargo-target-shim.mjs +166 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +3 -1
- package/codesign-shim.mjs +11 -1
- package/deterministic-scans.mjs +807 -56
- package/development-signing.mjs +4 -1
- package/development.mjs +110 -20
- package/door-policy.mjs +61 -6
- package/door.mjs +23 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +15 -5
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/ios-dev.mjs +70 -3
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +161 -26
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/native-helpers.mjs +355 -0
- package/package-recipe-targets.mjs +52 -7
- package/package.json +5 -3
- package/pipeline-receipts.mjs +302 -302
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +8 -6
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +73 -5
- package/release-config.mjs +1 -1
- package/release.mjs +15 -5
- package/rightkit-versions.json +13 -7
- package/snapshot-check.mjs +306 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +161 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +856 -0
- package/stage-commands.mjs +60 -0
- package/stage-sidecar.mjs +57 -7
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +259 -0
- package/web-build.mjs +38 -6
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Declarative assembly of a composite desktop app: one definition per platform (`assembly.mac`, `assembly.win` in the
|
|
2
|
+
// release config), shared by `right-release dev` (install in place, install-assembled-app.mjs) and `right-release build`
|
|
3
|
+
// (production packaging through `right-release assemble-app`, assemble-app-command.mjs). Key names, shapes and the Pulse
|
|
4
|
+
// example are in docs/ASSEMBLED-APP.md.
|
|
5
|
+
//
|
|
6
|
+
// This module is the pure half: validation, stage-receipt access, bundle/payload layout, version stamps, bindings and the
|
|
7
|
+
// structural proof of an assembled bundle. Nothing here signs, installs or contacts the network. Every file-system and process
|
|
8
|
+
// effect goes through `deps` (defaults are the real node modules) so tests run against a temp folder with a fake `run`.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import nodeFs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawnSync } from "./background-process.mjs";
|
|
13
|
+
import { resolveTargetRoot } from "./cargo-target.mjs";
|
|
14
|
+
import { validateStageSidecarReceipt } from "./stage-sidecar.mjs";
|
|
15
|
+
|
|
16
|
+
export const ASSEMBLED_APP_KIND = "assembled-app";
|
|
17
|
+
export const DEFAULT_STAGE_RECEIPT = "dist/stage/stage-sidecar.json";
|
|
18
|
+
export const BUNDLE_VERSION_COMMIT_COUNT = "commit-count";
|
|
19
|
+
export const ASSEMBLY_PROFILES = Object.freeze(["dev", "release"]);
|
|
20
|
+
|
|
21
|
+
export class AssembledAppError extends Error {
|
|
22
|
+
constructor(code, message, details = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "AssembledAppError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
Object.assign(this, details);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const refuse = (code, message, details) => { throw new AssembledAppError(code, message, details); };
|
|
30
|
+
const invalid = (message) => refuse("ASSEMBLY_CONFIG_INVALID", message);
|
|
31
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
const sha256Of = (fs, file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
33
|
+
|
|
34
|
+
/** A contained relative path with `/` separators; `where` names the key in errors. */
|
|
35
|
+
export function containedRelative(value, where) {
|
|
36
|
+
if (typeof value !== "string" || !value.trim()) invalid(`${where} must be a non-empty relative path`);
|
|
37
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
38
|
+
if (path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").some((part) => part === ".." || part === "")) {
|
|
39
|
+
invalid(`${where} must be relative, contained and free of empty or ".." segments: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
const nativePath = (base, relative) => path.join(base, ...relative.split("/"));
|
|
44
|
+
const assertKeys = (object, allowed, where) => {
|
|
45
|
+
if (!isObject(object)) invalid(`${where} must be an object`);
|
|
46
|
+
const unknown = Object.keys(object).filter((key) => !allowed.includes(key));
|
|
47
|
+
if (unknown.length) invalid(`${where} has unknown key(s): ${unknown.join(", ")} (allowed: ${allowed.join(", ")})`);
|
|
48
|
+
};
|
|
49
|
+
const list = (value, where) => { if (value === undefined) return []; if (!Array.isArray(value)) invalid(`${where} must be an array`); return value; };
|
|
50
|
+
const stringList = (value, where) => list(value, where).map((item, index) => { if (typeof item !== "string" || !item.trim()) invalid(`${where}[${index}] must be a non-empty string`); return item; });
|
|
51
|
+
const relativeList = (value, where) => stringList(value, where).map((item, index) => containedRelative(item, `${where}[${index}]`));
|
|
52
|
+
|
|
53
|
+
function commandList(value, where) {
|
|
54
|
+
return list(value, where).map((item, index) => {
|
|
55
|
+
const label = `${where}[${index}]`;
|
|
56
|
+
assertKeys(item, ["cmd", "args", "cwd", "env", "timeoutMs"], label);
|
|
57
|
+
if (typeof item.cmd !== "string" || !item.cmd.trim()) invalid(`${label}.cmd must be a non-empty string`);
|
|
58
|
+
if (item.args !== undefined && (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string"))) invalid(`${label}.args must be a list of strings`);
|
|
59
|
+
if (item.env !== undefined && (!isObject(item.env) || Object.values(item.env).some((v) => typeof v !== "string"))) invalid(`${label}.env must map names to strings`);
|
|
60
|
+
return { cmd: item.cmd, args: [...(item.args ?? [])], ...(item.cwd === undefined ? {} : { cwd: containedRelative(item.cwd, `${label}.cwd`) }), ...(item.env ? { env: { ...item.env } } : {}), ...(item.timeoutMs === undefined ? {} : { timeoutMs: item.timeoutMs }) };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function prepareBlock(value, where) {
|
|
64
|
+
if (value === undefined) return { release: [] };
|
|
65
|
+
assertKeys(value, ["release"], where);
|
|
66
|
+
return { release: commandList(value.release, `${where}.release`) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stageEntry = (item, where, { defaultTo }) => {
|
|
70
|
+
const hasStage = item.stage !== undefined;
|
|
71
|
+
const hasProduct = item.product !== undefined;
|
|
72
|
+
const hasFrom = item.from !== undefined;
|
|
73
|
+
if (Number(hasStage) + Number(hasProduct) + Number(hasFrom) !== 1) invalid(`${where} needs exactly one of stage, product${defaultTo ? "" : ", from"}`);
|
|
74
|
+
if (item.kind !== undefined && !(hasStage && ["cargo-bin", "remote", "resource", "native-library"].includes(item.kind))) invalid(`${where}.kind applies to a stage row: cargo-bin, remote, resource or native-library`);
|
|
75
|
+
if (item.optional !== undefined && typeof item.optional !== "boolean") invalid(`${where}.optional must be true or false`);
|
|
76
|
+
if (item.ifStaged !== undefined && (typeof item.ifStaged !== "string" || !item.ifStaged)) invalid(`${where}.ifStaged must be a staged row name`);
|
|
77
|
+
const name = item.stage ?? item.product;
|
|
78
|
+
if (name !== undefined && (typeof name !== "string" || !name.trim() || /[\\/]/.test(name))) invalid(`${where} name must be a plain name`);
|
|
79
|
+
const to = item.to === undefined ? (defaultTo && name ? `${defaultTo}/${name}` : undefined) : item.to;
|
|
80
|
+
return {
|
|
81
|
+
...(hasStage ? { stage: item.stage, kind: item.kind ?? "cargo-bin" } : {}),
|
|
82
|
+
...(hasProduct ? { product: item.product } : {}),
|
|
83
|
+
...(hasFrom ? { from: containedRelative(item.from, `${where}.from`) } : {}),
|
|
84
|
+
to: containedRelative(to, `${where}.to`),
|
|
85
|
+
...(item.optional ? { optional: true } : {}),
|
|
86
|
+
...(item.ifStaged ? { ifStaged: item.ifStaged } : {}),
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const BUNDLE_ID = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
|
|
91
|
+
const COMMON_KEYS = ["productName", "targetTriple", "stageReceipt", "devMarker", "stagingOutput", "bindingFile", "prepare"];
|
|
92
|
+
|
|
93
|
+
/** The normalized `assembly.mac` definition, or an AssembledAppError (ASSEMBLY_CONFIG_INVALID). */
|
|
94
|
+
export function validateMacAssembly(spec) {
|
|
95
|
+
assertKeys(spec, [...COMMON_KEYS, "bundleId", "outer", "nested", "helpers", "plugins", "launchDaemons", "launchAgents", "resources", "bundleVersion", "require"], "assembly.mac");
|
|
96
|
+
const where = "assembly.mac";
|
|
97
|
+
if (typeof spec.productName !== "string" || !spec.productName.trim() || /[\\/]/.test(spec.productName)) invalid(`${where}.productName must be a plain name`);
|
|
98
|
+
if (typeof spec.bundleId !== "string" || !BUNDLE_ID.test(spec.bundleId)) invalid(`${where}.bundleId must be the outer bundle identifier`);
|
|
99
|
+
if (typeof spec.targetTriple !== "string" || !/-apple-darwin$/.test(spec.targetTriple)) invalid(`${where}.targetTriple must be an apple-darwin triple`);
|
|
100
|
+
const outer = spec.outer;
|
|
101
|
+
assertKeys(outer, ["tool", "projectSpec", "project", "scheme", "configuration", "product", "unsetEnv", "extraBuildSettings"], `${where}.outer`);
|
|
102
|
+
if (outer.tool !== "xcodebuild") invalid(`${where}.outer.tool must be "xcodebuild"`);
|
|
103
|
+
if (typeof outer.scheme !== "string" || !outer.scheme) invalid(`${where}.outer.scheme is required`);
|
|
104
|
+
const project = containedRelative(outer.project, `${where}.outer.project`);
|
|
105
|
+
if (!/\.xcodeproj$/.test(project)) invalid(`${where}.outer.project must be an .xcodeproj`);
|
|
106
|
+
const settings = outer.extraBuildSettings ?? {};
|
|
107
|
+
if (!isObject(settings) || Object.entries(settings).some(([k, v]) => !/^[A-Z_][A-Z0-9_]*$/.test(k) || typeof v !== "string" || /^CODE_SIGN/.test(k))) invalid(`${where}.outer.extraBuildSettings must map build setting names (not CODE_SIGN*) to strings`);
|
|
108
|
+
const product = outer.product ?? `${spec.productName}.app`;
|
|
109
|
+
if (!/^[^/\\]+\.app$/.test(product)) invalid(`${where}.outer.product must be an <name>.app file name`);
|
|
110
|
+
const nested = list(spec.nested, `${where}.nested`).map((item, index) => {
|
|
111
|
+
const label = `${where}.nested[${index}]`;
|
|
112
|
+
assertKeys(item, ["built", "manifestPath", "bundleDirectory", "productName", "to"], label);
|
|
113
|
+
if (item.built !== "tauri") invalid(`${label}.built must be "tauri"`);
|
|
114
|
+
if ((item.manifestPath === undefined) === (item.bundleDirectory === undefined)) invalid(`${label} needs exactly one of manifestPath, bundleDirectory`);
|
|
115
|
+
if (typeof item.productName !== "string" || !item.productName) invalid(`${label}.productName is required`);
|
|
116
|
+
const to = containedRelative(item.to, `${label}.to`);
|
|
117
|
+
if (!to.endsWith(".app")) invalid(`${label}.to must end in .app`);
|
|
118
|
+
return { built: "tauri", ...(item.manifestPath ? { manifestPath: containedRelative(item.manifestPath, `${label}.manifestPath`) } : { bundleDirectory: containedRelative(item.bundleDirectory, `${label}.bundleDirectory`) }), productName: item.productName, to };
|
|
119
|
+
});
|
|
120
|
+
const helpers = list(spec.helpers, `${where}.helpers`).map((item, index) => {
|
|
121
|
+
assertKeys(item, ["product", "stage", "kind", "to", "optional", "ifStaged"], `${where}.helpers[${index}]`);
|
|
122
|
+
return stageEntry(item, `${where}.helpers[${index}]`, { defaultTo: "Contents/Helpers" });
|
|
123
|
+
});
|
|
124
|
+
const plist = (value, kind) => list(value, `${where}.${kind}`).map((item, index) => {
|
|
125
|
+
const label = `${where}.${kind}[${index}]`;
|
|
126
|
+
assertKeys(item, ["from", "to"], label);
|
|
127
|
+
const from = containedRelative(item.from, `${label}.from`);
|
|
128
|
+
return { from, to: containedRelative(item.to ?? `Contents/Library/${kind === "launchDaemons" ? "LaunchDaemons" : "LaunchAgents"}/${path.posix.basename(from)}`, `${label}.to`) };
|
|
129
|
+
});
|
|
130
|
+
const resources = list(spec.resources, `${where}.resources`).map((item, index) => {
|
|
131
|
+
const label = `${where}.resources[${index}]`;
|
|
132
|
+
assertKeys(item, ["from", "to", "ifStaged"], label);
|
|
133
|
+
return { from: containedRelative(item.from, `${label}.from`), to: containedRelative(item.to, `${label}.to`), ...(item.ifStaged ? { ifStaged: String(item.ifStaged) } : {}) };
|
|
134
|
+
});
|
|
135
|
+
if (spec.bundleVersion !== undefined && spec.bundleVersion !== BUNDLE_VERSION_COMMIT_COUNT) invalid(`${where}.bundleVersion must be "${BUNDLE_VERSION_COMMIT_COUNT}" or unset`);
|
|
136
|
+
const plugins = stringList(spec.plugins, `${where}.plugins`);
|
|
137
|
+
if (plugins.some((name) => /[\\/]/.test(name))) invalid(`${where}.plugins entries are plain file names inside Contents/PlugIns`);
|
|
138
|
+
return {
|
|
139
|
+
platform: "mac", productName: spec.productName, bundleId: spec.bundleId, targetTriple: spec.targetTriple,
|
|
140
|
+
stageReceipt: containedRelative(spec.stageReceipt ?? DEFAULT_STAGE_RECEIPT, `${where}.stageReceipt`),
|
|
141
|
+
outer: { tool: "xcodebuild", ...(outer.projectSpec ? { projectSpec: containedRelative(outer.projectSpec, `${where}.outer.projectSpec`) } : {}), project, scheme: outer.scheme, configuration: outer.configuration ?? "Release", product, unsetEnv: stringList(outer.unsetEnv, `${where}.outer.unsetEnv`), extraBuildSettings: { ...settings } },
|
|
142
|
+
nested, helpers, plugins, launchDaemons: plist(spec.launchDaemons, "launchDaemons"), launchAgents: plist(spec.launchAgents, "launchAgents"), resources,
|
|
143
|
+
bundleVersion: spec.bundleVersion, require: relativeList(spec.require, `${where}.require`),
|
|
144
|
+
...(spec.devMarker === undefined ? {} : { devMarker: containedRelative(spec.devMarker, `${where}.devMarker`) }),
|
|
145
|
+
stagingOutput: containedRelative(spec.stagingOutput ?? `dist/staging/${spec.productName}.app`, `${where}.stagingOutput`),
|
|
146
|
+
bindingFile: containedRelative(spec.bindingFile ?? `${path.posix.dirname(spec.stagingOutput ?? `dist/staging/${spec.productName}.app`)}/payload-binding.json`, `${where}.bindingFile`),
|
|
147
|
+
prepare: prepareBlock(spec.prepare, `${where}.prepare`),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The normalized `assembly.win` definition. */
|
|
152
|
+
export function validateWindowsAssembly(spec) {
|
|
153
|
+
assertKeys(spec, [...COMMON_KEYS, "payload", "sign", "nsis"], "assembly.win");
|
|
154
|
+
const where = "assembly.win";
|
|
155
|
+
if (typeof spec.productName !== "string" || !spec.productName.trim() || /[\\/]/.test(spec.productName)) invalid(`${where}.productName must be a plain name`);
|
|
156
|
+
if (typeof spec.targetTriple !== "string" || !/-pc-windows-/.test(spec.targetTriple)) invalid(`${where}.targetTriple must be a windows triple`);
|
|
157
|
+
assertKeys(spec.payload, ["files", "rewrites", "require"], `${where}.payload`);
|
|
158
|
+
const files = list(spec.payload.files, `${where}.payload.files`).map((item, index) => {
|
|
159
|
+
assertKeys(item, ["stage", "kind", "from", "to", "optional", "ifStaged"], `${where}.payload.files[${index}]`);
|
|
160
|
+
return stageEntry(item, `${where}.payload.files[${index}]`, { defaultTo: null });
|
|
161
|
+
});
|
|
162
|
+
if (!files.length) invalid(`${where}.payload.files must not be empty`);
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const file of files) { const key = file.to.toLowerCase(); if (seen.has(key) && !file.from) invalid(`${where}.payload.files lists ${file.to} twice (Windows paths are case-insensitive)`); seen.add(key); }
|
|
165
|
+
const rewrites = list(spec.payload.rewrites, `${where}.payload.rewrites`).map((item, index) => {
|
|
166
|
+
const label = `${where}.payload.rewrites[${index}]`;
|
|
167
|
+
assertKeys(item, ["file", "regex", "flags", "replacement", "requireMatch"], label);
|
|
168
|
+
if (typeof item.regex !== "string" || typeof item.replacement !== "string") invalid(`${label} needs regex and replacement strings`);
|
|
169
|
+
try { new RegExp(item.regex, item.flags ?? ""); } catch (error) { invalid(`${label}.regex is not a valid expression: ${error.message}`); }
|
|
170
|
+
return { file: containedRelative(item.file, `${label}.file`), regex: item.regex, flags: item.flags ?? "", replacement: item.replacement, requireMatch: item.requireMatch !== false };
|
|
171
|
+
});
|
|
172
|
+
let nsis;
|
|
173
|
+
if (spec.nsis !== undefined) {
|
|
174
|
+
assertKeys(spec.nsis, ["script", "output", "defines"], `${where}.nsis`);
|
|
175
|
+
const defines = spec.nsis.defines ?? {};
|
|
176
|
+
if (!isObject(defines) || Object.entries(defines).some(([k, v]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || typeof v !== "string")) invalid(`${where}.nsis.defines must map names to strings`);
|
|
177
|
+
nsis = { script: containedRelative(spec.nsis.script, `${where}.nsis.script`), output: containedRelative(spec.nsis.output, `${where}.nsis.output`), defines: { ...defines } };
|
|
178
|
+
}
|
|
179
|
+
const stagingOutput = containedRelative(spec.stagingOutput ?? `dist/staging/windows/${spec.productName}`, `${where}.stagingOutput`);
|
|
180
|
+
return {
|
|
181
|
+
platform: "win", productName: spec.productName, targetTriple: spec.targetTriple,
|
|
182
|
+
stageReceipt: containedRelative(spec.stageReceipt ?? DEFAULT_STAGE_RECEIPT, `${where}.stageReceipt`),
|
|
183
|
+
payload: { files, rewrites, require: relativeList(spec.payload.require, `${where}.payload.require`) },
|
|
184
|
+
sign: relativeList(spec.sign, `${where}.sign`),
|
|
185
|
+
...(spec.devMarker === undefined ? {} : { devMarker: containedRelative(spec.devMarker, `${where}.devMarker`) }),
|
|
186
|
+
stagingOutput, bindingFile: containedRelative(spec.bindingFile ?? `${path.posix.dirname(stagingOutput)}/payload-binding.json`, `${where}.bindingFile`),
|
|
187
|
+
prepare: prepareBlock(spec.prepare, `${where}.prepare`), ...(nsis ? { nsis } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `config.assembly.<platform>` validated. Throws when the block is absent. */
|
|
192
|
+
export function resolveAssembly(config, platform) {
|
|
193
|
+
const spec = config?.assembly?.[platform];
|
|
194
|
+
if (spec === undefined) invalid(`the release config declares no assembly.${platform}`);
|
|
195
|
+
if (platform === "mac") return validateMacAssembly(spec);
|
|
196
|
+
if (platform === "win") return validateWindowsAssembly(spec);
|
|
197
|
+
return invalid(`assembly.${platform} is not supported (mac or win)`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- stage receipt -----------------------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The stage-sidecar receipt of this assembly, validated for its target. `find(name, kind)` returns `{ file, sha256 }` for a row
|
|
204
|
+
* and re-hashes the staged file against the receipt (fails closed on a missing, other-profile or changed file).
|
|
205
|
+
*/
|
|
206
|
+
export function loadStageReceipt({ root, spec, profile, fs = nodeFs }) {
|
|
207
|
+
const receiptFile = path.resolve(root, spec.stageReceipt);
|
|
208
|
+
let receipt;
|
|
209
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptFile, "utf8")); }
|
|
210
|
+
catch (error) { refuse("ASSEMBLY_RECEIPT_MISSING", `stage receipt unreadable: ${receiptFile} (${error.code ?? error.message}); run right-release stage-sidecar --target ${spec.targetTriple} first`); }
|
|
211
|
+
try { validateStageSidecarReceipt(receipt, { target: spec.targetTriple }); }
|
|
212
|
+
catch (error) { refuse("ASSEMBLY_RECEIPT_INVALID", `${receiptFile}: ${error.message}`); }
|
|
213
|
+
return {
|
|
214
|
+
file: receiptFile,
|
|
215
|
+
has: (name) => receipt.files.some((row) => row.name === name),
|
|
216
|
+
find(name, kind = "cargo-bin") {
|
|
217
|
+
const row = receipt.files.find((candidate) => candidate.kind === kind && candidate.name === name);
|
|
218
|
+
if (!row) refuse("ASSEMBLY_RECEIPT_ROW_MISSING", `stage receipt has no ${kind} row ${name}`);
|
|
219
|
+
if (kind === "cargo-bin" && row.profile !== profile) refuse("ASSEMBLY_RECEIPT_PROFILE", `${name} was staged with Cargo profile ${row.profile}; this ${profile} assembly needs ${profile}`);
|
|
220
|
+
const file = path.resolve(root, row.path);
|
|
221
|
+
let actual;
|
|
222
|
+
try { actual = sha256Of(fs, file); } catch (error) { refuse("ASSEMBLY_RECEIPT_FILE_MISSING", `staged ${name} is missing: ${file} (${error.code ?? error.message})`); }
|
|
223
|
+
if (actual !== row.sha256) refuse("ASSEMBLY_RECEIPT_HASH", `staged ${name} differs from the stage receipt (${actual} vs ${row.sha256}); run stage-sidecar again`);
|
|
224
|
+
return { file, sha256: row.sha256 };
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- layout ------------------------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function copyFileTo(fs, source, target, label, { executable = false } = {}) {
|
|
232
|
+
let info;
|
|
233
|
+
try { info = fs.statSync(source); } catch (error) { refuse("ASSEMBLY_SOURCE_MISSING", `${label} is missing: ${source} (${error.code ?? error.message})`); }
|
|
234
|
+
if (!info.isFile()) refuse("ASSEMBLY_SOURCE_INVALID", `${label} is not a regular file: ${source}`);
|
|
235
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
236
|
+
fs.copyFileSync(source, target);
|
|
237
|
+
if (executable) fs.chmodSync(target, 0o755);
|
|
238
|
+
}
|
|
239
|
+
function copyPathTo(fs, source, target, label) {
|
|
240
|
+
let info;
|
|
241
|
+
try { info = fs.statSync(source); } catch (error) { refuse("ASSEMBLY_SOURCE_MISSING", `${label} is missing: ${source} (${error.code ?? error.message})`); }
|
|
242
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
243
|
+
if (info.isDirectory()) fs.cpSync(source, target, { recursive: true, force: true, verbatimSymlinks: true });
|
|
244
|
+
else copyFileTo(fs, source, target, label);
|
|
245
|
+
}
|
|
246
|
+
function copyBuiltApp(fs, source, target, label) {
|
|
247
|
+
let info;
|
|
248
|
+
try { info = fs.statSync(source); } catch (error) { refuse("ASSEMBLY_SOURCE_MISSING", `${label} is missing: ${source} (${error.code ?? error.message})`); }
|
|
249
|
+
if (!info.isDirectory()) refuse("ASSEMBLY_SOURCE_INVALID", `${label} is not an app bundle: ${source}`);
|
|
250
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
251
|
+
fs.cpSync(source, target, { recursive: true, force: true, verbatimSymlinks: true });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The marker/binding document: receipt hashes of every staged file placed in the output. */
|
|
255
|
+
export function bindingDocument({ profile, targetTriple, bound }) {
|
|
256
|
+
return { schema: 1, profile, devOnly: profile !== "release", target: targetTriple, files: Object.fromEntries(bound.map(({ dest, sha256 }) => [dest, sha256])) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function profileDirectory(profile) { return profile === "release" ? "release" : "debug"; }
|
|
260
|
+
|
|
261
|
+
/** The built Tauri .app of one `nested` entry (Cargo's own target directory; the product name is the bundle name). */
|
|
262
|
+
export function resolveNestedApp({ root, entry, spec, profile, deps = {} }) {
|
|
263
|
+
const fs = deps.fs ?? nodeFs;
|
|
264
|
+
const base = entry.bundleDirectory
|
|
265
|
+
? path.resolve(root, entry.bundleDirectory)
|
|
266
|
+
: path.join((deps.resolveTargetRoot ?? resolveTargetRoot)(path.resolve(root, entry.manifestPath)), spec.targetTriple, profileDirectory(profile), "bundle", "macos");
|
|
267
|
+
const app = path.join(base, `${entry.productName}.app`);
|
|
268
|
+
if (!fs.existsSync(app)) refuse("ASSEMBLY_SOURCE_MISSING", `built Tauri app not found: ${app}`);
|
|
269
|
+
return app;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** CFBundleVersion: the commit count of the source revision. A shallow clone is refused (no network, so no unshallow). */
|
|
273
|
+
export function commitCount({ root, env = process.env, run = spawnSync }) {
|
|
274
|
+
const git = (args) => {
|
|
275
|
+
const result = run("git", args, { cwd: root, env, encoding: "utf8", windowsHide: true, shell: false });
|
|
276
|
+
if (result.error || result.status !== 0) refuse("ASSEMBLY_VERSION_UNAVAILABLE", `git ${args.join(" ")} failed (${result.status ?? result.error?.message})`);
|
|
277
|
+
return String(result.stdout ?? "").trim();
|
|
278
|
+
};
|
|
279
|
+
if (git(["rev-parse", "--is-shallow-repository"]) === "true") refuse("ASSEMBLY_VERSION_UNAVAILABLE", "the repository is shallow; CFBundleVersion needs the full history (fetch it; this tool contacts no network)");
|
|
280
|
+
const count = git(["rev-list", "--count", env.RIGHT_GIT_SOURCE_REVISION || "HEAD"]);
|
|
281
|
+
if (!/^[1-9][0-9]*$/.test(count)) refuse("ASSEMBLY_VERSION_UNAVAILABLE", `could not derive a build number: ${count}`);
|
|
282
|
+
return count;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function stampBundleVersion({ bundle, build, env, run }) {
|
|
286
|
+
const plist = path.join(bundle, "Contents", "Info.plist");
|
|
287
|
+
const buddy = (command) => run("/usr/libexec/PlistBuddy", ["-c", command, plist], { env, encoding: "utf8", windowsHide: true, shell: false });
|
|
288
|
+
const set = buddy(`Set :CFBundleVersion ${build}`);
|
|
289
|
+
if (set.status === 0) return;
|
|
290
|
+
const add = buddy(`Add :CFBundleVersion string ${build}`);
|
|
291
|
+
if (add.status !== 0) refuse("ASSEMBLY_VERSION_FAILED", `could not set CFBundleVersion in ${plist}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Lay out the unsigned macOS bundle at `out` from the xcodebuild product (`outerApp`), the built nested apps and the staged
|
|
296
|
+
* rows. Returns `{ bundle, bound }`; `bound` lists every receipt-staged file placed (bundle-relative dest, receipt sha256),
|
|
297
|
+
* each re-hashed after the copy. Does not sign.
|
|
298
|
+
*/
|
|
299
|
+
export function assembleMacBundle({ root, spec, profile, out, outerApp, nestedApps = [], receipt, env = process.env, deps = {} }) {
|
|
300
|
+
const fs = deps.fs ?? nodeFs;
|
|
301
|
+
const run = deps.run ?? spawnSync;
|
|
302
|
+
fs.rmSync(out, { recursive: true, force: true });
|
|
303
|
+
copyBuiltApp(fs, outerApp, out, "outer app (xcodebuild)");
|
|
304
|
+
const products = path.dirname(outerApp);
|
|
305
|
+
const bound = [];
|
|
306
|
+
const place = (dest, staged) => {
|
|
307
|
+
const actual = sha256Of(fs, nativePath(out, dest));
|
|
308
|
+
if (actual !== staged.sha256) refuse("ASSEMBLY_BOUND_HASH", `${dest} differs from its stage receipt row after the copy (${actual} vs ${staged.sha256})`);
|
|
309
|
+
bound.push({ dest, sha256: actual });
|
|
310
|
+
};
|
|
311
|
+
for (const name of spec.plugins) {
|
|
312
|
+
if (!fs.existsSync(nativePath(out, `Contents/PlugIns/${name}`))) refuse("ASSEMBLY_SOURCE_MISSING", `plug-in ${name} is not in Contents/PlugIns of the xcodebuild product`);
|
|
313
|
+
}
|
|
314
|
+
spec.nested.forEach((entry, index) => copyBuiltApp(fs, nestedApps[index], nativePath(out, entry.to), `nested app ${entry.productName}`));
|
|
315
|
+
for (const helper of spec.helpers) {
|
|
316
|
+
if (helper.ifStaged && !receipt.has(helper.ifStaged)) continue;
|
|
317
|
+
if (helper.product) { copyFileTo(fs, path.join(products, helper.product), nativePath(out, helper.to), `helper ${helper.product} (xcodebuild product)`, { executable: true }); continue; }
|
|
318
|
+
if (helper.optional && !receipt.has(helper.stage)) { (deps.log ?? console.log)(`assemble-app: optional ${helper.stage} is not staged; skipped`); continue; }
|
|
319
|
+
const staged = receipt.find(helper.stage, helper.kind);
|
|
320
|
+
copyFileTo(fs, staged.file, nativePath(out, helper.to), `helper ${helper.stage}`, { executable: true });
|
|
321
|
+
place(helper.to, staged);
|
|
322
|
+
}
|
|
323
|
+
for (const item of [...spec.launchDaemons, ...spec.launchAgents]) copyFileTo(fs, path.resolve(root, item.from), nativePath(out, item.to), `launchd plist ${item.from}`);
|
|
324
|
+
for (const item of spec.resources) {
|
|
325
|
+
if (item.ifStaged && !receipt.has(item.ifStaged)) continue;
|
|
326
|
+
copyPathTo(fs, path.resolve(root, item.from), nativePath(out, item.to), `resource ${item.from}`);
|
|
327
|
+
}
|
|
328
|
+
if (spec.bundleVersion === BUNDLE_VERSION_COMMIT_COUNT) {
|
|
329
|
+
const build = (deps.commitCount ?? commitCount)({ root, env, run });
|
|
330
|
+
for (const bundle of [out, ...spec.nested.map((entry) => nativePath(out, entry.to)), ...spec.plugins.map((name) => nativePath(out, `Contents/PlugIns/${name}`))]) stampBundleVersion({ bundle, build, env, run });
|
|
331
|
+
(deps.log ?? console.log)(`assemble-app: CFBundleVersion ${build}`);
|
|
332
|
+
}
|
|
333
|
+
if (profile === "dev" && spec.devMarker) {
|
|
334
|
+
const marker = nativePath(out, spec.devMarker);
|
|
335
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
336
|
+
fs.writeFileSync(marker, `${JSON.stringify(bindingDocument({ profile, targetTriple: spec.targetTriple, bound }), null, 2)}\n`);
|
|
337
|
+
}
|
|
338
|
+
for (const file of spec.require) if (!fs.existsSync(nativePath(out, file))) refuse("ASSEMBLY_REQUIRED_MISSING", `assembled bundle lacks ${file}`);
|
|
339
|
+
return { bundle: out, bound };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The structural proof of an assembled (and optionally signed) bundle, read-only: required files, Info.plist identity,
|
|
344
|
+
* launchd `BundleProgram` paths, and (`signed`) `/usr/bin/codesign --verify --strict --deep`. A release bundle must carry no dev marker.
|
|
345
|
+
* Throws AssembledAppError (ASSEMBLY_VERIFY_FAILED) listing every problem.
|
|
346
|
+
*/
|
|
347
|
+
export function verifyMacBundle({ root, spec, bundle, profile, signed = false, env = process.env, deps = {} }) {
|
|
348
|
+
const fs = deps.fs ?? nodeFs;
|
|
349
|
+
const run = deps.run ?? spawnSync;
|
|
350
|
+
const problems = [];
|
|
351
|
+
const capture = (command, args) => run(command, args, { cwd: root, env, encoding: "utf8", windowsHide: true, shell: false });
|
|
352
|
+
for (const file of spec.require) if (!fs.existsSync(nativePath(bundle, file))) problems.push(`missing ${file}`);
|
|
353
|
+
const id = capture("/usr/bin/plutil", ["-extract", "CFBundleIdentifier", "raw", "-o", "-", path.join(bundle, "Contents", "Info.plist")]);
|
|
354
|
+
const actualId = id.error || id.status !== 0 ? null : String(id.stdout ?? "").trim();
|
|
355
|
+
if (actualId !== spec.bundleId) problems.push(`CFBundleIdentifier ${actualId === null ? "unreadable" : JSON.stringify(actualId)} is not ${spec.bundleId}`);
|
|
356
|
+
for (const item of [...spec.launchDaemons, ...spec.launchAgents]) {
|
|
357
|
+
const file = nativePath(bundle, item.to);
|
|
358
|
+
const program = capture("/usr/bin/plutil", ["-extract", "BundleProgram", "raw", "-o", "-", file]);
|
|
359
|
+
if (program.error || program.status !== 0) continue; // a plist may name ProgramArguments instead
|
|
360
|
+
const value = String(program.stdout ?? "").trim();
|
|
361
|
+
if (!value || value.startsWith("/") || value.split("/").includes("..")) problems.push(`${item.to}: BundleProgram ${JSON.stringify(value)} must be a relative path inside the bundle`);
|
|
362
|
+
else if (!fs.existsSync(nativePath(bundle, value))) problems.push(`${item.to}: BundleProgram ${value} is not in the bundle`);
|
|
363
|
+
}
|
|
364
|
+
if (profile === "release" && spec.devMarker && fs.existsSync(nativePath(bundle, spec.devMarker))) problems.push(`${spec.devMarker} marks a dev-only payload; it must not be signed or shipped for release`);
|
|
365
|
+
if (signed) {
|
|
366
|
+
const strict = capture("/usr/bin/codesign", ["--verify", "--strict", "--deep", bundle]);
|
|
367
|
+
if (strict.error || strict.status !== 0) problems.push(`codesign --verify --strict --deep failed: ${strict.error?.message ?? (`${strict.stdout ?? ""}${strict.stderr ?? ""}`.trim() || `exit ${strict.status}`)}`);
|
|
368
|
+
}
|
|
369
|
+
if (problems.length) refuse("ASSEMBLY_VERIFY_FAILED", `${bundle}: ${problems.length} problem(s): ${problems.join("; ")}`, { problems });
|
|
370
|
+
return { checked: spec.require.length };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- Windows payload ----------------------------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
/** Lay out the unsigned Windows payload at `out`. Returns `{ payload, bound }`. */
|
|
376
|
+
export function assembleWindowsPayload({ root, spec, profile, out, receipt, deps = {} }) {
|
|
377
|
+
const fs = deps.fs ?? nodeFs;
|
|
378
|
+
fs.rmSync(out, { recursive: true, force: true });
|
|
379
|
+
fs.mkdirSync(out, { recursive: true });
|
|
380
|
+
const bound = [];
|
|
381
|
+
// Directory copies first, then single files, so a file row may overwrite one inside a copied folder (the Muse hook launchers).
|
|
382
|
+
const ordered = [...spec.payload.files.filter((item) => item.from), ...spec.payload.files.filter((item) => !item.from)];
|
|
383
|
+
for (const item of ordered) {
|
|
384
|
+
if (item.ifStaged && !receipt.has(item.ifStaged)) continue;
|
|
385
|
+
const target = nativePath(out, item.to);
|
|
386
|
+
if (item.from) { copyPathTo(fs, path.resolve(root, item.from), target, `payload ${item.from}`); continue; }
|
|
387
|
+
if (item.optional && !receipt.has(item.stage)) { (deps.log ?? console.log)(`assemble-app: optional ${item.stage} is not staged; skipped`); continue; }
|
|
388
|
+
const staged = receipt.find(item.stage, item.kind);
|
|
389
|
+
copyFileTo(fs, staged.file, target, `payload ${item.stage}`);
|
|
390
|
+
const actual = sha256Of(fs, target);
|
|
391
|
+
if (actual !== staged.sha256) refuse("ASSEMBLY_BOUND_HASH", `${item.to} differs from its stage receipt row after the copy (${actual} vs ${staged.sha256})`);
|
|
392
|
+
bound.push({ dest: item.to, sha256: actual });
|
|
393
|
+
}
|
|
394
|
+
for (const rewrite of spec.payload.rewrites) {
|
|
395
|
+
const file = nativePath(out, rewrite.file);
|
|
396
|
+
let text;
|
|
397
|
+
try { text = fs.readFileSync(file, "utf8"); } catch (error) { refuse("ASSEMBLY_SOURCE_MISSING", `rewrite target ${rewrite.file} is missing (${error.code ?? error.message})`); }
|
|
398
|
+
const pattern = new RegExp(rewrite.regex, rewrite.flags);
|
|
399
|
+
if (!pattern.test(text) && rewrite.requireMatch) refuse("ASSEMBLY_REWRITE_NO_MATCH", `${rewrite.file}: nothing matched ${rewrite.regex}`);
|
|
400
|
+
fs.writeFileSync(file, text.replace(new RegExp(rewrite.regex, rewrite.flags), rewrite.replacement));
|
|
401
|
+
}
|
|
402
|
+
if (profile === "dev" && spec.devMarker) fs.writeFileSync(nativePath(out, spec.devMarker), `${JSON.stringify(bindingDocument({ profile, targetTriple: spec.targetTriple, bound }), null, 2)}\n`);
|
|
403
|
+
for (const file of spec.payload.require) if (!fs.existsSync(nativePath(out, file))) refuse("ASSEMBLY_REQUIRED_MISSING", `assembled payload lacks ${file}`);
|
|
404
|
+
for (const file of spec.sign) if (!fs.existsSync(nativePath(out, file))) refuse("ASSEMBLY_REQUIRED_MISSING", `assembled payload lacks the file to sign: ${file}`);
|
|
405
|
+
return { payload: out, bound };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The release binding file (outside the signed output). Refuses to write for a dev profile. */
|
|
409
|
+
export function writeReleaseBinding({ root, spec, bound, fs = nodeFs }) {
|
|
410
|
+
const file = path.resolve(root, spec.bindingFile);
|
|
411
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
412
|
+
fs.writeFileSync(file, `${JSON.stringify(bindingDocument({ profile: "release", targetTriple: spec.targetTriple, bound }), null, 2)}\n`);
|
|
413
|
+
return file;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A release payload needs its binding: present, `devOnly: false`, profile release; `rehash` (before signing) re-reads every bound file.
|
|
418
|
+
* After signing the bytes change by design, so later steps pass rehash false.
|
|
419
|
+
*/
|
|
420
|
+
export function verifyReleaseBinding({ root, spec, output, rehash = false, fs = nodeFs }) {
|
|
421
|
+
const file = path.resolve(root, spec.bindingFile);
|
|
422
|
+
let binding;
|
|
423
|
+
try { binding = JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { refuse("ASSEMBLY_BINDING_MISSING", `payload binding unreadable: ${file} (${error.code ?? error.message}); run assemble-app --profile release first`); }
|
|
424
|
+
if (binding.devOnly !== false || binding.profile !== "release") refuse("ASSEMBLY_BINDING_DEV", `${file} marks this payload ${binding.profile ?? "unknown"}-only; only a release assembly may be signed for release`);
|
|
425
|
+
if (rehash) {
|
|
426
|
+
for (const [dest, expected] of Object.entries(binding.files ?? {})) {
|
|
427
|
+
let actual;
|
|
428
|
+
try { actual = sha256Of(fs, nativePath(output, dest)); } catch (error) { refuse("ASSEMBLY_BINDING_FILE", `bound file is missing: ${dest} (${error.code ?? error.message})`); }
|
|
429
|
+
if (actual !== expected) refuse("ASSEMBLY_BINDING_HASH", `bound file ${dest} changed after the receipt check (${actual} vs ${expected})`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return binding;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The structural proof of an assembled Windows payload (required files; release: no dev marker). */
|
|
436
|
+
export function verifyWindowsPayload({ spec, payload, profile, fs = nodeFs }) {
|
|
437
|
+
const problems = [];
|
|
438
|
+
for (const file of spec.payload.require) if (!fs.existsSync(nativePath(payload, file))) problems.push(`missing ${file}`);
|
|
439
|
+
for (const file of spec.sign) if (!fs.existsSync(nativePath(payload, file))) problems.push(`missing ${file}`);
|
|
440
|
+
if (profile === "release" && spec.devMarker && fs.existsSync(nativePath(payload, spec.devMarker))) problems.push(`${spec.devMarker} marks a dev-only payload`);
|
|
441
|
+
if (problems.length) refuse("ASSEMBLY_VERIFY_FAILED", `${payload}: ${problems.length} problem(s): ${problems.join("; ")}`, { problems });
|
|
442
|
+
return { checked: spec.payload.require.length + spec.sign.length };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export const platformPath = nativePath;
|
package/bin/darwin/rightkit-swap
CHANGED
|
Binary file
|