@rightkit/release 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -7
- package/adhoc-entitlements.mjs +8 -0
- package/adoption-evidence.mjs +650 -0
- package/advisory-exceptions.mjs +371 -0
- package/apple-profile-create.mjs +113 -10
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/binary-license-notices.mjs +161 -0
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +39 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +142 -19
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-dependency-allowlist.json +2 -0
- package/ownership-mandatory-set.json +158 -68
- package/ownership-mandatory-set.mjs +82 -6
- package/package.json +6 -2
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-receipts.mjs +302 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/production-mode.mjs +42 -0
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +24 -7
- package/publish-swift.mjs +12 -3
- package/qa-control-scan.mjs +4 -0
- package/release-config.mjs +330 -0
- package/release-switch-scan.mjs +44 -5
- package/release.mjs +14 -4
- package/rightkit-ownership.json +57 -0
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +191 -131
- package/runtime-budget-classes.json +8 -0
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +128 -31
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +99 -15
- package/swap-helper-bundle.mjs +7 -3
- package/workflow-yaml.mjs +177 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// M24 R1 (invocation admission) and the R2 hook-string rules.
|
|
2
|
+
//
|
|
3
|
+
// A command that a release config, a package alias or a framework hook asks RightKit to run is admitted only as one COMPLETE approved
|
|
4
|
+
// invocation: an exact executable and an argument vector that matches a typed entry below. There is no shell, so there is nothing to
|
|
5
|
+
// quote: an operator, an environment injection, a substitution or a chained second command in any string is refused outright, and
|
|
6
|
+
// `pnpm exec` runs only an approved installed runtime. Aliases (`pnpm run x`, `pnpm x`) are followed through package.json with a
|
|
7
|
+
// depth bound; a cycle, an unknown alias or any unadmitted step anywhere in the chain blocks the whole invocation. Caller strings and
|
|
8
|
+
// process identity never establish "official" origin: a step carries no origin field (an unknown key is refused), and only the
|
|
9
|
+
// executable and arguments are judged.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is a pure function over the step and an optional `scripts` map; it reads no file and runs nothing. The scanner
|
|
12
|
+
// (`pipeline-scan.mjs`) supplies the app's package.json scripts and the shared-generator registry.
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { classifyTokens, commandBaseName } from "./pipeline-tools.mjs";
|
|
15
|
+
|
|
16
|
+
export class PipelineAdmissionError extends Error {
|
|
17
|
+
constructor(code, message, findings = []) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "PipelineAdmissionError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.findings = findings;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
26
|
+
const refuse = (code, reason) => ({ admitted: false, code, reason });
|
|
27
|
+
const admit = (id, via, extra = {}) => ({ admitted: true, id, via, ...extra });
|
|
28
|
+
|
|
29
|
+
export const MAX_ALIAS_DEPTH = 8;
|
|
30
|
+
export const MAX_COMMAND_TIMEOUT_MS = 6 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** Keys a command step may carry. `env` and `nodeOptions` are environment injection; there is no `shell`, `origin` or `official` key. */
|
|
33
|
+
export const STEP_ALLOWED_KEYS = Object.freeze(["cmd", "node", "args", "cwd", "timeoutMs", "name", "nodeFlags"]);
|
|
34
|
+
const ENV_INJECTION_KEYS = new Set(["env", "nodeOptions", "envFile", "shell"]);
|
|
35
|
+
|
|
36
|
+
/** Operators, redirects, substitutions and variable expansion: anything a shell or a script runner could re-interpret. */
|
|
37
|
+
const SHELL_META = /[\0\r\n]|&&|\|\||[;|<>`]|\$\(|\$\{|\$[A-Za-z_]|%[A-Za-z_][A-Za-z0-9_]*%|\^\^/;
|
|
38
|
+
export const hasShellMeta = (text) => SHELL_META.test(String(text));
|
|
39
|
+
|
|
40
|
+
/** right-release subcommands a config may invoke (the CLI's own commands; unknown names are refused). */
|
|
41
|
+
export const OWNER_SUBCOMMANDS = Object.freeze(new Set([
|
|
42
|
+
"release", "build", "package", "publish", "upload", "deps", "publish-update", "hardening", "harden", "hardeningscan", "sign-windows",
|
|
43
|
+
"sign-updater", "sign-dev-artifact", "sign-qa-bundle", "sign-candidate", "engine-locate", "stage-sidecar", "init", "pe", "icons", "recipe-build",
|
|
44
|
+
"signing-agent", "create-mac-updater", "mirror-root-artifact", "test", "web-build", "license-notices", "toolchain", "github",
|
|
45
|
+
"cargo-target", "qa", "ownership", "ownership-approval", "ownership-report", "ownership-mandatory", "adoption-evidence", "swift-package", "bundle-scan",
|
|
46
|
+
"check-release-dependencies", "dist-scan", "identity-scan", "apple-profile-create", "ios-credentials", "dev", "doctor", "hooks",
|
|
47
|
+
"suite-doctor", "cache", "disk", "install-app", "dmg", "apple", "inventory", "model", "addon", "lsclean", "generate-dmg-background",
|
|
48
|
+
"pipeline",
|
|
49
|
+
]));
|
|
50
|
+
|
|
51
|
+
/** right-release subcommands that start another pipeline run: a generator or a framework hook may never run one (R2, R4). */
|
|
52
|
+
export const PIPELINE_DISPATCH_SUBCOMMANDS = Object.freeze(new Set([
|
|
53
|
+
"release", "build", "package", "publish", "upload", "publish-update", "github", "dev", "install-app", "recipe-build", "sign-windows",
|
|
54
|
+
"sign-updater", "sign-dev-artifact", "sign-candidate", "create-mac-updater", "swift-package", "dmg", "addon", "apple",
|
|
55
|
+
]));
|
|
56
|
+
|
|
57
|
+
const INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "fish", "ksh", "csh", "tcsh", "cmd", "powershell", "pwsh", "python", "python3", "py", "ruby", "perl", "php", "osascript", "wsl", "env", "sudo", "xargs", "wscript", "cscript", "deno", "bun"]);
|
|
58
|
+
const PACKAGE_MANAGERS = new Set(["npm", "npx", "yarn", "corepack", "pnpx"]);
|
|
59
|
+
const PNPM_BUILTINS = new Set(["add", "install", "i", "remove", "rm", "uninstall", "update", "up", "upgrade", "exec", "run", "run-script", "dlx", "publish", "pack", "rebuild", "store", "config", "link", "ln", "unlink", "list", "ls", "outdated", "audit", "why", "root", "bin", "create", "deploy", "patch", "patch-commit", "import", "init", "licenses", "prune", "fetch", "env", "setup", "self-update", "completion", "approve-builds", "dedupe", "doctor", "server", "cache", "recursive", "-r", "--filter", "-F", "--version", "-v", "--help", "-h"]);
|
|
60
|
+
/** pnpm subcommands that run a package.json script of the same name. */
|
|
61
|
+
const PNPM_SCRIPT_SHORTHANDS = new Set(["test", "t", "start", "stop", "restart"]);
|
|
62
|
+
const CARGO_SUBCOMMANDS = new Set(["build", "check", "test", "clippy", "fmt", "metadata", "tree", "doc", "bench", "about", "fetch", "vendor"]);
|
|
63
|
+
const NODE_FLAG = /^(?:--disable-warning=[A-Za-z0-9_-]+|--no-experimental-webstorage|--no-warnings|--max-old-space-size=\d{3,6})$/;
|
|
64
|
+
const SAFE_VALUE = /^[A-Za-z0-9._:/@=+,#-]+$/;
|
|
65
|
+
const TRIPLE = /^[A-Za-z0-9_]+(?:-[A-Za-z0-9_.]+){1,4}$/;
|
|
66
|
+
const PROFILE = /^[a-z][a-z0-9_-]*$/;
|
|
67
|
+
const LEGACY_CONFIG = /\.(?:[cm]?js|[cm]?ts)$/i;
|
|
68
|
+
|
|
69
|
+
function relativeInside(value) {
|
|
70
|
+
if (typeof value !== "string" || !value.trim() || path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value)) return null;
|
|
71
|
+
const normalized = value.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
72
|
+
return normalized.split("/").includes("..") || normalized === "" ? null : normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split one command line into tokens (quotes group, a backslash escapes outside single quotes). Returns `{ tokens }` or
|
|
77
|
+
* `{ error }`; an unquoted operator is an error, so a chained second command can never be hidden in the text.
|
|
78
|
+
*/
|
|
79
|
+
export function tokenizeCommandLine(text) {
|
|
80
|
+
const input = String(text);
|
|
81
|
+
if (/[\0\r\n]/.test(input)) return { error: "a command line may not contain a newline or NUL" };
|
|
82
|
+
const tokens = [];
|
|
83
|
+
let current = "";
|
|
84
|
+
let started = false;
|
|
85
|
+
let quote = null;
|
|
86
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
87
|
+
const ch = input[i];
|
|
88
|
+
if (quote) {
|
|
89
|
+
if (ch === quote) quote = null;
|
|
90
|
+
else if (ch === "\\" && quote === '"' && (input[i + 1] === '"' || input[i + 1] === "\\")) { current += input[i + 1]; i += 1; }
|
|
91
|
+
else current += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
|
|
95
|
+
if (/\s/.test(ch)) { if (started) { tokens.push(current); current = ""; started = false; } continue; }
|
|
96
|
+
if (/[;|&<>`()]/.test(ch) || (ch === "$" && /[({A-Za-z_]/.test(input[i + 1] ?? ""))) return { error: `the operator or substitution "${ch}" is not allowed in an admitted command` };
|
|
97
|
+
current += ch;
|
|
98
|
+
started = true;
|
|
99
|
+
}
|
|
100
|
+
if (quote) return { error: "unterminated quote" };
|
|
101
|
+
if (started) tokens.push(current);
|
|
102
|
+
return { tokens };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function shellSafeArgs(args, label) {
|
|
106
|
+
const bad = args.find((arg) => hasShellMeta(arg));
|
|
107
|
+
return bad === undefined ? null : refuse("R1-operators", `${label}: argument ${JSON.stringify(bad)} contains an operator, substitution, environment reference or newline`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validate a step's shape and return `{ cmd, args, cwd }` (a `node` step becomes `cmd: "node"`), or a refusal. */
|
|
111
|
+
export function normalizeStep(step) {
|
|
112
|
+
if (!isObject(step)) return refuse("R1-shape", "a command must be an object { cmd, args? }");
|
|
113
|
+
for (const key of Object.keys(step)) {
|
|
114
|
+
if (ENV_INJECTION_KEYS.has(key)) return refuse("R1-env-injection", `the "${key}" key injects environment or a shell and is not allowed in an admitted command`);
|
|
115
|
+
if (!STEP_ALLOWED_KEYS.includes(key)) return refuse("R1-shape", `unknown command key "${key}" (allowed: ${STEP_ALLOWED_KEYS.join(", ")}); a caller-supplied origin or identity is never accepted`);
|
|
116
|
+
}
|
|
117
|
+
if (step.cmd !== undefined && step.node !== undefined) return refuse("R1-shape", "a command sets both cmd and node");
|
|
118
|
+
const argList = step.args === undefined ? [] : step.args;
|
|
119
|
+
if (!Array.isArray(argList) || argList.some((arg) => typeof arg !== "string")) return refuse("R1-shape", "args must be a list of strings");
|
|
120
|
+
if (argList.some((arg) => /[\0\r\n]/.test(arg))) return refuse("R1-operators", "an argument contains a newline or NUL");
|
|
121
|
+
if (step.cwd !== undefined && relativeInside(step.cwd) === null) return refuse("R1-cwd", `cwd must be relative and stay inside the app: ${JSON.stringify(step.cwd)}`);
|
|
122
|
+
if (step.timeoutMs !== undefined && !(Number.isInteger(step.timeoutMs) && step.timeoutMs > 0 && step.timeoutMs <= MAX_COMMAND_TIMEOUT_MS)) return refuse("R1-shape", "timeoutMs must be a positive integer of at most six hours");
|
|
123
|
+
if (step.nodeFlags !== undefined && (!Array.isArray(step.nodeFlags) || step.nodeFlags.some((flag) => typeof flag !== "string" || !NODE_FLAG.test(flag)))) return refuse("R1-node-flags", "nodeFlags allow only --disable-warning=<id>, --no-experimental-webstorage, --no-warnings and --max-old-space-size=<n>");
|
|
124
|
+
if (step.node !== undefined) {
|
|
125
|
+
if (typeof step.node !== "string") return refuse("R1-shape", "node must be an app-relative script path");
|
|
126
|
+
return { cmd: "node", args: [...(step.nodeFlags ?? []), step.node, ...argList], cwd: step.cwd, nodeScript: true };
|
|
127
|
+
}
|
|
128
|
+
if (step.nodeFlags !== undefined) return refuse("R1-node-flags", "nodeFlags apply only to a node script step");
|
|
129
|
+
if (typeof step.cmd !== "string" || !step.cmd.trim()) return refuse("R1-shape", "cmd must be a non-empty string");
|
|
130
|
+
return { cmd: step.cmd, args: argList, cwd: step.cwd };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The approved installed runtimes a `pnpm exec` (or `node node_modules/...`) may start. */
|
|
134
|
+
const flagTable = (spec) => (rest, label) => {
|
|
135
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
136
|
+
const arg = rest[i];
|
|
137
|
+
const flag = arg.includes("=") && arg.startsWith("--") ? arg.slice(0, arg.indexOf("=")) : arg;
|
|
138
|
+
const kind = spec[flag];
|
|
139
|
+
if (kind === undefined) return refuse("R1-option", `${label}: option ${JSON.stringify(arg)} is not an approved option`);
|
|
140
|
+
if (kind === "none") { if (arg !== flag) return refuse("R1-option", `${label}: ${flag} takes no value`); continue; }
|
|
141
|
+
const value = arg !== flag ? arg.slice(flag.length + 1) : rest[(i += 1)];
|
|
142
|
+
if (value === undefined) return refuse("R1-option", `${label}: ${flag} needs a value`);
|
|
143
|
+
if (kind === "value" && !SAFE_VALUE.test(value)) return refuse("R1-option", `${label}: ${flag} value ${JSON.stringify(value)} is not a plain token`);
|
|
144
|
+
if (kind === "triple" && !TRIPLE.test(value)) return refuse("R1-option", `${label}: ${flag} must name a target triple`);
|
|
145
|
+
if (kind === "profile" && !PROFILE.test(value)) return refuse("R1-option", `${label}: ${flag} must name a Cargo profile`);
|
|
146
|
+
if (kind === "path" && relativeInside(value) === null) return refuse("R1-option", `${label}: ${flag} must be a relative path inside the app`);
|
|
147
|
+
if (kind === "int" && !/^\d{1,3}$/.test(value)) return refuse("R1-option", `${label}: ${flag} must be a small integer`);
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const TAURI_COMMANDS = new Set(["build", "bundle", "dev", "info"]);
|
|
153
|
+
const TAURI_OPTIONS = flagTable({
|
|
154
|
+
"--target": "triple", "--bundles": "value", "--no-sign": "none", "--no-bundle": "none", "--ci": "none", "--verbose": "none", "-v": "none",
|
|
155
|
+
"--debug": "none", "-d": "none", "--features": "value", "--config": "config", "-c": "config",
|
|
156
|
+
});
|
|
157
|
+
const TAURI_PASSTHROUGH = flagTable({ "--profile": "profile", "--locked": "none", "--features": "value", "--no-default-features": "none", "--jobs": "int", "--frozen": "none", "--offline": "none" });
|
|
158
|
+
|
|
159
|
+
/** `--config <json | path>`: an inline override may set only the typed keys below, and each hook inside it passes R2. */
|
|
160
|
+
export function validateTauriConfigOverride(text, options = {}, label = "tauri --config") {
|
|
161
|
+
const value = String(text).trim();
|
|
162
|
+
if (!value.startsWith("{")) {
|
|
163
|
+
const normalized = relativeInside(value);
|
|
164
|
+
if (normalized === null || !/\.json$/i.test(normalized)) return refuse("R2-tauri-config", `${label} must be inline JSON or a relative .json file: ${JSON.stringify(value)}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
let parsed;
|
|
168
|
+
try { parsed = JSON.parse(value); } catch { return refuse("R2-tauri-config", `${label} is not valid JSON`); }
|
|
169
|
+
if (!isObject(parsed)) return refuse("R2-tauri-config", `${label} must be a JSON object`);
|
|
170
|
+
for (const key of Object.keys(parsed)) if (key !== "build" && key !== "bundle") return refuse("R2-tauri-config", `${label}: top-level key "${key}" is not allowed (identity and bundle id belong to RightKit; allowed: build, bundle)`);
|
|
171
|
+
const build = parsed.build ?? {};
|
|
172
|
+
if (!isObject(build)) return refuse("R2-tauri-config", `${label}.build must be an object`);
|
|
173
|
+
for (const [key, entry] of Object.entries(build)) {
|
|
174
|
+
if (["beforeDevCommand", "beforeBuildCommand", "beforeBundleCommand"].includes(key)) {
|
|
175
|
+
const result = admitTauriHook(entry, options, `${label}.build.${key}`);
|
|
176
|
+
if (result) return result;
|
|
177
|
+
} else if (!["frontendDist", "devUrl"].includes(key)) return refuse("R2-tauri-config", `${label}.build.${key} is not allowed`);
|
|
178
|
+
}
|
|
179
|
+
const bundle = parsed.bundle ?? {};
|
|
180
|
+
if (!isObject(bundle)) return refuse("R2-tauri-config", `${label}.bundle must be an object`);
|
|
181
|
+
for (const [key, entry] of Object.entries(bundle)) {
|
|
182
|
+
if (key === "createUpdaterArtifacts") { if (typeof entry !== "boolean" && entry !== "v1Compatible") return refuse("R2-tauri-config", `${label}.bundle.createUpdaterArtifacts must be a boolean`); }
|
|
183
|
+
else if (key === "windows") { if (!isObject(entry) || Object.keys(entry).some((k) => k !== "signCommand" || entry[k] !== null)) return refuse("R3-tauri-sign-hook", `${label}.bundle.windows may only set signCommand to null: a sign hook is not allowed`); }
|
|
184
|
+
else if (key === "macOS") { if (!isObject(entry) || Object.keys(entry).some((k) => k !== "signingIdentity" || entry[k] !== "-")) return refuse("R3-tauri-sign-hook", `${label}.bundle.macOS may only set signingIdentity to "-" (ad hoc): signing belongs to RightKit`); }
|
|
185
|
+
else return refuse("R2-tauri-config", `${label}.bundle.${key} is not allowed`);
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A Tauri `before*Command` value: a string or `{ script, cwd }`; one admitted command, no chain. */
|
|
191
|
+
export function admitTauriHook(entry, options = {}, label = "tauri hook") {
|
|
192
|
+
const script = typeof entry === "string" ? entry : isObject(entry) && typeof entry.script === "string" ? entry.script : null;
|
|
193
|
+
if (script === null) return refuse("R2-tauri-hook", `${label} must be a command string or { script, cwd }`);
|
|
194
|
+
if (isObject(entry) && entry.cwd !== undefined && relativeInside(entry.cwd) === null) return refuse("R2-tauri-hook", `${label}.cwd must stay inside the app`);
|
|
195
|
+
if (script.trim() === "") return null;
|
|
196
|
+
const result = admitHookString(script, options);
|
|
197
|
+
return result.admitted ? null : refuse(result.code, `${label}: ${result.reason}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function admitTauri(rest, options, label) {
|
|
201
|
+
const sub = rest[0];
|
|
202
|
+
if (sub === "signer" || sub === "icon" || sub === "init" || sub === "add" || sub === "migrate") return refuse("R3-tauri-subcommand", `${label}: tauri ${sub} is not an admitted pipeline command`);
|
|
203
|
+
if (!TAURI_COMMANDS.has(sub) && sub !== "--version" && sub !== "-V") return refuse("R1-option", `${label}: tauri subcommand ${JSON.stringify(sub)} is not admitted`);
|
|
204
|
+
if (sub === "--version" || sub === "-V") return rest.length === 1 ? admit("tauri-version", "runtime") : refuse("R1-option", `${label}: --version takes no further arguments`);
|
|
205
|
+
const dash = rest.indexOf("--");
|
|
206
|
+
const own = dash >= 0 ? rest.slice(1, dash) : rest.slice(1);
|
|
207
|
+
const pass = dash >= 0 ? rest.slice(dash + 1) : [];
|
|
208
|
+
// `--config` values carry JSON that the generic flag table cannot judge: validate them here, then check the remaining flags.
|
|
209
|
+
const plain = [];
|
|
210
|
+
for (let i = 0; i < own.length; i += 1) {
|
|
211
|
+
if (own[i] === "--config" || own[i] === "-c" || own[i].startsWith("--config=")) {
|
|
212
|
+
const value = own[i].startsWith("--config=") ? own[i].slice("--config=".length) : own[(i += 1)];
|
|
213
|
+
if (value === undefined) return refuse("R1-option", `${label}: --config needs a value`);
|
|
214
|
+
const problem = validateTauriConfigOverride(value, options, `${label} --config`);
|
|
215
|
+
if (problem) return problem;
|
|
216
|
+
} else plain.push(own[i]);
|
|
217
|
+
}
|
|
218
|
+
const problem = TAURI_OPTIONS(plain.map((arg) => arg), label);
|
|
219
|
+
if (problem) return problem;
|
|
220
|
+
const passProblem = TAURI_PASSTHROUGH(pass, `${label} --`);
|
|
221
|
+
if (passProblem) return passProblem;
|
|
222
|
+
return admit(`tauri-${sub}`, "runtime");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const VITE_OPTIONS = flagTable({ "--config": "path", "-c": "path", "--outDir": "path", "--base": "value", "--emptyOutDir": "none" });
|
|
226
|
+
|
|
227
|
+
/** `pnpm exec <bin>` and `node node_modules/...` runtimes. */
|
|
228
|
+
function admitRuntime(bin, rest, options, label) {
|
|
229
|
+
switch (bin) {
|
|
230
|
+
case "right-release": return admitRightRelease(rest, label);
|
|
231
|
+
case "tauri": return admitTauri(rest, options, label);
|
|
232
|
+
case "vite": return rest[0] === "build" ? (VITE_OPTIONS(rest.slice(1), label) ?? admit("vite-build", "runtime")) : rest.length === 1 && rest[0] === "--version" ? admit("vite-version", "runtime") : refuse("R1-option", `${label}: only vite build and vite --version are admitted`);
|
|
233
|
+
case "tsc": return shellSafeArgs(rest, label) ?? admit("tsc", "runtime");
|
|
234
|
+
case "vitest": return rest[0] === "run" || (rest.length === 1 && rest[0] === "--version") ? (shellSafeArgs(rest, label) ?? admit("vitest", "runtime")) : refuse("R1-option", `${label}: only vitest run is admitted`);
|
|
235
|
+
case "eslint": return shellSafeArgs(rest, label) ?? admit("eslint", "runtime");
|
|
236
|
+
default: return refuse("R1-runtime", `${label}: "${bin}" is not an approved installed runtime for pnpm exec`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function admitRightRelease(args, label) {
|
|
241
|
+
const sub = args[0];
|
|
242
|
+
if (!OWNER_SUBCOMMANDS.has(sub)) return refuse("R1-owner-subcommand", `${label}: right-release ${JSON.stringify(sub ?? "")} is not a known RightKit command`);
|
|
243
|
+
const meta = shellSafeArgs(args, label);
|
|
244
|
+
if (meta) return meta;
|
|
245
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
246
|
+
const value = args[i] === "--config" ? args[i + 1] : args[i].startsWith("--config=") ? args[i].slice("--config=".length) : null;
|
|
247
|
+
if (value !== null && value !== undefined && LEGACY_CONFIG.test(value)) return refuse("R1-legacy-config", `${label}: ${value} is a legacy executable config; RightKit reads only right-release.config.json`);
|
|
248
|
+
}
|
|
249
|
+
return admit(`right-release-${sub}`, "owner");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const NODE_MODULE_ENTRIES = Object.freeze({
|
|
253
|
+
"node_modules/typescript/bin/tsc": "tsc",
|
|
254
|
+
"node_modules/typescript/lib/tsc.js": "tsc",
|
|
255
|
+
"node_modules/vite/bin/vite.js": "vite",
|
|
256
|
+
"node_modules/vitest/vitest.mjs": "vitest",
|
|
257
|
+
"node_modules/@tauri-apps/cli/tauri.js": "tauri",
|
|
258
|
+
"node_modules/@rightkit/release/cli/right-release.mjs": "right-release",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function admitNode(args, options, label) {
|
|
262
|
+
let index = 0;
|
|
263
|
+
while (index < args.length && args[index].startsWith("-")) {
|
|
264
|
+
if (!NODE_FLAG.test(args[index])) return refuse("R1-interpreter", `${label}: node option ${JSON.stringify(args[index])} is not admitted (no -e, --eval, --import or --require)`);
|
|
265
|
+
index += 1;
|
|
266
|
+
}
|
|
267
|
+
const script = relativeInside(args[index]);
|
|
268
|
+
if (script === null) return refuse("R1-interpreter", `${label}: node needs a script path inside the app`);
|
|
269
|
+
const rest = args.slice(index + 1);
|
|
270
|
+
const runtime = NODE_MODULE_ENTRIES[script];
|
|
271
|
+
if (runtime) return admitRuntime(runtime, rest, options, label);
|
|
272
|
+
const generator = options.admitGenerator?.({ script, args: rest, cwd: options.cwd });
|
|
273
|
+
if (generator?.admitted) return admit(generator.id, "generator", { generator: generator.id });
|
|
274
|
+
return refuse(generator?.code ?? "R4-unregistered-generator", `${label}: ${script} is app code, not an admitted shared generator${generator?.reason ? ` (${generator.reason})` : ""}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const PNPM_INSTALL_FLAGS = new Set(["--frozen-lockfile", "--ignore-scripts", "--prefer-offline", "--offline"]);
|
|
278
|
+
|
|
279
|
+
function admitPnpm(args, options, label, chain) {
|
|
280
|
+
const sub = args[0];
|
|
281
|
+
if (sub === undefined) return refuse("R1-pnpm", `${label}: pnpm needs a subcommand`);
|
|
282
|
+
if (sub === "exec") {
|
|
283
|
+
const bin = args[1];
|
|
284
|
+
if (bin === undefined || bin.startsWith("-")) return refuse("R1-pnpm-exec", `${label}: pnpm exec needs an installed runtime name first (no shell mode or option before it)`);
|
|
285
|
+
return admitRuntime(bin, args.slice(2), options, `${label} exec ${bin}`);
|
|
286
|
+
}
|
|
287
|
+
if (sub === "install" || sub === "i") {
|
|
288
|
+
const flags = args.slice(1);
|
|
289
|
+
if (flags.some((flag) => !PNPM_INSTALL_FLAGS.has(flag)) || !flags.includes("--frozen-lockfile")) return refuse("R1-pnpm", `${label}: pnpm install is admitted only as install --frozen-lockfile [--ignore-scripts]`);
|
|
290
|
+
return admit("pnpm-install", "runtime");
|
|
291
|
+
}
|
|
292
|
+
if (sub === "run" || sub === "run-script") {
|
|
293
|
+
const name = args[1];
|
|
294
|
+
if (name === undefined || name.startsWith("-")) return refuse("R1-pnpm", `${label}: pnpm run needs a script name first`);
|
|
295
|
+
const meta = shellSafeArgs(args.slice(2), label);
|
|
296
|
+
if (meta) return meta;
|
|
297
|
+
return admitAlias(name, options, chain);
|
|
298
|
+
}
|
|
299
|
+
if (PNPM_SCRIPT_SHORTHANDS.has(sub)) {
|
|
300
|
+
const meta = shellSafeArgs(args.slice(1), label);
|
|
301
|
+
return meta ?? admitAlias(sub === "t" ? "test" : sub, options, chain);
|
|
302
|
+
}
|
|
303
|
+
if (PNPM_BUILTINS.has(sub)) return refuse("R1-pnpm", `${label}: pnpm ${sub} is not an admitted pipeline command`);
|
|
304
|
+
const meta = shellSafeArgs(args.slice(1), label);
|
|
305
|
+
return meta ?? admitAlias(sub, options, chain);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Follow a package.json alias (and its pre/post hooks): every step must be admitted; a cycle or an unknown name blocks. */
|
|
309
|
+
export function admitAlias(name, options = {}, chain = []) {
|
|
310
|
+
const scripts = options.scripts;
|
|
311
|
+
if (!isObject(scripts)) return refuse("R1-alias-unresolved", `package script "${name}" cannot be resolved (no package.json scripts were supplied); an unknown reachable owner blocks`);
|
|
312
|
+
if (chain.includes(name)) return refuse("R1-alias-cycle", `package script alias cycle: ${[...chain, name].join(" -> ")}`);
|
|
313
|
+
if (chain.length >= MAX_ALIAS_DEPTH) return refuse("R1-alias-depth", `package script alias chain deeper than ${MAX_ALIAS_DEPTH}: ${[...chain, name].join(" -> ")}`);
|
|
314
|
+
if (typeof scripts[name] !== "string") return refuse("R1-alias-unknown", `package script "${name}" does not exist`);
|
|
315
|
+
const next = [...chain, name];
|
|
316
|
+
for (const hook of [`pre${name}`, name, `post${name}`]) {
|
|
317
|
+
if (scripts[hook] === undefined) continue;
|
|
318
|
+
if (typeof scripts[hook] !== "string") return refuse("R1-alias-unknown", `package script "${hook}" is not a string`);
|
|
319
|
+
// The hook name joins the chain so `prebuild: pnpm run build` is a cycle, not an unbounded descent.
|
|
320
|
+
const result = admitHookString(scripts[hook], options, hook === name ? next : [...next, hook]);
|
|
321
|
+
if (!result.admitted) return refuse(result.code, `script "${hook}": ${result.reason}`);
|
|
322
|
+
}
|
|
323
|
+
return admit(`alias:${name}`, "alias", { chain: next });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** One command line (a package script, a Tauri hook, a workflow step, an Xcode phase): a single admitted command, no chain. */
|
|
327
|
+
export function admitHookString(text, options = {}, chain = []) {
|
|
328
|
+
const tokenized = tokenizeCommandLine(text);
|
|
329
|
+
if (tokenized.error) return refuse("R1-operators", tokenized.error);
|
|
330
|
+
if (!tokenized.tokens.length) return refuse("R1-shape", "an empty command line");
|
|
331
|
+
const [cmd, ...args] = tokenized.tokens;
|
|
332
|
+
return admitInvocation({ cmd, args }, { ...options, aliasChain: chain });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* R1: is this one step an approved invocation? Returns `{ admitted: true, id, via }` or `{ admitted: false, code, reason }`.
|
|
337
|
+
* `options`: `scripts` (package.json scripts, for alias traversal), `stage` (reported only), `label` (message prefix),
|
|
338
|
+
* `admitGenerator({ script, args, cwd })` (the shared-generator registry check; absent means no generator is admitted).
|
|
339
|
+
*/
|
|
340
|
+
export function admitInvocation(step, options = {}) {
|
|
341
|
+
const label = options.label ?? "command";
|
|
342
|
+
const normalized = normalizeStep(step);
|
|
343
|
+
if (normalized.admitted === false) return normalized;
|
|
344
|
+
const { cmd, args, cwd } = normalized;
|
|
345
|
+
const chain = options.aliasChain ?? [];
|
|
346
|
+
if (/^\.[\\/]gradlew(?:\.bat)?$/i.test(cmd)) return admitGradle(args, options, label);
|
|
347
|
+
if (!/^[A-Za-z0-9._@+-]+$/.test(cmd)) return refuse("R1-executable", `${label}: executable ${JSON.stringify(cmd)} must be a bare name (a path, a space or an operator makes it app-owned code)`);
|
|
348
|
+
const name = commandBaseName(cmd);
|
|
349
|
+
const inner = { ...options, cwd };
|
|
350
|
+
const tool = classifyTokens([name, ...args]);
|
|
351
|
+
if (tool) return refuse("R3-pipeline-tool", `${label}: ${tool.tool} is a ${tool.stage} tool; build, sign, notarize, package and upload run only inside RightKit`);
|
|
352
|
+
if (name === "right-release") return admitRightRelease(args, label);
|
|
353
|
+
if (name === "pnpm") return admitPnpm(args, inner, label, chain);
|
|
354
|
+
if (name === "node") return admitNode(args, inner, label);
|
|
355
|
+
if (name === "rightkit") {
|
|
356
|
+
if (args[0] !== "cargo" || !CARGO_SUBCOMMANDS.has(args[1])) return refuse("R1-managed-client", `${label}: only rightkit cargo ${[...CARGO_SUBCOMMANDS].join("|")} is admitted`);
|
|
357
|
+
return shellSafeArgs(args, label) ?? admit(`rightkit-cargo-${args[1]}`, "managed-cargo");
|
|
358
|
+
}
|
|
359
|
+
if (name === "cargo" || name === "rustc" || name === "rustdoc") return refuse("R1-bare-cargo", `${label}: run Cargo through the managed client (rightkit cargo ...), never bare ${name}`);
|
|
360
|
+
if (name === "tauri") return refuse("R1-direct-tauri", `${label}: run the Tauri CLI as pnpm exec tauri ..., or through right-release; a bare tauri is not an approved installed runtime`);
|
|
361
|
+
if (PACKAGE_MANAGERS.has(name)) return refuse("R1-package-manager", `${label}: ${name} is not allowed; the workspace uses pnpm`);
|
|
362
|
+
if (INTERPRETERS.has(name)) return refuse("R1-interpreter", `${label}: ${name} runs script text or a script file the app owns; only an admitted shared generator may do that`);
|
|
363
|
+
return refuse("R1-unknown-executable", `${label}: ${JSON.stringify(cmd)} is not an admitted RightKit step; unknown pipeline ownership blocks`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const GRADLE_ARG = /^(?::?[A-Za-z][A-Za-z0-9:_-]*|--(?:no-daemon|stacktrace|offline|no-build-cache|warning-mode=(?:all|summary|none|fail)|console=plain)|-P[A-Za-z][A-Za-z0-9._]*=[A-Za-z0-9._:/-]+)$/;
|
|
367
|
+
function admitGradle(args, options, label) {
|
|
368
|
+
const stage = options.stage ?? "";
|
|
369
|
+
if (!["android-build", "dev-build"].includes(stage)) return refuse("R1-executable", `${label}: ./gradlew is admitted only as the Android build (targets.android.gradle) or an Android dev build`);
|
|
370
|
+
if (!args.length || args.some((arg) => !GRADLE_ARG.test(arg))) return refuse("R1-option", `${label}: gradle arguments must be plain tasks and approved flags`);
|
|
371
|
+
const task = args.find((arg) => !arg.startsWith("-"));
|
|
372
|
+
if (!task) return refuse("R1-option", `${label}: gradle needs a task`);
|
|
373
|
+
if (/(?<![A-Za-z])(?:publish|upload|sign)(?![a-z])/.test(args.filter((arg) => !arg.startsWith("-")).join(" "))) return refuse("R3-pipeline-tool", `${label}: a gradle publish, upload or sign task is a release effect that belongs to RightKit`);
|
|
374
|
+
return admit("gradle-build", "runtime");
|
|
375
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// M24: every command a release config hands to RightKit is judged here, before anything runs.
|
|
2
|
+
//
|
|
3
|
+
// A release config is data. The places where it can name an executable are the dev build and install commands, the package, signing
|
|
4
|
+
// and publish hooks, the recipe `prepare` / `postBuild` lists, the native assembly hooks, the sidecar `command` entries and the web
|
|
5
|
+
// build steps. `collectConfigCommands` finds every object that carries `cmd` or `node` under those roots (so a command hidden in a
|
|
6
|
+
// new key is still seen), and `validateConfigPipeline` runs R1 admission on each one (R2 inside Tauri `--config` overrides, R4 for a
|
|
7
|
+
// `node` generator). Each finding names its JSON pointer, its line in the config file, its pipeline stage and who owns the step.
|
|
8
|
+
// `tests` and `qa` are not pipeline commands (M20, M21) and are not walked.
|
|
9
|
+
import { admitHookString, admitInvocation } from "./pipeline-admission.mjs";
|
|
10
|
+
import { makeGeneratorAdmitter } from "./pipeline-generators.mjs";
|
|
11
|
+
|
|
12
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
const escapePointer = (key) => String(key).replaceAll("~", "~0").replaceAll("/", "~1");
|
|
14
|
+
|
|
15
|
+
/** Config roots that can carry a command. Everything else (checks, buildInputs, legal, distribution, qa, tests) is typed data. */
|
|
16
|
+
export const PIPELINE_CONFIG_ROOTS = Object.freeze(["development", "targets", "nativeAssembly", "sidecars", "web"]);
|
|
17
|
+
/** The only keys of a command object that can change what runs; every other key on the node is typed host metadata. */
|
|
18
|
+
const COMMAND_KEYS = ["cmd", "node", "args", "cwd", "timeoutMs", "nodeFlags", "env", "nodeOptions", "envFile", "shell"];
|
|
19
|
+
|
|
20
|
+
/** The pipeline stage a command pointer belongs to. */
|
|
21
|
+
export function stageOfPointer(pointer) {
|
|
22
|
+
const parts = pointer.split("/").slice(1);
|
|
23
|
+
if (parts[0] === "development") {
|
|
24
|
+
if (parts[3] === "install") return "dev-install";
|
|
25
|
+
return "dev-build";
|
|
26
|
+
}
|
|
27
|
+
if (parts[0] === "targets") {
|
|
28
|
+
if (parts[2] === "gradle") return "android-build";
|
|
29
|
+
if (["winPackage", "macPackage", "iosPackage"].includes(parts[2])) return "recipe-prepare";
|
|
30
|
+
const hook = parts[2];
|
|
31
|
+
if (hook === "prePackage") return "build";
|
|
32
|
+
if (hook === "package") return "package";
|
|
33
|
+
if (hook === "postSign") return "post-sign";
|
|
34
|
+
if (hook === "publish" || hook === "upload") return "publish";
|
|
35
|
+
return "build";
|
|
36
|
+
}
|
|
37
|
+
if (parts[0] === "nativeAssembly") return "native-assembly";
|
|
38
|
+
if (parts[0] === "sidecars") return "sidecar-stage";
|
|
39
|
+
if (parts[0] === "web") return "web-build";
|
|
40
|
+
return "pipeline";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every command node under the pipeline roots: `[{ pointer, stage, step }]`, plus string-form hooks (`nativeAssembly.finalizer`). */
|
|
44
|
+
export function collectConfigCommands(config) {
|
|
45
|
+
const found = [];
|
|
46
|
+
const visit = (node, pointer, depth) => {
|
|
47
|
+
if (depth > 32) return;
|
|
48
|
+
if (Array.isArray(node)) { node.forEach((item, index) => visit(item, `${pointer}/${index}`, depth + 1)); return; }
|
|
49
|
+
if (!isObject(node)) return;
|
|
50
|
+
if (typeof node.cmd === "string" || typeof node.node === "string") {
|
|
51
|
+
const step = {};
|
|
52
|
+
for (const key of COMMAND_KEYS) if (key in node) step[key] = node[key];
|
|
53
|
+
found.push({ pointer, stage: stageOfPointer(pointer), step });
|
|
54
|
+
}
|
|
55
|
+
for (const [key, value] of Object.entries(node)) {
|
|
56
|
+
if (key === "args" || key === "nodeFlags" || key === "env") continue;
|
|
57
|
+
visit(value, `${pointer}/${escapePointer(key)}`, depth + 1);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
for (const root of PIPELINE_CONFIG_ROOTS) if (config?.[root] !== undefined) visit(config[root], `/${root}`, 0);
|
|
61
|
+
if (typeof config?.nativeAssembly?.finalizer === "string") found.push({ pointer: "/nativeAssembly/finalizer", stage: "native-assembly", string: config.nativeAssembly.finalizer });
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const UNKNOWN_CODE = /(?:unknown|unresolved|cycle|depth|unregistered|containment)/;
|
|
66
|
+
/** Who owns a refused step: `unknown` when ownership could not be established (it blocks), else the app that wrote it. */
|
|
67
|
+
export const ownershipOfCode = (code) => (UNKNOWN_CODE.test(code) ? "unknown" : "app-owned");
|
|
68
|
+
export const ruleOfCode = (code) => String(code).split("-")[0];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Judge every command in a release config. `options`: `scripts` (the app's package.json scripts), `registry` + `appRoot` (the shared
|
|
72
|
+
* generator registry for a `node` step), `lineOf(pointer)` (config line numbers), `containment`, `readBytes`, `readText` (injectable).
|
|
73
|
+
* Returns the findings; an empty list means every command is an admitted RightKit invocation.
|
|
74
|
+
*/
|
|
75
|
+
export function validateConfigPipeline(config, options = {}) {
|
|
76
|
+
const findings = [];
|
|
77
|
+
const app = typeof config?.app === "string" ? config.app : "";
|
|
78
|
+
const admitGenerator = options.registry && options.appRoot
|
|
79
|
+
? makeGeneratorAdmitter({ registry: options.registry, app, appRoot: options.appRoot, ...(options.containment ? { containment: options.containment } : {}), ...(options.readBytes ? { readBytes: options.readBytes } : {}), ...(options.readText ? { readText: options.readText } : {}) })
|
|
80
|
+
: undefined;
|
|
81
|
+
for (const entry of collectConfigCommands(config)) {
|
|
82
|
+
const common = { scripts: options.scripts, stage: entry.stage, label: entry.pointer, admitGenerator };
|
|
83
|
+
const result = entry.string !== undefined ? admitHookString(entry.string, common) : admitInvocation(entry.step, common);
|
|
84
|
+
if (result.admitted) continue;
|
|
85
|
+
findings.push({
|
|
86
|
+
rule: ruleOfCode(result.code),
|
|
87
|
+
code: result.code,
|
|
88
|
+
pointer: entry.pointer,
|
|
89
|
+
line: options.lineOf ? options.lineOf(entry.pointer) : 1,
|
|
90
|
+
stage: entry.stage,
|
|
91
|
+
ownership: ownershipOfCode(result.code),
|
|
92
|
+
message: result.reason,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return findings;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatFindings(findings, file = "right-release.config.json") {
|
|
99
|
+
return findings.map((finding) => `${file}:${finding.line} [${finding.code}] ${finding.stage} (${finding.ownership}) ${finding.pointer}: ${finding.message}`);
|
|
100
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// M24 R4: the typed shared-generator registry.
|
|
2
|
+
//
|
|
3
|
+
// Product-source generation (an icon pass, a schema codegen, an engine stage) may sit inside a pipeline only as an explicitly admitted
|
|
4
|
+
// shared step. Each registry entry binds one generator to its script, its executable dependency closure (every file the script loads),
|
|
5
|
+
// its interpreter, its exact argument vector and its I/O limits, by sha256. An app cannot register one: the registry is a RightKit file
|
|
6
|
+
// (`pipeline-generators.json`), so a new capability is a RightKit change. An admitted generator can reach no network and perform no
|
|
7
|
+
// signing, installation or upload; the registry refuses an entry that claims otherwise, and the static check below refuses a
|
|
8
|
+
// generator source that names a pipeline tool or dispatches RightKit again (a generator may never start another pipeline).
|
|
9
|
+
//
|
|
10
|
+
// Static review is NOT a sandbox. The process, network and output limits are enforced by native containment, which this package does
|
|
11
|
+
// not yet implement for any platform: `detectContainment` reports it unsupported, and an unsupported containment REFUSES the
|
|
12
|
+
// generator (the target stays unqualified) instead of running it unconfined. Callers inject a real containment to admit one.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dispatchesRightKit, scanTextForPipelineTools, segmentTokens, splitSegments } from "./pipeline-tools.mjs";
|
|
18
|
+
|
|
19
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
export const GENERATOR_REGISTRY_FILE = "pipeline-generators.json";
|
|
21
|
+
export const GENERATOR_INTERPRETERS = Object.freeze(["node"]);
|
|
22
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
23
|
+
const ID = /^[a-z][a-z0-9-]{1,63}$/;
|
|
24
|
+
const APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
25
|
+
const ENTRY_KEYS = ["id", "app", "interpreter", "script", "scriptSha256", "args", "closure", "inputs", "outputs", "limits", "network"];
|
|
26
|
+
const LIMIT_KEYS = ["maxOutputBytes", "maxProcesses", "timeoutMs"];
|
|
27
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
|
|
29
|
+
export class GeneratorRegistryError extends Error {
|
|
30
|
+
constructor(message) { super(message); this.name = "GeneratorRegistryError"; this.code = "RK_GENERATOR_REGISTRY"; }
|
|
31
|
+
}
|
|
32
|
+
const bad = (message) => { throw new GeneratorRegistryError(message); };
|
|
33
|
+
|
|
34
|
+
function relativePath(value, label) {
|
|
35
|
+
if (typeof value !== "string" || !value.trim() || path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.includes("\\")) bad(`${label} must be a forward-slash relative path`);
|
|
36
|
+
const parts = value.replace(/^(?:\.\/)+/, "").split("/");
|
|
37
|
+
if (parts.includes("..") || parts.includes("") || parts.includes(".")) bad(`${label} must stay inside the app: ${value}`);
|
|
38
|
+
return parts.join("/");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validate one registry entry; returns the normalised entry. */
|
|
42
|
+
export function validateGeneratorEntry(entry, label = "generator") {
|
|
43
|
+
if (!isObject(entry)) bad(`${label} must be an object`);
|
|
44
|
+
const unknown = Object.keys(entry).filter((key) => !ENTRY_KEYS.includes(key));
|
|
45
|
+
if (unknown.length) bad(`${label} has unknown key(s): ${unknown.join(", ")}`);
|
|
46
|
+
if (typeof entry.id !== "string" || !ID.test(entry.id)) bad(`${label}.id must be a lowercase id`);
|
|
47
|
+
if (typeof entry.app !== "string" || !APP.test(entry.app)) bad(`${label}.app must name the registered app`);
|
|
48
|
+
if (!GENERATOR_INTERPRETERS.includes(entry.interpreter)) bad(`${label}.interpreter must be one of ${GENERATOR_INTERPRETERS.join(", ")}`);
|
|
49
|
+
const script = relativePath(entry.script, `${label}.script`);
|
|
50
|
+
if (typeof entry.scriptSha256 !== "string" || !SHA256.test(entry.scriptSha256)) bad(`${label}.scriptSha256 must be a lowercase sha256`);
|
|
51
|
+
if (!Array.isArray(entry.args) || entry.args.some((arg) => typeof arg !== "string" || /[\0\r\n]/.test(arg))) bad(`${label}.args must be the exact argument vector (a list of strings)`);
|
|
52
|
+
if (!Array.isArray(entry.closure)) bad(`${label}.closure must list every executable dependency of the script (an empty list for none)`);
|
|
53
|
+
const closure = entry.closure.map((item, index) => {
|
|
54
|
+
if (!isObject(item) || Object.keys(item).some((key) => key !== "path" && key !== "sha256")) bad(`${label}.closure[${index}] must be { path, sha256 }`);
|
|
55
|
+
if (typeof item.sha256 !== "string" || !SHA256.test(item.sha256)) bad(`${label}.closure[${index}].sha256 must be a lowercase sha256`);
|
|
56
|
+
return { path: relativePath(item.path, `${label}.closure[${index}].path`), sha256: item.sha256 };
|
|
57
|
+
});
|
|
58
|
+
for (const key of ["inputs", "outputs"]) {
|
|
59
|
+
if (!Array.isArray(entry[key]) || entry[key].length === 0) bad(`${label}.${key} must be a non-empty list of app-relative globs (the I/O limit)`);
|
|
60
|
+
for (const [index, glob] of entry[key].entries()) {
|
|
61
|
+
if (typeof glob !== "string" || glob.includes("..") || path.isAbsolute(glob) || /^[A-Za-z]:/.test(glob) || glob.includes("\\")) bad(`${label}.${key}[${index}] must be an app-relative forward-slash glob`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!isObject(entry.limits) || Object.keys(entry.limits).some((key) => !LIMIT_KEYS.includes(key))) bad(`${label}.limits must be an object with ${LIMIT_KEYS.join(", ")}`);
|
|
65
|
+
for (const key of LIMIT_KEYS) if (!Number.isInteger(entry.limits[key]) || entry.limits[key] <= 0) bad(`${label}.limits.${key} must be a positive integer`);
|
|
66
|
+
if (entry.network !== false) bad(`${label}.network must be false: an admitted generator reaches no network, and cannot sign, install or upload`);
|
|
67
|
+
return { ...entry, script, closure, args: [...entry.args], inputs: [...entry.inputs], outputs: [...entry.outputs] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Load and validate the registry. A missing or malformed file throws: the shipped registry always exists (empty means no generator is admitted). */
|
|
71
|
+
export function loadGeneratorRegistry(file = path.join(here, GENERATOR_REGISTRY_FILE), { readText = (target) => readFileSync(target, "utf8") } = {}) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try { parsed = JSON.parse(readText(file)); } catch (error) { bad(`the generator registry ${file} cannot be read: ${error.message}`); }
|
|
74
|
+
if (!isObject(parsed) || parsed.schema !== 1 || !Array.isArray(parsed.generators)) bad(`${file} must be { schema: 1, generators: [...] }`);
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const generators = parsed.generators.map((entry, index) => {
|
|
77
|
+
const valid = validateGeneratorEntry(entry, `generators[${index}]`);
|
|
78
|
+
if (seen.has(valid.id)) bad(`generators[${index}] repeats the id ${valid.id}`);
|
|
79
|
+
seen.add(valid.id);
|
|
80
|
+
return valid;
|
|
81
|
+
});
|
|
82
|
+
return { schema: 1, generators };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Native containment for a generator (no network, bounded output and processes). None is implemented yet: report it, never fake it. */
|
|
86
|
+
export function detectContainment({ platform = process.platform } = {}) {
|
|
87
|
+
return { supported: false, mechanism: null, reason: `no native containment (network-, output- and process-restricted) is implemented for ${platform}; the generator is refused and the target stays unqualified` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sha256File = (file, readBytes) => createHash("sha256").update(readBytes(file)).digest("hex");
|
|
91
|
+
|
|
92
|
+
/** Static refusals on a generator file's text: a pipeline tool, or RightKit dispatched again. */
|
|
93
|
+
export function generatorSourceProblems(text, label) {
|
|
94
|
+
const problems = [];
|
|
95
|
+
for (const hit of scanTextForPipelineTools(text, { kind: "code" })) problems.push(`${label}:${hit.line} runs ${hit.tool} (${hit.stage}); a generator cannot sign, install or upload`);
|
|
96
|
+
for (const [index, line] of String(text).split(/\r?\n/).entries()) {
|
|
97
|
+
for (const literal of line.matchAll(/["'`]([^"'`\n]{1,200})["'`]/g)) {
|
|
98
|
+
const tokens = segmentTokens(splitSegments(literal[1])[0] ?? "");
|
|
99
|
+
if (tokens.length && dispatchesRightKit(tokens)) problems.push(`${label}:${index + 1} dispatches RightKit; a generator cannot start another pipeline`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return problems;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Is `node <script> <args...>` an admitted generator for `app`? Returns `{ admitted: true, id }` or `{ admitted: false, code, reason }`.
|
|
107
|
+
* Revalidates the files at the moment of the call (a hash recorded earlier is not enough). All file access and the containment
|
|
108
|
+
* probe are injectable.
|
|
109
|
+
*/
|
|
110
|
+
export function admitGeneratorScript({ registry, app, appRoot, script, args = [], containment = detectContainment, readBytes = (file) => readFileSync(file), readText = (file) => readFileSync(file, "utf8") }) {
|
|
111
|
+
const refuse = (code, reason) => ({ admitted: false, code, reason });
|
|
112
|
+
const entries = (registry?.generators ?? []).filter((entry) => entry.app === app && entry.script === script);
|
|
113
|
+
if (entries.length === 0) return refuse("R4-unregistered-generator", `no registered generator for ${app} runs ${script}; apps cannot self-register, a new generator is a RightKit change`);
|
|
114
|
+
const entry = entries.find((candidate) => candidate.args.length === args.length && candidate.args.every((arg, index) => arg === args[index]));
|
|
115
|
+
if (!entry) return refuse("R4-arguments", `${script}: the argument vector differs from the registered one; only the exact registered arguments are admitted`);
|
|
116
|
+
const contained = containment();
|
|
117
|
+
if (!contained?.supported) return refuse("R4-containment-unsupported", contained?.reason ?? "native containment is not available");
|
|
118
|
+
const files = [{ path: entry.script, sha256: entry.scriptSha256 }, ...entry.closure];
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const absolute = path.resolve(appRoot, file.path);
|
|
121
|
+
let digest;
|
|
122
|
+
try { digest = sha256File(absolute, readBytes); } catch { return refuse("R4-missing-file", `${file.path} (registered for ${entry.id}) cannot be read`); }
|
|
123
|
+
if (digest !== file.sha256) return refuse("R4-digest-mismatch", `${file.path} does not match the registered digest for ${entry.id}; the generator changed after review`);
|
|
124
|
+
if (/\.(?:[cm]?js|ts)$/i.test(file.path)) {
|
|
125
|
+
let text;
|
|
126
|
+
try { text = readText(absolute); } catch { return refuse("R4-missing-file", `${file.path} cannot be read`); }
|
|
127
|
+
const problems = generatorSourceProblems(text, file.path);
|
|
128
|
+
if (problems.length) return refuse("R4-forbidden-effect", problems.join("; "));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { admitted: true, id: entry.id, interpreter: entry.interpreter, limits: entry.limits, containment: contained.mechanism };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The `admitGenerator` option for `admitInvocation`, bound to one app. */
|
|
135
|
+
export function makeGeneratorAdmitter({ registry, app, appRoot, ...injected }) {
|
|
136
|
+
return ({ script, args }) => admitGeneratorScript({ registry, app, appRoot, script, args, ...injected });
|
|
137
|
+
}
|