@rightkit/release 0.6.13 → 0.6.14
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 +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1190 -337
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +109 -24
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +151 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +14 -10
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
// The signing policy of a gate-run test or dev build (rightkit#1303, GATE-CONTRACT "One signing identity per product, every build"). Pure: it reads
|
|
2
|
+
// nothing but what its caller hands it, so the gate's route decision (rightkit-gate.mjs routePlan, from the commit's own bytes), the `sign` step
|
|
3
|
+
// (suite-signing.mjs, from the snapshot) and the tests all judge the same text.
|
|
4
|
+
//
|
|
5
|
+
// - The identity is derived, never declared: the app's tauri.conf.json `identifier` is the one id. Mac install.bundleId must equal it, and a
|
|
6
|
+
// dev or test Tauri `--config` that overrides it is refused. The team is DEFAULT_TEAM, the signer subject WINDOWS_SIGNER_SUBJECT.
|
|
7
|
+
// - A suite that builds a product (tauri build/bundle, or a cargo build of the tauri package or a sidecar package) must be named by a later `sign`
|
|
8
|
+
// step; a Windows dev `install.kind: "tauri"` is refused (`tauri-authenticode` is the signed route); `sign-qa-bundle` and RIGHTKIT_DEV_SIGNING
|
|
9
|
+
// (ad hoc) are refused.
|
|
10
|
+
// - These refusals apply on Mac and Dell only (process.platform darwin or win32). Linux builds sign nothing and hosted CI is exempt.
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { DEFAULT_TEAM, WINDOWS_SIGNER_SUBJECT } from "./signing-identity.mjs";
|
|
14
|
+
|
|
15
|
+
export const SIGN_TEAM = DEFAULT_TEAM;
|
|
16
|
+
export const SIGN_SUBJECT = WINDOWS_SIGNER_SUBJECT;
|
|
17
|
+
/** The environment variable a successful `sign` step sets for the suites after it: the signed copy of the first file. */
|
|
18
|
+
export const QA_APP_BINARY_ENV = "RIGHTKIT_QA_APP_BINARY";
|
|
19
|
+
/** The environment variable a successful `sign` step sets beside it: the path of the signing evidence file (signed files, sha256 before and after, identity). */
|
|
20
|
+
export const QA_SIGNING_EVIDENCE_ENV = "RIGHTKIT_QA_SIGNING_EVIDENCE";
|
|
21
|
+
/** GB-42: set beside the two above: a JSON object mapping each file a `sign` step signed (its `path`, relative to the output layout) to its signed copy, so a
|
|
22
|
+
* consumer is handed every declared sidecar's signed bytes, not only the main binary's. */
|
|
23
|
+
export const QA_SIGNED_FILES_ENV = "RIGHTKIT_QA_SIGNED_FILES";
|
|
24
|
+
export const SIGN_STEP_KEYS = Object.freeze(["name", "sign"]);
|
|
25
|
+
export const WINDOWS_TAURI_KIND = "tauri";
|
|
26
|
+
export const WINDOWS_SIGNED_KIND = "tauri-authenticode";
|
|
27
|
+
|
|
28
|
+
// The package's TOML parser (smol-toml, a dependency of this package), loaded on first use: the installed gate must start, and run its checks, in an app
|
|
29
|
+
// with no node_modules, so it is never imported at module load. Where it cannot be loaded, manifests read as unparsed and the policy stays conservative.
|
|
30
|
+
let tomlParser;
|
|
31
|
+
function parseToml(text) {
|
|
32
|
+
if (tomlParser === undefined) { try { tomlParser = createRequire(import.meta.url)("smol-toml").parse; } catch { tomlParser = null; } }
|
|
33
|
+
if (tomlParser === null) return undefined;
|
|
34
|
+
return tomlParser(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SuiteSigningError extends Error {
|
|
38
|
+
constructor(code, message, details = {}) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "SuiteSigningError";
|
|
41
|
+
this.code = code;
|
|
42
|
+
Object.assign(this, details);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const refuse = (code, message, details) => { throw new SuiteSigningError(code, message, details); };
|
|
46
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
47
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
48
|
+
const SIGNING_ID = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
|
|
49
|
+
|
|
50
|
+
/** A relative path with no `..`, no drive, no root: the form a sign file path takes inside a receipt's output directory. Returns it with `/`. */
|
|
51
|
+
export function containedRelative(value, label) {
|
|
52
|
+
if (typeof value !== "string" || !value.trim()) refuse("SIGN_STEP_PATH", `${label} must be a non-empty relative path`);
|
|
53
|
+
const normalized = value.replaceAll("\\", "/");
|
|
54
|
+
if (path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.startsWith("/") || normalized.split("/").includes("..") || normalized.split("/").includes("")) {
|
|
55
|
+
refuse("SIGN_STEP_PATH", `${label} must be a relative path inside the earlier suite's output directory (no absolute path, no .., no empty segment): ${JSON.stringify(value)}`);
|
|
56
|
+
}
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate one `sign` spec: `{ files: [{ from, path }] }`. `from` names an EARLIER suite of the same list (`earlier` is the set of names declared
|
|
62
|
+
* before this step), `path` is relative to that suite's carried-receipt output directory. Returns the spec with normalized paths.
|
|
63
|
+
*/
|
|
64
|
+
export function validateSignSpec(sign, label, earlier) {
|
|
65
|
+
if (!isObject(sign)) refuse("SIGN_STEP_SHAPE", `${label} must be an object { files: [{ from, path }] }`);
|
|
66
|
+
const unknown = Object.keys(sign).filter((key) => key !== "files");
|
|
67
|
+
if (unknown.length) refuse("SIGN_STEP_SHAPE", `${label} has unknown key(s): ${unknown.join(", ")} (allowed: files). The identity is derived from the product, never declared here`);
|
|
68
|
+
if (!Array.isArray(sign.files) || sign.files.length === 0) refuse("SIGN_STEP_SHAPE", `${label}.files must be a non-empty list of { from, path }`);
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const files = sign.files.map((item, index) => {
|
|
71
|
+
const at = `${label}.files[${index}]`;
|
|
72
|
+
if (!isObject(item)) refuse("SIGN_STEP_SHAPE", `${at} must be { from, path }`);
|
|
73
|
+
const extra = Object.keys(item).filter((key) => key !== "from" && key !== "path");
|
|
74
|
+
if (extra.length) refuse("SIGN_STEP_SHAPE", `${at} has unknown key(s): ${extra.join(", ")} (allowed: from, path)`);
|
|
75
|
+
if (typeof item.from !== "string" || !NAME.test(item.from)) refuse("SIGN_STEP_FROM", `${at}.from must name an earlier suite`);
|
|
76
|
+
if (!earlier.has(item.from)) refuse("SIGN_STEP_FROM", `${at}.from ${JSON.stringify(item.from)} is not a suite declared before this step (${[...earlier].join(", ") || "none"}); a sign step signs what an earlier suite built`);
|
|
77
|
+
const rel = containedRelative(item.path, `${at}.path`);
|
|
78
|
+
const key = `${item.from}\0${rel}`;
|
|
79
|
+
if (seen.has(key)) refuse("SIGN_STEP_SHAPE", `${at} repeats ${item.from}:${rel}`);
|
|
80
|
+
seen.add(key);
|
|
81
|
+
return { from: item.from, path: rel };
|
|
82
|
+
});
|
|
83
|
+
return { files };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- what a step is -------------------------------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const base = (program) => path.basename(String(program ?? "").replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "").toLowerCase();
|
|
89
|
+
/** A config word as text: a string, or a typed `{ "$json": value }` template (the raw config a commit holds) as the JSON it expands to. */
|
|
90
|
+
const wordText = (word) => (typeof word === "string" ? word : isObject(word) && Object.hasOwn(word, "$json") ? JSON.stringify(word.$json) : JSON.stringify(word));
|
|
91
|
+
|
|
92
|
+
/** The words a step runs: its program, then its arguments, as text. */
|
|
93
|
+
export function stepWords(step) {
|
|
94
|
+
const own = step?.node !== undefined ? ["node", String(step.node)] : step?.cmd !== undefined ? [String(step.cmd)] : [];
|
|
95
|
+
return [...own, ...(Array.isArray(step?.args) ? step.args.map(wordText) : [])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A suite entry merged with its runner's command and arguments (enough to read what it runs; journeys and env are not needed here). */
|
|
99
|
+
export function suiteStep(entry, runners = {}) {
|
|
100
|
+
const runner = typeof entry.runner === "string" && isObject(runners[entry.runner]) ? runners[entry.runner] : {};
|
|
101
|
+
return {
|
|
102
|
+
cmd: entry.cmd ?? (entry.node === undefined ? runner.cmd : undefined),
|
|
103
|
+
node: entry.node ?? (entry.cmd === undefined ? runner.node : undefined),
|
|
104
|
+
args: [...(runner.args ?? []), ...(entry.args ?? [])],
|
|
105
|
+
env: { ...(runner.env ?? {}), ...(entry.env ?? {}) },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const firstWord = (words) => words.find((word) => !word.startsWith("-") && !word.startsWith("+"));
|
|
110
|
+
|
|
111
|
+
/** The package names and manifest paths a cargo build step names: `-p x`, `--package x`, `--package=x`, `--manifest-path p`. */
|
|
112
|
+
function cargoTargets(words) {
|
|
113
|
+
const packages = []; const manifests = []; const excluded = [];
|
|
114
|
+
// Words after `--` belong to the program, not to cargo.
|
|
115
|
+
const end = words.indexOf("--");
|
|
116
|
+
(end === -1 ? words : words.slice(0, end)).forEach((word, i) => {
|
|
117
|
+
if (word === "--exclude") { if (words[i + 1] !== undefined) excluded.push(words[i + 1]); return; }
|
|
118
|
+
if (word.startsWith("--exclude=")) { excluded.push(word.slice("--exclude=".length)); return; }
|
|
119
|
+
if (word === "-p" || word === "--package") packages.push(words[i + 1]);
|
|
120
|
+
else if (word.startsWith("--package=")) packages.push(word.slice("--package=".length));
|
|
121
|
+
else if (word === "--manifest-path") manifests.push(words[i + 1]);
|
|
122
|
+
else if (word.startsWith("--manifest-path=")) manifests.push(word.slice("--manifest-path=".length));
|
|
123
|
+
});
|
|
124
|
+
return { packages: packages.filter(Boolean), manifests: manifests.filter(Boolean), excluded: excluded.filter(Boolean) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The `name` of a Cargo manifest's [package] table, or null. A line scan; declared bins and test paths use smol-toml (productTargets). */
|
|
128
|
+
export function cargoPackageName(text) {
|
|
129
|
+
let inPackage = false;
|
|
130
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
131
|
+
const table = /^\s*\[([^\]]*)\]\s*(?:#.*)?$/.exec(line);
|
|
132
|
+
if (table) { inPackage = table[1].trim() === "package"; continue; }
|
|
133
|
+
const named = inPackage ? /^\s*name\s*=\s*"([^"]+)"\s*(?:#.*)?$/.exec(line) : null;
|
|
134
|
+
if (named) return named[1];
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `{ packages, manifests, dirs, testsLaunch, testsUnknown }`: what counts as a product for a cargo step (the Tauri package and every sidecar package).
|
|
141
|
+
* `dirs` maps a product package to its directory (null when no manifest path is known). `testsLaunch` holds the product packages whose integration tests
|
|
142
|
+
* (`<dir>/tests/**.rs`) reference `CARGO_BIN_EXE_`, which launches the package's own bin; `testsUnknown` the product packages whose tests cannot be listed
|
|
143
|
+
* (no directory, or no `listFiles`), which cannot be classified. `listFiles(dir)` lists the `.rs` files under an app-relative directory ([] when it has
|
|
144
|
+
* none, null when it cannot be read).
|
|
145
|
+
*/
|
|
146
|
+
export function productTargets({ config = {}, readFile = () => null, devPlatform = null, listFiles = null } = {}) {
|
|
147
|
+
const manifests = new Set(); const packages = new Set(); const dirs = new Map();
|
|
148
|
+
const testsLaunch = new Set(); const testsUnknown = new Set();
|
|
149
|
+
const launchBins = new Map(); // package -> Set(every bin its test sources launch; "*" when a name is not literal)
|
|
150
|
+
const declaredBins = new Map(); // package -> Set(its declared bins), or null when its manifest cannot be read
|
|
151
|
+
const tauriPackages = new Set();
|
|
152
|
+
const norm = (p) => path.posix.normalize(String(p).replaceAll("\\", "/"));
|
|
153
|
+
const dirOf = (manifest) => path.posix.dirname(norm(manifest));
|
|
154
|
+
const addPackage = (name, manifest) => {
|
|
155
|
+
packages.add(name);
|
|
156
|
+
if (manifest !== undefined && !dirs.has(name)) dirs.set(name, dirOf(manifest));
|
|
157
|
+
else if (!dirs.has(name)) dirs.set(name, null);
|
|
158
|
+
};
|
|
159
|
+
// A package with no declared manifest path is found in the workspace: the root Cargo.toml's `[workspace] members` (globs included, minus `exclude`),
|
|
160
|
+
// the member whose `[package] name` is it. Read from the commit, never by cargo. A package that is missing or in two members stays unresolved.
|
|
161
|
+
const workspaceDirs = () => {
|
|
162
|
+
const found = new Map(); // package name -> Set of directories
|
|
163
|
+
if (typeof listFiles !== "function") return found;
|
|
164
|
+
const globRe = (pattern) => new RegExp(`^${pattern.replace(/[.+^${}()|\\]/g, "\\$&").replaceAll("**", "\u0000").replaceAll("*", "[^/]*").replaceAll("?", "[^/]").replaceAll("\u0000", ".*")}$`);
|
|
165
|
+
const record = (dir, text) => { const name = typeof text === "string" ? cargoPackageName(text) : null; if (name) found.set(name, new Set([...(found.get(name) ?? []), dir])); };
|
|
166
|
+
for (const rootManifest of new Set(["Cargo.toml", ...[...tauriManifests].map(norm)])) {
|
|
167
|
+
const rootText = readFile(rootManifest);
|
|
168
|
+
if (typeof rootText !== "string") continue;
|
|
169
|
+
const rootDir = dirOf(rootManifest);
|
|
170
|
+
const under = (rel) => path.posix.normalize(rootDir === "." ? rel : `${rootDir}/${rel}`);
|
|
171
|
+
record(rootDir, rootText);
|
|
172
|
+
const start = rootText.search(/^\s*\[workspace\]/m);
|
|
173
|
+
if (start < 0) continue;
|
|
174
|
+
const section = rootText.slice(start).replace(/^\s*\[workspace\]/, "").split(/^\s*\[(?!workspace)/m)[0];
|
|
175
|
+
const list = (key) => { const m = new RegExp(`\\b${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`).exec(section); return m ? [...m[1].matchAll(/"([^"]+)"/g)].map((item) => item[1]) : []; };
|
|
176
|
+
const excluded = list("exclude").map((item) => globRe(under(item)));
|
|
177
|
+
for (const member of list("members")) {
|
|
178
|
+
const pattern = under(member);
|
|
179
|
+
if (!/[*?]/.test(pattern)) { if (!excluded.some((re) => re.test(pattern))) record(pattern, readFile(`${pattern}/Cargo.toml`)); continue; }
|
|
180
|
+
const prefix = pattern.split("/").slice(0, pattern.split("/").findIndex((part) => /[*?]/.test(part))).join("/");
|
|
181
|
+
const listed = listFiles(prefix || ".");
|
|
182
|
+
if (!Array.isArray(listed)) continue;
|
|
183
|
+
const re = globRe(pattern);
|
|
184
|
+
for (const file of listed) {
|
|
185
|
+
if (path.posix.basename(file) !== "Cargo.toml") continue;
|
|
186
|
+
const dir = path.posix.dirname(file);
|
|
187
|
+
if (re.test(dir) && !excluded.some((ex) => ex.test(dir))) record(dir, readFile(file));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return found;
|
|
192
|
+
};
|
|
193
|
+
const finish = () => {
|
|
194
|
+
if ([...packages].some((name) => (dirs.get(name) ?? null) === null)) {
|
|
195
|
+
const located = workspaceDirs();
|
|
196
|
+
for (const name of packages) {
|
|
197
|
+
if ((dirs.get(name) ?? null) !== null) continue;
|
|
198
|
+
const hits = located.get(name);
|
|
199
|
+
if (hits && hits.size === 1) dirs.set(name, [...hits][0]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const name of packages) {
|
|
203
|
+
const dir = dirs.get(name) ?? null;
|
|
204
|
+
const at = (rel) => (dir === "." ? rel : `${dir}/${rel}`);
|
|
205
|
+
const files = dir !== null && typeof listFiles === "function" ? listFiles(at("tests")) : null;
|
|
206
|
+
if (!Array.isArray(files)) { testsUnknown.add(name); continue; }
|
|
207
|
+
// The manifest, read once with the package's TOML parser: its [[test]] paths are test sources too, and its [[bin]] tables declare bins.
|
|
208
|
+
const manifestText = readFile(at("Cargo.toml"));
|
|
209
|
+
let manifest = null;
|
|
210
|
+
if (typeof manifestText === "string") {
|
|
211
|
+
try { manifest = parseToml(manifestText) ?? null; } catch { testsUnknown.add(name); continue; }
|
|
212
|
+
// No parser available: [[test]] paths cannot be listed, so a manifest that declares any cannot be classified.
|
|
213
|
+
if (manifest === null && /\[\[\s*test\s*\]\]|\btest\s*=\s*\[/.test(manifestText)) { testsUnknown.add(name); continue; }
|
|
214
|
+
}
|
|
215
|
+
const sources = new Set(files.filter((item) => /\.rs$/.test(item)));
|
|
216
|
+
for (const entry of Array.isArray(manifest?.test) ? manifest.test : []) if (typeof entry?.path === "string") sources.add(norm(at(entry.path)));
|
|
217
|
+
// Every CARGO_BIN_EXE_<bin> in ANY test source of the package (tests/** and [[test]] paths) counts for every cargo test of it: no narrowing by target.
|
|
218
|
+
const bins = new Set();
|
|
219
|
+
for (const file of sources) {
|
|
220
|
+
const text = readFile(file);
|
|
221
|
+
if (typeof text !== "string") { testsUnknown.add(name); continue; }
|
|
222
|
+
if (!/CARGO_BIN_EXE_/.test(text)) continue;
|
|
223
|
+
testsLaunch.add(name);
|
|
224
|
+
const named = [...text.matchAll(/CARGO_BIN_EXE_([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
|
225
|
+
for (const bin of named.length ? named : ["*"]) bins.add(bin);
|
|
226
|
+
}
|
|
227
|
+
if (bins.size) launchBins.set(name, bins);
|
|
228
|
+
// The bins the package declares: [[bin]] names (any TOML form), src/main.rs (the package's own name) and src/bin/*. A "*" launch needs all signed.
|
|
229
|
+
if (manifest === null) { declaredBins.set(name, null); continue; }
|
|
230
|
+
const declared = new Set();
|
|
231
|
+
for (const entry of Array.isArray(manifest.bin) ? manifest.bin : []) if (typeof entry?.name === "string") declared.add(entry.name);
|
|
232
|
+
if (typeof readFile(at("src/main.rs")) === "string") declared.add(typeof manifest.package?.name === "string" ? manifest.package.name : name);
|
|
233
|
+
const srcBin = listFiles(at("src/bin"));
|
|
234
|
+
if (Array.isArray(srcBin)) for (const file of srcBin) { const rel = file.slice(at("src/bin").length + 1).split("/"); if (/\.rs$/.test(file) && (rel.length === 1 || rel[1] === "main.rs")) declared.add(rel[0].replace(/\.rs$/, "")); }
|
|
235
|
+
declaredBins.set(name, declared);
|
|
236
|
+
}
|
|
237
|
+
return { packages, manifests, dirs, testsLaunch, testsUnknown, launchBins, declaredBins, tauriPackages };
|
|
238
|
+
};
|
|
239
|
+
const installs = Object.entries(config?.development?.targets ?? {}).filter(([name]) => devPlatform === null || name === devPlatform).map(([, target]) => target?.install).filter(isObject);
|
|
240
|
+
const tauriManifests = new Set([...installs.map((install) => install.manifestPath).filter((p) => typeof p === "string"), "src-tauri/Cargo.toml"]);
|
|
241
|
+
for (const manifest of tauriManifests) {
|
|
242
|
+
manifests.add(norm(manifest));
|
|
243
|
+
const text = readFile(manifest);
|
|
244
|
+
const name = typeof text === "string" ? cargoPackageName(text) : null;
|
|
245
|
+
if (name) { addPackage(name, manifest); tauriPackages.add(name); }
|
|
246
|
+
}
|
|
247
|
+
const sidecars = Array.isArray(config?.sidecars?.sidecars) ? config.sidecars.sidecars : Array.isArray(config?.sidecars) ? config.sidecars : [];
|
|
248
|
+
for (const sidecar of sidecars) {
|
|
249
|
+
if (!isObject(sidecar)) continue;
|
|
250
|
+
const hasManifest = typeof sidecar.manifestPath === "string";
|
|
251
|
+
if (hasManifest) manifests.add(norm(sidecar.manifestPath));
|
|
252
|
+
if (typeof sidecar.package === "string") addPackage(sidecar.package, hasManifest ? sidecar.manifestPath : undefined);
|
|
253
|
+
else if (hasManifest) {
|
|
254
|
+
const text = readFile(sidecar.manifestPath);
|
|
255
|
+
const name = typeof text === "string" ? cargoPackageName(text) : null;
|
|
256
|
+
if (name) addPackage(name, sidecar.manifestPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return finish();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const CARGO_BUILD_VERBS = new Set(["build", "b", "rustc", "install", "run", "r"]);
|
|
263
|
+
const CARGO_TEST_VERBS = new Set(["test", "t", "bench"]);
|
|
264
|
+
/** The target-selection flags of a cargo step (before any `--`). */
|
|
265
|
+
/** The values of a repeatable `--flag value` / `--flag=value` option of a step (before any `--`). */
|
|
266
|
+
function flagValues(words, flag) {
|
|
267
|
+
const end = words.indexOf("--");
|
|
268
|
+
const own = end < 0 ? words : words.slice(0, end);
|
|
269
|
+
const values = [];
|
|
270
|
+
own.forEach((word, i) => { if (word === flag) { if (own[i + 1] !== undefined) values.push(own[i + 1]); } else if (word.startsWith(`${flag}=`)) values.push(word.slice(flag.length + 1)); });
|
|
271
|
+
return values;
|
|
272
|
+
}
|
|
273
|
+
function cargoSelectors(words) {
|
|
274
|
+
const end = words.indexOf("--");
|
|
275
|
+
return new Set((end < 0 ? words : words.slice(0, end)).map((word) => word.replace(/=.*$/, "")).filter((word) => ["--lib", "--bin", "--bins", "--test", "--tests", "--example", "--examples", "--bench", "--benches", "--all-targets", "--doc"].includes(word)));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Classify the executables a step produces, by artifact rather than by verb (rightkit#1303, Approver's Muse ruling). `{ product, launches, unknown }`:
|
|
280
|
+
* - product: a Tauri `build`/`bundle`; a cargo `build`/`run`/`install`/`rustc` that produces a bin of a product package (no selector, `--bin`,
|
|
281
|
+
* `--bins`, `--all-targets`): the bin must be signed by a LATER sign step. `launches` (also set by a cargo `run`): the step runs a product bin,
|
|
282
|
+
* as does a cargo `test`/`bench` that builds the integration tests of a product package whose tests reference `CARGO_BIN_EXE_`: a sign step
|
|
283
|
+
* must come EARLIER. A cargo step naming no package covers every product package.
|
|
284
|
+
* - harness (neither flag set): in-place Rust test binaries (`--lib`, `--bins` unit tests, `--tests` of a package that never launches its bin),
|
|
285
|
+
* `--lib`/`--test`/`--tests` builds and an explicitly selected `--example` or `--bench`; they need no product signing.
|
|
286
|
+
* - unknown: a product package's tests that cannot be read (no directory known, or no way to list them). Refused before anything runs.
|
|
287
|
+
* Only a cargo `build`-like or `test`-like verb produces an executable; `check`, `clippy`, `doc`, `metadata` and the rest never do.
|
|
288
|
+
*/
|
|
289
|
+
export function classifyStepArtifacts(step, targets = { packages: new Set(), manifests: new Set() }) {
|
|
290
|
+
// `packages`: the product packages the step builds or launches (a Tauri build builds only the shell's own package); `buildsAny`: a bare default build
|
|
291
|
+
// with no product known, which may produce any product bin; `launchBins`: package -> Set of the bin names a launching step runs ("*" when the name
|
|
292
|
+
// is not literal); `layout`: { target, dir }, the --target triple (null: none) and the profile directory the step's outputs land in.
|
|
293
|
+
const out = { product: false, launches: false, unknown: [], packages: new Set(), buildsAny: false, launchBins: new Map(), layout: null };
|
|
294
|
+
const words = stepWords(step);
|
|
295
|
+
if (!words.length) return out;
|
|
296
|
+
const program = base(words[0]);
|
|
297
|
+
const rest = words.slice(1);
|
|
298
|
+
const isTauriWord = (word) => word === "tauri" || base(word) === "cargo-tauri" || /(^|\/)tauri\.js$/.test(String(word).replaceAll("\\", "/"));
|
|
299
|
+
const tauriAt = rest.findIndex(isTauriWord);
|
|
300
|
+
if (program === "cargo-tauri" || tauriAt >= 0) {
|
|
301
|
+
const after = program === "cargo-tauri" ? rest : rest.slice(tauriAt + 1);
|
|
302
|
+
const command = firstWord(after);
|
|
303
|
+
if (command === "build" || command === "bundle") return { ...out, product: true, packages: new Set(targets.tauriPackages ?? []), layout: stepLayout(after, { tauri: true }) };
|
|
304
|
+
}
|
|
305
|
+
const cargoWords = program === "rustright" && base(rest[0]) === "cargo" ? rest.slice(1) : program === "cargo" ? rest : null;
|
|
306
|
+
if (!cargoWords) return out;
|
|
307
|
+
const command = firstWord(cargoWords);
|
|
308
|
+
out.layout = stepLayout(cargoWords, { command });
|
|
309
|
+
const building = CARGO_BUILD_VERBS.has(command);
|
|
310
|
+
const testing = CARGO_TEST_VERBS.has(command);
|
|
311
|
+
if (!building && !testing) return out;
|
|
312
|
+
const named = cargoTargets(cargoWords);
|
|
313
|
+
const norm = (p) => path.posix.normalize(String(p).replaceAll("\\", "/"));
|
|
314
|
+
const all = !named.packages.length && !named.manifests.length;
|
|
315
|
+
const selected = new Set();
|
|
316
|
+
if (all) for (const name of targets.packages) selected.add(name);
|
|
317
|
+
else {
|
|
318
|
+
for (const name of named.packages) if (targets.packages.has(name)) selected.add(name);
|
|
319
|
+
for (const manifest of named.manifests) {
|
|
320
|
+
if (!targets.manifests.has(norm(manifest))) continue;
|
|
321
|
+
const hit = [...(targets.dirs ?? new Map())].find(([, dir]) => dir !== null && norm(manifest) === (dir === "." ? "Cargo.toml" : `${dir}/Cargo.toml`));
|
|
322
|
+
if (hit) selected.add(hit[0]); else out.unknown.push(`${manifest} (a product manifest whose package is not known)`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// `--exclude <pkg>` (and `--exclude=<pkg>`, repeated) removes a package from a workspace-wide step.
|
|
326
|
+
for (const name of named.excluded) selected.delete(name);
|
|
327
|
+
const selectors = cargoSelectors(cargoWords);
|
|
328
|
+
const none = selectors.size === 0;
|
|
329
|
+
if (building) {
|
|
330
|
+
// A bin is produced with no selector, `--bin`, `--bins` or `--all-targets`; `--example`, `--lib`, `--test(s)` and `--bench(es)` alone are harnesses.
|
|
331
|
+
const bins = none || selectors.has("--bin") || selectors.has("--bins") || selectors.has("--all-targets");
|
|
332
|
+
// No package named and no product known (a bare default): the workspace builds its default members, which include the product.
|
|
333
|
+
if (bins && (selected.size > 0 || (all && targets.packages.size === 0))) {
|
|
334
|
+
out.product = true;
|
|
335
|
+
for (const name of selected) out.packages.add(name);
|
|
336
|
+
if (selected.size === 0) out.buildsAny = true;
|
|
337
|
+
if (command === "run" || command === "r") {
|
|
338
|
+
out.launches = true;
|
|
339
|
+
// `cargo run` starts `--bin <x>`, else the package's default bin, which carries the package's name.
|
|
340
|
+
const named = flagValues(cargoWords, "--bin");
|
|
341
|
+
for (const name of selected) out.launchBins.set(name, new Set(named.length ? named : [name]));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
const integration = none || selectors.has("--test") || selectors.has("--tests") || selectors.has("--all-targets");
|
|
347
|
+
if (!integration) return out;
|
|
348
|
+
// GB-43 (GATE Class A inventory/42, E19): `--no-run` (before the `--` boundary; after it the flag is the test harness's, not Cargo's) builds the test
|
|
349
|
+
// executables and runs none of them. It PRODUCES the bins an integration test would later launch (CARGO_BIN_EXE_), so those still need a later sign
|
|
350
|
+
// step before any consumer runs them, but it launches nothing, so it needs no earlier one. Produced and executed artifacts are modelled separately.
|
|
351
|
+
const ownWords = cargoWords.indexOf("--") < 0 ? cargoWords : cargoWords.slice(0, cargoWords.indexOf("--"));
|
|
352
|
+
const compileOnly = ownWords.includes("--no-run");
|
|
353
|
+
for (const name of selected) {
|
|
354
|
+
if (targets.testsUnknown?.has(name)) out.unknown.push(`${name}'s integration tests cannot be read, so it cannot be shown they never launch its bin`);
|
|
355
|
+
else if (targets.testsLaunch?.has(name) && compileOnly) {
|
|
356
|
+
out.product = true;
|
|
357
|
+
out.packages.add(name);
|
|
358
|
+
} else if (targets.testsLaunch?.has(name)) {
|
|
359
|
+
out.launches = true;
|
|
360
|
+
out.packages.add(name);
|
|
361
|
+
// Every bin the package's test sources launch, whatever `--test` selects (no narrowing).
|
|
362
|
+
const bins = targets.launchBins?.get(name);
|
|
363
|
+
out.launchBins.set(name, bins?.size ? new Set(bins) : new Set(["*"]));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Where a step's outputs land under the build's target directory: `--target` (null when none: the build then writes `<profile>/<bin>`, with no triple
|
|
371
|
+
* directory) and the profile directory (debug by default, `release` for --release, the name for --profile X with dev and test mapping to debug and bench
|
|
372
|
+
* to release; a Tauri build is release unless --debug).
|
|
373
|
+
*/
|
|
374
|
+
function stepLayout(words, { command, tauri = false } = {}) {
|
|
375
|
+
const end = words.indexOf("--");
|
|
376
|
+
const own = end < 0 ? words : words.slice(0, end);
|
|
377
|
+
const target = flagValues(words, "--target")[0] ?? (tauri ? flagValues(words, "-t")[0] : undefined) ?? null;
|
|
378
|
+
let profile;
|
|
379
|
+
if (tauri) profile = own.includes("--debug") ? "debug" : "release";
|
|
380
|
+
else profile = flagValues(words, "--profile")[0] ?? (own.includes("--release") || own.includes("-r") ? "release" : command === "bench" ? "bench" : "dev");
|
|
381
|
+
const dir = profile === "dev" || profile === "test" ? "debug" : profile === "bench" ? "release" : profile;
|
|
382
|
+
return { target, dir };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** True when a sign file path is exactly where a step with `layout` leaves `bin`: `<triple>/<profile>/<bin>[.exe]` for a --target build, `<profile>/<bin>[.exe]` otherwise. */
|
|
386
|
+
function signPathNames(file, layout, bin) {
|
|
387
|
+
const segments = String(file).replaceAll("\\", "/").split("/");
|
|
388
|
+
const expected = layout.target ? [layout.target, layout.dir] : [layout.dir];
|
|
389
|
+
if (segments.length !== expected.length + 1) return false;
|
|
390
|
+
return expected.every((segment, i) => segments[i] === segment) && segments[segments.length - 1].replace(/\.exe$/i, "") === bin;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* GB-42 (GATE 42, E19; stable identity before launch): why an earlier sign step never makes `cargo run` or an integration test that uses
|
|
395
|
+
* CARGO_BIN_EXE_ safe. The signer signs a COPY outside the build's output directory, and neither Cargo nor a compile-time literal is redirected to it
|
|
396
|
+
* (RIGHTKIT_QA_APP_BINARY changes nothing for them), so such a step starts the raw, unsigned bytes in the target directory even though the sign
|
|
397
|
+
* declaration names the right paths. The supported route is: compile only (`cargo test --no-run`, a build), sign, then a consumer that is handed the
|
|
398
|
+
* signed path (the QA runner, which reads RIGHTKIT_QA_APP_BINARY and RIGHTKIT_QA_SIGNED_FILES) and runs prebuilt bytes.
|
|
399
|
+
*/
|
|
400
|
+
export const RAW_TARGET_LAUNCH = "launches a product bin Cargo resolves inside the build's target directory (cargo run, or an integration test using CARGO_BIN_EXE_): the signer signs a copy elsewhere and nothing redirects Cargo or a compile-time literal to it, so an earlier sign step does not bind the bytes that run. Build compile-only (cargo test --no-run), sign, then run a consumer that is handed the signed path (RIGHTKIT_QA_APP_BINARY, RIGHTKIT_QA_SIGNED_FILES)";
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Launch binding (rightkit#1303): the earlier sign steps that satisfy a launching suite must name, in `sign.files`, each executable it launches. `launch` is
|
|
404
|
+
* the launching step's classification. A sign file covers a bin when `classOf(from)` (the classification of the earlier suite it names) is a build of that
|
|
405
|
+
* package (a Tauri build builds only the shell's package), that build's output layout equals the launch's (the same explicit --target, or neither; the same profile),
|
|
406
|
+
* and the path is exactly where that layout leaves the bin. `signs` is `[{ files: [{ from, path }] }]`, the sign steps before the launch. A bin whose name is
|
|
407
|
+
* not literal ("*") needs every bin the package declares covered (`declaredBins(pkg)`; null when they cannot be read, which is never covered).
|
|
408
|
+
* Returns the launched bins no sign file covers (`"pkg:bin"`), empty when every one is covered.
|
|
409
|
+
*/
|
|
410
|
+
export function uncoveredLaunchBins(launch, signs, classOf, declaredBins = () => null) {
|
|
411
|
+
const missing = [];
|
|
412
|
+
// Exactly the same layout: cargo writes a --target build under <triple>/ and a build with none under <profile>/ alone, so a host triple never stands in for a missing --target.
|
|
413
|
+
const same = (x, y) => x.target === y.target && x.dir === y.dir;
|
|
414
|
+
const covers = (pkg, bin) => signs.some((sign) => sign.files.some((file) => {
|
|
415
|
+
const built = classOf(file.from);
|
|
416
|
+
return Boolean(built) && built.product && (built.buildsAny || built.packages.has(pkg)) && built.layout && launch.layout && same(built.layout, launch.layout) && signPathNames(file.path, built.layout, bin);
|
|
417
|
+
}));
|
|
418
|
+
for (const [pkg, bins] of launch.launchBins) {
|
|
419
|
+
for (const bin of bins) {
|
|
420
|
+
if (bin !== "*") { if (!covers(pkg, bin)) missing.push(`${pkg}:${bin}`); continue; }
|
|
421
|
+
const declared = declaredBins(pkg);
|
|
422
|
+
if (!declared || declared.size === 0) { missing.push(`${pkg}:<a bin of ${pkg}: its declared bins cannot be read>`); continue; }
|
|
423
|
+
for (const each of declared) if (!covers(pkg, each)) missing.push(`${pkg}:${each} (the launched bin's name is not literal, so every bin of ${pkg} must be signed)`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return missing;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Whether a step builds or launches a product, by artifact (see `classifyStepArtifacts`). A step whose artifacts cannot be classified counts as one
|
|
431
|
+
* here (the policy refuses it separately, before it runs).
|
|
432
|
+
*/
|
|
433
|
+
export function isProductBuildStep(step, targets = { packages: new Set(), manifests: new Set() }) {
|
|
434
|
+
const found = classifyStepArtifacts(step, targets);
|
|
435
|
+
return found.product || found.launches || found.unknown.length > 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Why a step asks for ad hoc signing (the QA bundle signer, or the ad hoc opt-in), or null. */
|
|
439
|
+
export function adHocReason(step) {
|
|
440
|
+
const words = stepWords(step);
|
|
441
|
+
const bad = words.find((word) => /sign-qa-bundle/.test(word));
|
|
442
|
+
if (bad !== undefined) return `${JSON.stringify(bad)} runs the ad hoc QA bundle signer (sign-qa-bundle)`;
|
|
443
|
+
const named = words.find((word) => /RIGHTKIT_DEV_SIGNING/.test(word));
|
|
444
|
+
if (named !== undefined) return `${JSON.stringify(named)} names RIGHTKIT_DEV_SIGNING`;
|
|
445
|
+
if (isObject(step?.env) && Object.keys(step.env).some((key) => key === "RIGHTKIT_DEV_SIGNING")) return "its env sets RIGHTKIT_DEV_SIGNING (ad hoc signing)";
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The Tauri `--config`/`-c` values of a step (inline JSON text or a path). */
|
|
450
|
+
export function tauriConfigValues(step) {
|
|
451
|
+
const words = stepWords(step);
|
|
452
|
+
const values = [];
|
|
453
|
+
words.forEach((word, i) => {
|
|
454
|
+
if (word === "--config" || word === "-c") { if (words[i + 1] !== undefined) values.push(words[i + 1]); }
|
|
455
|
+
else if (word.startsWith("--config=")) values.push(word.slice("--config=".length));
|
|
456
|
+
});
|
|
457
|
+
return values;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** True when a parsed Tauri config (or merge patch) sets `identifier`. */
|
|
461
|
+
const setsIdentifier = (value) => isObject(value) && Object.hasOwn(value, "identifier");
|
|
462
|
+
|
|
463
|
+
/** Why a step's Tauri `--config` overrides the identifier, or null. `readFile(path)` reads an app-relative file's text (null when it cannot). */
|
|
464
|
+
export function identifierOverrideReason(step, readFile = () => null) {
|
|
465
|
+
for (const value of tauriConfigValues(step)) {
|
|
466
|
+
const text = String(value).trim();
|
|
467
|
+
let parsed;
|
|
468
|
+
if (text.startsWith("{")) {
|
|
469
|
+
try { parsed = JSON.parse(text); } catch { return `a --config value is JSON that does not parse, so it cannot be shown not to set the identifier: ${text.slice(0, 60)}`; }
|
|
470
|
+
} else {
|
|
471
|
+
const body = readFile(text);
|
|
472
|
+
if (typeof body !== "string") return `the --config file ${JSON.stringify(text)} cannot be read, so it cannot be shown not to set the identifier`;
|
|
473
|
+
try { parsed = JSON.parse(body); } catch { return `the --config file ${JSON.stringify(text)} is not JSON, so it cannot be shown not to set the identifier`; }
|
|
474
|
+
}
|
|
475
|
+
if (setsIdentifier(parsed)) return `a --config sets the bundle identifier (${JSON.stringify(parsed.identifier)}); a dev or test build carries the product's one identifier`;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---- the product's one identifier -----------------------------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The product's bundle identifier: `identifier` of tauri.conf.json beside the Tauri manifest. A platform file (tauri.macos.conf.json,
|
|
484
|
+
* tauri.windows.conf.json) that sets a different one is refused. `readFile(path)` reads an app-relative file's text (null when absent).
|
|
485
|
+
*/
|
|
486
|
+
export function productIdentifier(options) { return productIdentity(options).identifier; }
|
|
487
|
+
|
|
488
|
+
/** The identifier plus the product's declared main binary name (tauri.conf.json `mainBinaryName`, or null when it declares none). */
|
|
489
|
+
export function productIdentity({ readFile, manifestPath = "src-tauri/Cargo.toml" }) {
|
|
490
|
+
const dir = path.posix.dirname(String(manifestPath).replaceAll("\\", "/"));
|
|
491
|
+
const at = (name) => (dir === "." ? name : `${dir}/${name}`);
|
|
492
|
+
const text = readFile(at("tauri.conf.json"));
|
|
493
|
+
if (typeof text !== "string") refuse("SIGN_IDENTIFIER_UNREADABLE", `${at("tauri.conf.json")} cannot be read: the product's identifier is derived from it`);
|
|
494
|
+
let identifier; let mainBinaryName = null;
|
|
495
|
+
try { const parsed = JSON.parse(text); identifier = parsed.identifier; if (typeof parsed.mainBinaryName === "string" && parsed.mainBinaryName.trim()) mainBinaryName = parsed.mainBinaryName.trim(); } catch { refuse("SIGN_IDENTIFIER_UNREADABLE", `${at("tauri.conf.json")} is not JSON`); }
|
|
496
|
+
if (typeof identifier !== "string" || !SIGNING_ID.test(identifier)) refuse("SIGN_IDENTIFIER_UNREADABLE", `${at("tauri.conf.json")} has no usable identifier (letters, digits, '.' and '-'): ${JSON.stringify(identifier)}`);
|
|
497
|
+
for (const name of ["tauri.macos.conf.json", "tauri.windows.conf.json", "tauri.linux.conf.json"]) {
|
|
498
|
+
const platform = readFile(at(name));
|
|
499
|
+
if (typeof platform !== "string") continue;
|
|
500
|
+
let other;
|
|
501
|
+
try { other = JSON.parse(platform)?.identifier; } catch { refuse("SIGN_IDENTIFIER_UNREADABLE", `${at(name)} is not JSON`); }
|
|
502
|
+
if (other !== undefined && other !== identifier) refuse("SIGN_IDENTIFIER_OVERRIDE", `${at(name)} sets identifier ${JSON.stringify(other)}, not the product's ${JSON.stringify(identifier)}`);
|
|
503
|
+
}
|
|
504
|
+
return { identifier, mainBinaryName };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The mac dev install must name the same id: `install.bundleId`, when set, equals the Tauri identifier. */
|
|
508
|
+
export function assertBundleIdMatches(config, identifier) {
|
|
509
|
+
const bundleId = config?.development?.targets?.mac?.install?.bundleId;
|
|
510
|
+
if (bundleId !== undefined && bundleId !== identifier) refuse("SIGN_IDENTIFIER_MISMATCH", `development.targets.mac.install.bundleId ${JSON.stringify(bundleId)} differs from the tauri.conf.json identifier ${JSON.stringify(identifier)}; one id per product, derived from tauri.conf.json`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---- the gate's refusals --------------------------------------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
/** The codes of the gate's signing refusals (the route probe lists them beside its own). */
|
|
516
|
+
export const SIGNING_REFUSAL_CODES = Object.freeze(["RK_SUITE_UNSIGNED_BUILD", "RK_SUITE_ADHOC_SIGNING", "RK_SUITE_IDENTIFIER_OVERRIDE", "RK_SUITE_IDENTIFIER_MISMATCH", "RK_DEV_WIN_UNSIGNED_INSTALL", "RK_DEV_UNSIGNED_INSTALL", "RK_SUITE_UNCLASSIFIED_ARTIFACT", "RK_SUITE_LAUNCH_UNBOUND"]);
|
|
517
|
+
|
|
518
|
+
/** Hosts these refusals apply to: the Mac and the Dell. Linux and hosted CI are exempt. */
|
|
519
|
+
export const signingRefusalHost = (platform, env = {}) => (platform === "darwin" || platform === "win32") && !(env.GITHUB_ACTIONS === "true" && env.CI === "true" && env.RUNNER_ENVIRONMENT === "github-hosted");
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Every signing refusal for an app's config on this host, `[{ code, message }]` (empty when the host is exempt or the config is clean).
|
|
523
|
+
* `to` is the stage (`dev`, `test`); `devPlatform` the `--platform` of a dev run (default: the host's); `readFile(rel)` reads an app-relative file of the
|
|
524
|
+
* commit (text, or null).
|
|
525
|
+
*/
|
|
526
|
+
export function suiteSigningRefusals({ config, platform, env = {}, to, devPlatform = null, buildOnly = false, readFile = () => null, listFiles = null }) {
|
|
527
|
+
if (!signingRefusalHost(platform, env) || !isObject(config)) return [];
|
|
528
|
+
const out = [];
|
|
529
|
+
const add = (code, message) => out.push({ code, message });
|
|
530
|
+
const hostDev = platform === "win32" ? "win" : "mac";
|
|
531
|
+
const dev = devPlatform ?? hostDev;
|
|
532
|
+
const targets = productTargets({ config, readFile, devPlatform: null, listFiles });
|
|
533
|
+
const runners = isObject(config.tests?.runners) ? config.tests.runners : {};
|
|
534
|
+
const suites = Array.isArray(config.tests?.suites) ? config.tests.suites.filter(isObject) : [];
|
|
535
|
+
// Suites run by `right-release test`; development builds run by `right-release dev`.
|
|
536
|
+
const judged = to === "dev" ? [] : suites;
|
|
537
|
+
const signs = [];
|
|
538
|
+
const classified = new Map(); // suite name -> its artifacts
|
|
539
|
+
judged.forEach((entry, index) => {
|
|
540
|
+
if (entry.sign !== undefined) {
|
|
541
|
+
const files = Array.isArray(entry.sign?.files) ? entry.sign.files.filter(isObject) : [];
|
|
542
|
+
signs.push({ index, from: new Set(files.map((file) => file.from)), files });
|
|
543
|
+
} else classified.set(entry.name, { index, found: classifyStepArtifacts(suiteStep(entry, runners), targets) });
|
|
544
|
+
});
|
|
545
|
+
judged.forEach((entry, index) => {
|
|
546
|
+
if (entry.sign !== undefined) return;
|
|
547
|
+
const step = suiteStep(entry, runners);
|
|
548
|
+
const label = `tests.suites[${index}] (${entry.name ?? "?"})`;
|
|
549
|
+
const adhoc = adHocReason(step);
|
|
550
|
+
if (adhoc) add("RK_SUITE_ADHOC_SIGNING", `${label}: ${adhoc}; a ${platform === "darwin" ? "Mac" : "Dell"} build is signed with the product's one identity (Developer ID, team ${SIGN_TEAM}; Azure Artifact Signing ${SIGN_SUBJECT}), never ad hoc`);
|
|
551
|
+
const artifacts = classifyStepArtifacts(step, targets);
|
|
552
|
+
for (const why of artifacts.unknown) add("RK_SUITE_UNCLASSIFIED_ARTIFACT", `${label}: ${why}; an artifact that cannot be classified as product or harness is refused before it runs`);
|
|
553
|
+
if (artifacts.launches) {
|
|
554
|
+
const earlier = signs.filter((sign) => sign.index < index);
|
|
555
|
+
if (!earlier.length) add("RK_SUITE_UNSIGNED_BUILD", `${label} launches a product bin (cargo run, or an integration test that uses CARGO_BIN_EXE_) and no earlier sign step signs one; products are signed before any launch`);
|
|
556
|
+
else {
|
|
557
|
+
const missing = uncoveredLaunchBins(artifacts, earlier, (from) => { const built = classified.get(from); return built && built.index < index ? built.found : null; }, (pkg) => targets.declaredBins.get(pkg) ?? null);
|
|
558
|
+
if (!missing.length) add("RK_SUITE_LAUNCH_UNBOUND", `${label}: ${RAW_TARGET_LAUNCH}`);
|
|
559
|
+
if (missing.length) add("RK_SUITE_LAUNCH_UNBOUND", `${label} launches ${missing.join(", ")} and no earlier sign step names it: a sign.files entry { from: <the suite that builds it>, path: <triple>/<profile>/<bin>[.exe] } must cover the executable the suite launches`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (artifacts.product || artifacts.launches) {
|
|
563
|
+
const override = identifierOverrideReason(step, readFile);
|
|
564
|
+
if (override) add("RK_SUITE_IDENTIFIER_OVERRIDE", `${label}: ${override}`);
|
|
565
|
+
}
|
|
566
|
+
if (artifacts.product) {
|
|
567
|
+
if (!signs.some((sign) => sign.index > index && sign.from.has(entry.name))) add("RK_SUITE_UNSIGNED_BUILD", `${label} builds a product and no later { sign: { files: [{ from: ${JSON.stringify(entry.name)}, path }] } } step names it; a ${platform === "darwin" ? "Mac" : "Dell"} test build is signed with the product's one identity before anything runs it`);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
// Development targets (`right-release dev`).
|
|
571
|
+
const target = config.development?.targets?.[dev];
|
|
572
|
+
if (target && (to === "dev" || to === undefined || to === null)) {
|
|
573
|
+
const install = isObject(target.install) ? target.install : {};
|
|
574
|
+
(Array.isArray(target.build) ? target.build : []).forEach((step, index) => {
|
|
575
|
+
if (!isObject(step)) return;
|
|
576
|
+
const label = `development.targets.${dev}.build[${index}]`;
|
|
577
|
+
const adhoc = adHocReason(step);
|
|
578
|
+
if (adhoc) add("RK_SUITE_ADHOC_SIGNING", `${label}: ${adhoc}`);
|
|
579
|
+
const override = isProductBuildStep(step, targets) ? identifierOverrideReason(step, readFile) : null;
|
|
580
|
+
if (override) add("RK_SUITE_IDENTIFIER_OVERRIDE", `${label}: ${override}`);
|
|
581
|
+
});
|
|
582
|
+
// Only a signed install route leaves a signed binary: `command` and `portable` installs, no install at all, `--build-only` and `stage` entries
|
|
583
|
+
// all leave (or hand QA) the raw, unsigned build (rightkit#1303 M2).
|
|
584
|
+
if (dev === "mac" || dev === "win") {
|
|
585
|
+
if (!install.kind || install.kind === "command" || install.kind === "portable") add("RK_DEV_UNSIGNED_INSTALL", `development.targets.${dev}.install ${install.kind ? `kind ${JSON.stringify(install.kind)}` : "is missing"}: it leaves the product binary unsigned; use a signed install kind (${dev === "win" ? `"${WINDOWS_SIGNED_KIND}"` : '"tauri-devid"'})`);
|
|
586
|
+
if (buildOnly) add("RK_DEV_UNSIGNED_INSTALL", `--build-only on development.targets.${dev} builds the product and signs nothing; a ${platform === "darwin" ? "Mac" : "Dell"} dev build is signed with the product's one identity`);
|
|
587
|
+
if (Array.isArray(target.stage) && target.stage.length) add("RK_DEV_UNSIGNED_INSTALL", `development.targets.${dev}.stage copies the raw, unsigned build to the staged path QA runs from; a ${platform === "darwin" ? "Mac" : "Dell"} dev build stages no product binary`);
|
|
588
|
+
}
|
|
589
|
+
if (install.signing !== undefined) add("RK_SUITE_ADHOC_SIGNING", `development.targets.${dev}.install.signing ${JSON.stringify(install.signing)} asks for ad hoc signing; a ${platform === "darwin" ? "Mac" : "Dell"} dev build is signed with the product's one identity`);
|
|
590
|
+
if (dev === "win" && install.kind === WINDOWS_TAURI_KIND) add("RK_DEV_WIN_UNSIGNED_INSTALL", `development.targets.win.install.kind "tauri" installs an unsigned installer; use "${WINDOWS_SIGNED_KIND}" (build --no-bundle, sign the exe, bundle, sign the installer, install)`);
|
|
591
|
+
if (dev === "mac" && install.bundleId !== undefined) {
|
|
592
|
+
try { assertBundleIdMatches(config, productIdentifier({ readFile, manifestPath: install.manifestPath ?? "src-tauri/Cargo.toml" })); }
|
|
593
|
+
catch (error) { if (!(error instanceof SuiteSigningError)) throw error; add("RK_SUITE_IDENTIFIER_MISMATCH", error.message); }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The policy applied to the RESOLVED plan of a `right-release test` run (rightkit#1303 H1), which is what actually executes: runner mode (`--runner`),
|
|
601
|
+
* a selected subset of suites and the `-- <args>` passthrough are all judged, not only `config.tests.suites`. For every non-sign item, its merged
|
|
602
|
+
* step plus `extraArgs` is checked for ad hoc signing and an identifier override; a step that builds a product needs a sign item LATER in the same
|
|
603
|
+
* plan whose `from` names it (by name or group). Runner mode has no sign item, so a runner invocation that builds a product is refused.
|
|
604
|
+
* `plan` is `planTests`' result: `[{ name, group, step, extraArgs, sign? }]`.
|
|
605
|
+
*/
|
|
606
|
+
export function planSigningRefusals({ config, plan, platform, env = {}, readFile = () => null, listFiles = null }) {
|
|
607
|
+
if (!signingRefusalHost(platform, env) || !Array.isArray(plan)) return [];
|
|
608
|
+
const out = [];
|
|
609
|
+
const add = (code, message) => out.push({ code, message });
|
|
610
|
+
const host = platform === "darwin" ? "Mac" : "Dell";
|
|
611
|
+
const targets = productTargets({ config: isObject(config) ? config : {}, readFile, devPlatform: null, listFiles });
|
|
612
|
+
plan.forEach((item, index) => {
|
|
613
|
+
if (item.sign !== undefined && item.sign !== null) return;
|
|
614
|
+
const step = { ...item.step, args: [...(Array.isArray(item.step?.args) ? item.step.args : []), ...(Array.isArray(item.extraArgs) ? item.extraArgs : [])] };
|
|
615
|
+
const label = `the planned step ${index} (${item.name ?? "?"})`;
|
|
616
|
+
const adhoc = adHocReason(step);
|
|
617
|
+
if (adhoc) add("RK_SUITE_ADHOC_SIGNING", `${label}: ${adhoc}; a ${host} build is signed with the product's one identity (Developer ID, team ${SIGN_TEAM}; Azure Artifact Signing ${SIGN_SUBJECT}), never ad hoc`);
|
|
618
|
+
const artifacts = classifyStepArtifacts(step, targets);
|
|
619
|
+
for (const why of artifacts.unknown) add("RK_SUITE_UNCLASSIFIED_ARTIFACT", `${label}: ${why}; an artifact that cannot be classified as product or harness is refused before it runs`);
|
|
620
|
+
if (!artifacts.product && !artifacts.launches) return;
|
|
621
|
+
const override = identifierOverrideReason(step, readFile);
|
|
622
|
+
if (override) add("RK_SUITE_IDENTIFIER_OVERRIDE", `${label}: ${override}`);
|
|
623
|
+
if (artifacts.launches) {
|
|
624
|
+
const earlier = plan.slice(0, index).filter((item2) => item2.sign && Array.isArray(item2.sign.files)).map((item2) => ({ files: item2.sign.files.filter(isObject) }));
|
|
625
|
+
if (!plan.slice(0, index).some((item2) => item2.sign)) add("RK_SUITE_UNSIGNED_BUILD", `${label} launches a product bin (cargo run, or an integration test that uses CARGO_BIN_EXE_) and no earlier sign step of this plan signs one; products are signed before any launch`);
|
|
626
|
+
else {
|
|
627
|
+
const classOf = (from) => {
|
|
628
|
+
const built = plan.slice(0, index).find((item2) => !item2.sign && (item2.name === from || item2.group === from));
|
|
629
|
+
return built ? classifyStepArtifacts({ ...built.step, args: [...(Array.isArray(built.step?.args) ? built.step.args : []), ...(Array.isArray(built.extraArgs) ? built.extraArgs : [])] }, targets) : null;
|
|
630
|
+
};
|
|
631
|
+
const missing = uncoveredLaunchBins(artifacts, earlier, classOf, (pkg) => targets.declaredBins.get(pkg) ?? null);
|
|
632
|
+
if (!missing.length) add("RK_SUITE_LAUNCH_UNBOUND", `${label}: ${RAW_TARGET_LAUNCH}`);
|
|
633
|
+
if (missing.length) add("RK_SUITE_LAUNCH_UNBOUND", `${label} launches ${missing.join(", ")} and no earlier sign step of this plan names it: a sign.files entry { from: <the suite that builds it>, path: <triple>/<profile>/<bin>[.exe] } must cover the executable the suite launches`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (!artifacts.product) return;
|
|
637
|
+
const signed = plan.slice(index + 1).some((later) => later.sign && Array.isArray(later.sign.files) && later.sign.files.some((file) => file.from === item.name || file.from === item.group));
|
|
638
|
+
if (!signed) add("RK_SUITE_UNSIGNED_BUILD", `${label} builds a product and no later sign step of this plan names ${JSON.stringify(item.name)}${item.group && item.group !== item.name ? ` or ${JSON.stringify(item.group)}` : ""}; a ${host} test build is signed with the product's one identity before anything runs it (a runner or passthrough product build cannot be signed: run the planned build and sign suites)`);
|
|
639
|
+
});
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Throws SuiteSigningError with the first refusal of `planSigningRefusals`; a no-op off the Mac and the Dell. */
|
|
644
|
+
export function assertPlanSigningPolicy({ config, plan, platform = process.platform, env = process.env, readFile = () => null, listFiles = null }) {
|
|
645
|
+
const [first] = planSigningRefusals({ config, plan, platform, env, readFile, listFiles });
|
|
646
|
+
if (first) refuse(first.code, first.message);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The same policy at run time, on the config a run actually loaded, whatever its format (right-release.config.json or a legacy right-release.config.mjs).
|
|
651
|
+
* The route probe (routePlan) reads only the commit's JSON config, so a .mjs config is enforced here: the suite runner before any suite runs and the
|
|
652
|
+
* dev runner before a target builds. Throws SuiteSigningError with the first refusal's code (RK_SUITE_*); a no-op off the Mac and the Dell.
|
|
653
|
+
*/
|
|
654
|
+
export function assertSuiteSigningPolicy({ config, platform = process.platform, env = process.env, to, devPlatform = null, buildOnly = false, readFile = () => null, listFiles = null }) {
|
|
655
|
+
const [first] = suiteSigningRefusals({ config, platform, env, to, devPlatform, buildOnly, readFile, listFiles });
|
|
656
|
+
if (first) refuse(first.code, first.message);
|
|
657
|
+
}
|