@rightkit/release 0.2.151 → 0.2.153
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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +117 -8
- package/build-release.mjs +7 -2
- package/cli/right-release.mjs +18 -5
- package/committed-source.mjs +19 -1
- package/crate-graph.mjs +121 -0
- package/development-signing.mjs +8 -0
- package/development.mjs +46 -13
- package/devtools-policy.json +5 -0
- package/devtools-policy.mjs +119 -0
- package/direct-bootstrap.mjs +9 -3
- package/dmg-build.mjs +19 -5
- package/dmg-layout.mjs +2 -2
- package/dmg-spec.mjs +97 -0
- package/execution-policy.mjs +17 -1
- package/github-release.mjs +25 -7
- package/github-upload-gate.mjs +84 -0
- package/hardeningscan.mjs +3 -0
- package/inventory-check.mjs +4 -4
- package/mac-bundle-signing.mjs +1 -1
- package/mac-sign-core.mjs +97 -0
- package/mac-signing-matrix.mjs +186 -0
- package/msix-appinstaller.mjs +4 -1
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/ownership-catalog-config.json +6 -7
- package/ownership-command.mjs +69 -8
- package/ownership-evidence.mjs +1 -1
- package/ownership-key-authority.mjs +1 -1
- package/ownership-key-command.mjs +15 -3
- package/ownership-key-elevation.mjs +11 -2
- package/ownership-mandatory-set.json +420 -0
- package/ownership-mandatory-set.mjs +273 -0
- package/ownership.mjs +2 -2
- package/package.json +15 -3
- package/packaged-app-identity.mjs +88 -0
- package/preflight.mjs +25 -3
- package/prune-r2.mjs +28 -3
- package/publication-gate.mjs +49 -10
- package/publish-cargo.mjs +21 -1
- package/publish-swift.mjs +78 -141
- package/publish-update.mjs +24 -2
- package/qa-build-features.mjs +216 -0
- package/qa-control-scan.mjs +435 -0
- package/registry-parity.mjs +18 -1
- package/release-invocation.mjs +2 -1
- package/release-state.mjs +9 -0
- package/release-switch-registry.json +33 -0
- package/release-switch-scan.mjs +452 -0
- package/release.mjs +320 -26
- package/renamed-away.mjs +29 -0
- package/rightkit-ownership.json +682 -709
- package/rightkit-sync.mjs +8 -4
- package/rightkit-version-policy.mjs +18 -0
- package/rightkit-versions.json +211 -127
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +870 -0
- package/runtime-bundle-scan.mjs +61 -0
- package/sign-macos.mjs +28 -30
- package/sign-updater.mjs +5 -2
- package/swift-mac-dmg.mjs +7 -2
- package/swift-package-command.mjs +1 -1
- package/upload-release.mjs +10 -37
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Release-time enforcement of M23 ("release code cannot use env/settings to select a test, data-root,
|
|
2
|
+
// executable, library or identity") and of the release half of M21 ("exclude the test switch from release
|
|
3
|
+
// builds"). Provisional owner decision D4: `rightkit_daemon::mode` is the runtime decision (a release build
|
|
4
|
+
// reads no override selector at all); this module is the proof, at release time, outside the app.
|
|
5
|
+
//
|
|
6
|
+
// Three layers, one vocabulary (selector classes S1-S6, see `README.md` "Release switch scan"):
|
|
7
|
+
//
|
|
8
|
+
// 1. Feature closure (source layer, AUTHORITATIVE). release.mjs runs `cargo tree -e features,no-dev --locked` in the
|
|
9
|
+
// app repository (`resolveFeatureClosure`) and fails closed when it cannot, so a build whose graph enables a
|
|
10
|
+
// forbidden feature anywhere, including by a dependent's feature unification, is refused. The binary marker below
|
|
11
|
+
// is secondary evidence only (a linker may discard unreferenced data).
|
|
12
|
+
// Original description: `cargo tree -e features --target <triple>` output must not enable a
|
|
13
|
+
// forbidden feature anywhere in the resolved graph (`featureClosureFindings`). Declared feature names alone
|
|
14
|
+
// are not enough: a transitive crate can switch `test-mode` on. Finding id: `forbidden-feature <name>`.
|
|
15
|
+
// 2. Inventory completeness (source layer). Every app commits `switches.json` (schema below). The source walker
|
|
16
|
+
// (`sourceSwitchFindings`) finds every environment read in app source and rejects any not in the inventory,
|
|
17
|
+
// any S1-S4 inventory entry that is not compiled out of release builds, and any S5/S6 entry that breaks its
|
|
18
|
+
// class rule. Finding ids: `unregistered-read`, `dynamic-read`, `not-compiled-out`, `inventory-invalid`.
|
|
19
|
+
// 3. Binary layer. Native binaries must not carry the test-mode marker (`test-mode-capability`) nor any
|
|
20
|
+
// registry or app-declared deny name from classes S1-S4 (`release-switch-names`), matched as Latin-1/UTF-8
|
|
21
|
+
// and as UTF-16LE (Windows wide strings). The scan returns a receipt: sha256 of every scanned file.
|
|
22
|
+
//
|
|
23
|
+
// Not claimed: packed installers (NSIS, DMG, compressed MSIX members) are covered only by the pre-pack scan of
|
|
24
|
+
// the staged payload; symlinks are skipped; browser JS is checked at the source layer only; argv flags are
|
|
25
|
+
// enforced only for deny-listed flags (a generic walker cannot tell a CLI definition from a hidden test flag).
|
|
26
|
+
//
|
|
27
|
+
// `switches.json` schema (one per app, committed at the repository root or `src-tauri/`):
|
|
28
|
+
// {
|
|
29
|
+
// "schema": 1,
|
|
30
|
+
// "app": "viewright",
|
|
31
|
+
// "switches": [
|
|
32
|
+
// { "name": "HEARDRIGHT_ENGINE_TEST_MODE", // env var, argv flag, settings key or VITE_* name
|
|
33
|
+
// "class": "S1", // S1 test activation | S2 authority/identity |
|
|
34
|
+
// // S3 executable/library/model | S4 state/data root |
|
|
35
|
+
// // S5 user-content location | S6 diagnostics/tuning
|
|
36
|
+
// "channel": "env", // env | argv | settings | preference | frontend
|
|
37
|
+
// "anchor": "src/test_mode.rs:97", // where it is read (informational)
|
|
38
|
+
// "compiledOut": "feature:test-mode", // required for S1-S4: feature:<name> | cfg:test |
|
|
39
|
+
// // cfg:debug_assertions (read exists only there)
|
|
40
|
+
// "valueType": "bool", // required for S6: bool | int | enum
|
|
41
|
+
// "userGesture": true, // required for S5: chosen by a user gesture
|
|
42
|
+
// "note": "free text" } ],
|
|
43
|
+
// "dynamicReads": [ { "file": "src/x.rs", "reason": "..." } ] // optional: files allowed a non-literal read
|
|
44
|
+
// }
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
46
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, readdirSync } from "node:fs";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import { spawnSync } from "./background-process.mjs";
|
|
49
|
+
import { featuresInTokens, resolveCargoManifestPath, scriptTarget, tokenize } from "./qa-build-features.mjs";
|
|
50
|
+
import { isNativeBinaryHeader } from "./qa-control-scan.mjs";
|
|
51
|
+
|
|
52
|
+
export const TEST_MODE_MARKER = "RIGHTKIT-TEST-MODE-COMPILED"; // identical to `TEST_MODE_MARKER_TEXT` in rightkit-daemon/src/mode.rs
|
|
53
|
+
const CHUNK = 4 * 1024 * 1024;
|
|
54
|
+
const FORBIDDEN_CLASSES = new Set(["S1", "S2", "S3", "S4"]);
|
|
55
|
+
const CLASSES = new Set(["S1", "S2", "S3", "S4", "S5", "S6"]);
|
|
56
|
+
const CHANNELS = new Set(["env", "argv", "settings", "preference", "frontend"]);
|
|
57
|
+
const COMPILED_OUT = /^(feature:[A-Za-z0-9_-]+|cfg:test|cfg:debug_assertions)$/;
|
|
58
|
+
|
|
59
|
+
export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-registry.json", import.meta.url), "utf8"));
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
62
|
+
// Layer 1: feature closure.
|
|
63
|
+
|
|
64
|
+
// `cargo tree -e features` lines look like: `├── rightkit-daemon feature "test-mode"` (possibly `(*)` suffixed).
|
|
65
|
+
const FEATURE_LINE = /^[\s│├└─|`+\\-]*([A-Za-z0-9_][A-Za-z0-9_-]*)(?: v[^\s]+)? feature "([^"]+)"/;
|
|
66
|
+
|
|
67
|
+
export function featureClosureFindings(treeText, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
|
|
68
|
+
const deny = new Set(forbidden);
|
|
69
|
+
const findings = []; const seen = new Set();
|
|
70
|
+
for (const line of String(treeText).split(/\r?\n/)) {
|
|
71
|
+
const m = FEATURE_LINE.exec(line);
|
|
72
|
+
if (!m || !deny.has(m[2])) continue;
|
|
73
|
+
const id = `forbidden-feature ${m[2]}`;
|
|
74
|
+
const key = `${m[1]}/${m[2]}`;
|
|
75
|
+
if (seen.has(key)) continue;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
findings.push({ layer: "feature-closure", rule: id, file: `<cargo tree>`, reason: `${id}: crate \`${m[1]}\` has feature \`${m[2]}\` enabled in the resolved graph` });
|
|
78
|
+
}
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// M7 lane E: rightkit-daemon `remote` (the HTTPS host with tokio, axum and hyper) may enter a release graph only for an app
|
|
83
|
+
// that declares `releaseSwitches.remoteHost: true` (the Hetzner Pulse and PostRight server builds). `testing` implies `remote`,
|
|
84
|
+
// so it is caught by the same line. Fail-closed: a value other than a boolean throws, and `false` or an absent key refuses.
|
|
85
|
+
export const REMOTE_SERVICE_CRATE = "rightkit-daemon";
|
|
86
|
+
export const REMOTE_SERVICE_FEATURE = "remote";
|
|
87
|
+
export function remoteServiceFindings(treeText, { remoteHost = false } = {}) {
|
|
88
|
+
if (typeof remoteHost !== "boolean") throw new Error(`releaseSwitches.remoteHost must be true or false, got ${JSON.stringify(remoteHost)}`);
|
|
89
|
+
if (remoteHost) return [];
|
|
90
|
+
const findings = []; const seen = new Set();
|
|
91
|
+
for (const line of String(treeText).split(/\r?\n/)) {
|
|
92
|
+
const m = FEATURE_LINE.exec(line);
|
|
93
|
+
if (!m || m[1] !== REMOTE_SERVICE_CRATE || m[2] !== REMOTE_SERVICE_FEATURE || seen.has(m[1])) continue;
|
|
94
|
+
seen.add(m[1]);
|
|
95
|
+
const id = "remote-feature-undeclared";
|
|
96
|
+
findings.push({ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_SERVICE_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteHost: true (only Pulse and PostRight server builds may enable it)` });
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A build command (shell string or { cmd, args }) as one flat token list, whole arguments plus their shell re-tokenisation.
|
|
102
|
+
export function commandTokens(command) {
|
|
103
|
+
const parts = typeof command === "string" ? [command] : [command?.cmd, ...(command?.args ?? [])].filter((v) => v !== undefined).map(String);
|
|
104
|
+
return [...parts, ...parts.flatMap((p) => tokenize(p))];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readPackageScripts(root) {
|
|
108
|
+
try { return JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {}; } catch { return {}; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Run `cargo tree -e features,no-dev --locked` for the release target in the app repository (cargo is permitted in app
|
|
112
|
+
// repos; right-release runs there). The build commands' `--features`/`--no-default-features` are forwarded so the closure
|
|
113
|
+
// is the one the command builds; features are also taken from package.json scripts a command runs (`pnpm run <script>`,
|
|
114
|
+
// up to three levels, the same walk as `commandFeatureIssues`), so a hook that is only `pnpm tauri:build` still counts.
|
|
115
|
+
// Choice: parse the resolved script bodies (not a mandatory `releaseSwitches.features`); a feature list hidden in a script
|
|
116
|
+
// shape this walker cannot read (a shell script file) is still caught by the binary layer, and an app may declare extra
|
|
117
|
+
// features in `releaseSwitches.features`, which are always forwarded.
|
|
118
|
+
// Manifest: `cargoManifest` (targets.<os>.cargoManifest) -> `manifest` (releaseSwitches.manifest) -> src-tauri/Cargo.toml
|
|
119
|
+
// -> Cargo.toml. A declared path must exist. With no manifest the gate FAILS (`release-switch-manifest-missing`); the only
|
|
120
|
+
// skip is `rust === false` (releaseSwitches.rust: false, an explicit "this app has no Rust") and only when no candidate
|
|
121
|
+
// manifest exists. Returns { treeText, manifest } or { skipped: <reason> }. Throws when cargo fails: the caller fails closed.
|
|
122
|
+
// `run` is injectable for tests: (command, args, options) => { status, stdout, stderr, error }.
|
|
123
|
+
export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [], run = spawnSync } = {}) {
|
|
124
|
+
const { rel, declared, candidates } = resolveCargoManifestPath({ root, cargoManifest, manifest });
|
|
125
|
+
if (!rel) {
|
|
126
|
+
if (rust === false) return { skipped: "releaseSwitches.rust is false: the app declares no Rust" };
|
|
127
|
+
throw new Error(`release-switch-manifest-missing: no Cargo manifest found for ${root} (tried ${candidates.join(", ")}${declared.length ? `; declared: ${declared.join(", ")}` : ""}). A Rust app must resolve its manifest via targets.<os>.cargoManifest or releaseSwitches.manifest; an app with no Rust must set releaseSwitches.rust: false.`);
|
|
128
|
+
}
|
|
129
|
+
if (declared.length && !declared.some((c) => existsSync(path.join(root, c)))) {
|
|
130
|
+
throw new Error(`release-switch-manifest-missing: declared manifest ${declared.join(", ")} does not exist under ${root}`);
|
|
131
|
+
}
|
|
132
|
+
const baseArgs = ["tree", "-e", "features,no-dev", "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
|
|
133
|
+
const pkgScripts = scripts ?? readPackageScripts(root);
|
|
134
|
+
// One cargo tree per distinct (features, no-default-features, manifest) combination, one combination per build command
|
|
135
|
+
// (flags gathered over the command and the package.json scripts it runs). A `--no-default-features` in one command never
|
|
136
|
+
// changes the graph checked for another, and test-mode in ANY graph fails the gate.
|
|
137
|
+
const combos = new Map();
|
|
138
|
+
const comboFor = (text) => {
|
|
139
|
+
const expanded = [];
|
|
140
|
+
const expand = (t, depth, seen) => {
|
|
141
|
+
expanded.push(t);
|
|
142
|
+
const script = scriptTarget(commandTokens(t));
|
|
143
|
+
if (script && depth < 3 && Object.hasOwn(pkgScripts, script) && !seen.has(script)) expand(String(pkgScripts[script]), depth + 1, new Set([...seen, script]));
|
|
144
|
+
};
|
|
145
|
+
expand(text, 0, new Set());
|
|
146
|
+
let noDefault = false;
|
|
147
|
+
const enabled = [...features];
|
|
148
|
+
for (const t of expanded) {
|
|
149
|
+
const tokens = commandTokens(t);
|
|
150
|
+
if (tokens.includes("--no-default-features")) noDefault = true;
|
|
151
|
+
enabled.push(...featuresInTokens(tokens).enabled);
|
|
152
|
+
}
|
|
153
|
+
const list = [...new Set(enabled)].sort();
|
|
154
|
+
combos.set(`${rel}|${noDefault}|${list.join(",")}`, { noDefault, list });
|
|
155
|
+
};
|
|
156
|
+
const texts = commandTexts.filter(Boolean);
|
|
157
|
+
if (texts.length === 0) comboFor("");
|
|
158
|
+
for (const text of texts) comboFor(text);
|
|
159
|
+
const trees = [];
|
|
160
|
+
for (const { noDefault, list } of combos.values()) {
|
|
161
|
+
const args = [...baseArgs];
|
|
162
|
+
if (noDefault) args.push("--no-default-features");
|
|
163
|
+
if (list.length) args.push("--features", list.join(","));
|
|
164
|
+
const result = run("cargo", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
165
|
+
if (result.error || result.status !== 0) {
|
|
166
|
+
throw new Error(`feature closure: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
|
|
167
|
+
}
|
|
168
|
+
const out = String(result.stdout ?? "");
|
|
169
|
+
if (!out.trim()) throw new Error("feature closure: `cargo tree` printed nothing; cannot verify");
|
|
170
|
+
trees.push(out);
|
|
171
|
+
}
|
|
172
|
+
const treeText = trees.join("\n");
|
|
173
|
+
return { treeText, trees, manifest: rel };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// S1-S4 names an app declares in its switches.json; the binary scan refuses every one (compiledOut claims are
|
|
177
|
+
// verified against the built binary, not trusted).
|
|
178
|
+
export function inventoryDenyNames(inventoryText) {
|
|
179
|
+
const { doc } = parseInventory(inventoryText);
|
|
180
|
+
return (doc?.switches ?? []).filter((e) => FORBIDDEN_CLASSES.has(e?.class) && typeof e?.name === "string" && e.name).map((e) => ({ name: e.name, class: e.class }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Findings for a build command (`--features test-mode`, `-F`, `--all-features`, ...). Uses the existing tokenizer.
|
|
184
|
+
export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
|
|
185
|
+
const deny = new Set(forbidden);
|
|
186
|
+
const tokens = argv;
|
|
187
|
+
const enabled = []; let all = false;
|
|
188
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
189
|
+
const t = tokens[i];
|
|
190
|
+
const take = (v) => { for (const p of String(v).split(/[,\s]+/)) if (p) enabled.push(p); };
|
|
191
|
+
if (t === "--all-features") all = true;
|
|
192
|
+
else if (["--features", "-F", "-f", "--feature"].includes(t)) { if (i + 1 < tokens.length) take(tokens[i + 1]); }
|
|
193
|
+
else if (t.startsWith("--features=")) take(t.slice("--features=".length));
|
|
194
|
+
else if (/^-F.+/.test(t)) take(t.slice(2));
|
|
195
|
+
}
|
|
196
|
+
const findings = [];
|
|
197
|
+
for (const item of enabled) {
|
|
198
|
+
const name = item.includes("/") ? item.slice(item.lastIndexOf("/") + 1) : item;
|
|
199
|
+
if (deny.has(name)) findings.push({ layer: "feature-closure", rule: `forbidden-feature ${name}`, file: "<command>", reason: `forbidden-feature ${name}: build command enables \`${item}\`` });
|
|
200
|
+
}
|
|
201
|
+
if (all) findings.push({ layer: "feature-closure", rule: "forbidden-feature all-features", file: "<command>", reason: "forbidden-feature all-features: build command enables every feature, including test-mode" });
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
206
|
+
// Layer 2: inventory completeness.
|
|
207
|
+
|
|
208
|
+
export function parseInventory(text, label = "switches.json") {
|
|
209
|
+
const issues = [];
|
|
210
|
+
let doc;
|
|
211
|
+
try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
|
|
212
|
+
const bad = (reason) => issues.push({ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${reason}` });
|
|
213
|
+
if (doc?.schema !== 1) bad("`schema` must be 1");
|
|
214
|
+
if (typeof doc?.app !== "string" || !doc.app) bad("`app` must be a non-empty string");
|
|
215
|
+
if (!Array.isArray(doc?.switches)) { bad("`switches` must be an array"); return { doc, issues }; }
|
|
216
|
+
const names = new Set();
|
|
217
|
+
for (const entry of doc.switches) {
|
|
218
|
+
const where = `switch \`${entry?.name}\``;
|
|
219
|
+
if (typeof entry?.name !== "string" || !entry.name) { bad("every switch needs a `name`"); continue; }
|
|
220
|
+
if (names.has(entry.name)) bad(`${where} is listed twice`);
|
|
221
|
+
names.add(entry.name);
|
|
222
|
+
if (!CLASSES.has(entry.class)) bad(`${where}: \`class\` must be one of S1..S6`);
|
|
223
|
+
if (!CHANNELS.has(entry.channel)) bad(`${where}: \`channel\` must be one of ${[...CHANNELS].join(", ")}`);
|
|
224
|
+
if (FORBIDDEN_CLASSES.has(entry.class)) {
|
|
225
|
+
if (typeof entry.compiledOut !== "string" || !COMPILED_OUT.test(entry.compiledOut)) {
|
|
226
|
+
issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test or cfg:debug_assertions (release builds may not contain it)` });
|
|
227
|
+
}
|
|
228
|
+
} else if (entry.class === "S6") {
|
|
229
|
+
if (!["bool", "int", "enum"].includes(entry.valueType)) bad(`${where}: class S6 needs a bounded \`valueType\` (bool, int or enum)`);
|
|
230
|
+
} else if (entry.class === "S5") {
|
|
231
|
+
if (entry.userGesture !== true) bad(`${where}: class S5 needs \`userGesture: true\` (chosen by a picker, saved preference or the user's own argument)`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { doc, issues };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const SOURCE_EXT = new Set([".rs", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".vue", ".svelte", ".swift", ".c", ".cc", ".cpp", ".h", ".m", ".mm"]);
|
|
238
|
+
const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", ".git", "tests", "__tests__", "benches", "examples", "fixtures", "test", "coverage", ".vite"]);
|
|
239
|
+
const TEST_FILE = /(\.test\.|\.spec\.|_tests?\.rs$|^tests?\.rs$)/;
|
|
240
|
+
|
|
241
|
+
// Reads of a literal name; group 1 is the name.
|
|
242
|
+
const LITERAL_READS = [
|
|
243
|
+
/\b(?:std::)?env::var(?:_os)?\(\s*"([^"]+)"\s*\)/g,
|
|
244
|
+
/\b(?:option_)?env!\(\s*"([^"]+)"/g,
|
|
245
|
+
/\bgetenv\(\s*"([^"]+)"\s*\)/g,
|
|
246
|
+
/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
247
|
+
/process\.env\[\s*["']([^"']+)["']\s*\]/g,
|
|
248
|
+
/import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
249
|
+
/import\.meta\.env\[\s*["']([^"']+)["']\s*\]/g,
|
|
250
|
+
/environment\[\s*"([^"]+)"\s*\]/g,
|
|
251
|
+
// clap: `#[arg(env = "X")]`, `#[clap(env = "X")]`, `Arg::new(..).env("X")`.
|
|
252
|
+
/\benv\s*=\s*"([^"]+)"/g,
|
|
253
|
+
/\.env\(\s*"([^"]+)"\s*\)/g,
|
|
254
|
+
];
|
|
255
|
+
// Bare `var("X")` / `var_os("X")` after `use std::env::var;` or `use std::env::{var, ..}` (also `as` renames are
|
|
256
|
+
// caught by the import test below); applied only to files that import them.
|
|
257
|
+
const BARE_IMPORT = /\buse\s+(?:std|core)::env::(?:var(?:_os)?\b|\{[^}]*\bvar(?:_os)?\b[^}]*\})/;
|
|
258
|
+
const BARE_LITERAL = /(?<![\w:.])var(?:_os)?\(\s*"([^"]+)"\s*\)/g;
|
|
259
|
+
const BARE_DYNAMIC = /(?<![\w:.])var(?:_os)?\(\s*[^")\s]/g;
|
|
260
|
+
// Reads whose name is not a literal.
|
|
261
|
+
const DYNAMIC_READS = [
|
|
262
|
+
/\b(?:std::)?env::var(?:_os)?\(\s*[^")\s]/g,
|
|
263
|
+
/\b(?:std::)?env::vars(?:_os)?\(\s*\)/g,
|
|
264
|
+
/\bgetenv\(\s*[^")\s]/g,
|
|
265
|
+
/process\.env\[\s*[^"'\]\s]/g,
|
|
266
|
+
/import\.meta\.env\[\s*[^"'\]\s]/g,
|
|
267
|
+
];
|
|
268
|
+
// Builtins every JS runtime sets; they select nothing and are not part of the inventory.
|
|
269
|
+
const JS_BUILTINS = new Set(["MODE", "DEV", "PROD", "BASE_URL", "SSR", "NODE_ENV"]);
|
|
270
|
+
|
|
271
|
+
function* walkSource(root) {
|
|
272
|
+
const stack = [root];
|
|
273
|
+
while (stack.length) {
|
|
274
|
+
const dir = stack.pop();
|
|
275
|
+
let entries;
|
|
276
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
const full = path.join(dir, entry.name);
|
|
279
|
+
if (entry.isSymbolicLink()) continue;
|
|
280
|
+
if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) stack.push(full); continue; }
|
|
281
|
+
if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name)) && !TEST_FILE.test(entry.name)) yield full;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function lineOf(text, index) { let n = 1; for (let i = 0; i < index; i += 1) if (text.charCodeAt(i) === 10) n += 1; return n; }
|
|
287
|
+
|
|
288
|
+
// inventoryText: contents of switches.json. Returns findings only.
|
|
289
|
+
export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
|
|
290
|
+
const { doc, issues } = parseInventory(inventoryText, inventoryLabel);
|
|
291
|
+
const findings = [...issues];
|
|
292
|
+
if (!doc || !Array.isArray(doc.switches)) return findings;
|
|
293
|
+
const registered = new Set(doc.switches.map((s) => s.name));
|
|
294
|
+
const dynamicAllowed = new Set((doc.dynamicReads ?? []).map((d) => path.normalize(d.file)));
|
|
295
|
+
const reads = new Set();
|
|
296
|
+
const denyFlags = [...REGISTRY.denyNames, ...extraDeny.map((n) => ({ name: typeof n === "string" ? n : n.name }))].map((d) => d.name).filter((n) => n.startsWith("-"));
|
|
297
|
+
for (const file of walkSource(sourceRoot)) {
|
|
298
|
+
let text;
|
|
299
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
300
|
+
const rel = path.relative(sourceRoot, file);
|
|
301
|
+
const bare = BARE_IMPORT.test(text) && /\.rs$/.test(file);
|
|
302
|
+
for (const pattern of bare ? [...LITERAL_READS, BARE_LITERAL] : LITERAL_READS) {
|
|
303
|
+
pattern.lastIndex = 0;
|
|
304
|
+
for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
|
|
305
|
+
const name = m[1];
|
|
306
|
+
if (JS_BUILTINS.has(name) && /\.(ts|tsx|js|mjs|cjs|jsx|vue|svelte)$/.test(file)) continue;
|
|
307
|
+
reads.add(name);
|
|
308
|
+
if (!registered.has(name)) findings.push({ layer: "inventory", rule: "unregistered-read", file: `${rel}:${lineOf(text, m.index)}`, reason: `unregistered-read: \`${name}\` is read here but is not listed in ${inventoryLabel}` });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!dynamicAllowed.has(path.normalize(rel))) {
|
|
312
|
+
for (const pattern of bare ? [...DYNAMIC_READS, BARE_DYNAMIC] : DYNAMIC_READS) {
|
|
313
|
+
pattern.lastIndex = 0;
|
|
314
|
+
for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
|
|
315
|
+
findings.push({ layer: "inventory", rule: "dynamic-read", file: `${rel}:${lineOf(text, m.index)}`, reason: `dynamic-read: an environment read with a non-literal name cannot be inventoried; list the file under \`dynamicReads\` in ${inventoryLabel} with a reason, or use a literal name` });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const flag of denyFlags) {
|
|
320
|
+
const literal = `"${flag}"`;
|
|
321
|
+
for (let at = text.indexOf(literal); at >= 0; at = text.indexOf(literal, at + 1)) {
|
|
322
|
+
reads.add(flag);
|
|
323
|
+
if (!registered.has(flag)) findings.push({ layer: "inventory", rule: "unregistered-read", file: `${rel}:${lineOf(text, at)}`, reason: `unregistered-read: deny-listed flag \`${flag}\` appears here but is not listed in ${inventoryLabel}` });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return findings;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
331
|
+
// Layer 3: binary layer.
|
|
332
|
+
|
|
333
|
+
export function denyNamesFor({ denyNames = REGISTRY.denyNames, appForbid = [] } = {}) {
|
|
334
|
+
const out = new Map();
|
|
335
|
+
for (const entry of denyNames) out.set(entry.name, entry.class ?? "S1");
|
|
336
|
+
for (const item of appForbid) {
|
|
337
|
+
const name = typeof item === "string" ? item : item.name;
|
|
338
|
+
if (name && !out.has(name)) out.set(name, typeof item === "string" ? "S1" : item.class ?? "S1");
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function needlesFor(denyMap) {
|
|
344
|
+
const needles = [{ rule: "test-mode-capability", name: TEST_MODE_MARKER, buffers: [Buffer.from(TEST_MODE_MARKER, "latin1"), Buffer.from(TEST_MODE_MARKER, "utf16le")] }];
|
|
345
|
+
for (const [name, cls] of denyMap) needles.push({ rule: "release-switch-names", name, cls, buffers: [Buffer.from(name, "latin1"), Buffer.from(name, "utf16le")] });
|
|
346
|
+
return needles;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
class SwitchScanner {
|
|
350
|
+
constructor(denyMap) {
|
|
351
|
+
this.needles = needlesFor(denyMap);
|
|
352
|
+
this.max = Math.max(...this.needles.flatMap((n) => n.buffers.map((b) => b.length)));
|
|
353
|
+
this.tail = Buffer.alloc(0); this.hits = new Map();
|
|
354
|
+
}
|
|
355
|
+
push(chunk) {
|
|
356
|
+
const window = this.tail.length ? Buffer.concat([this.tail, chunk]) : chunk;
|
|
357
|
+
for (const needle of this.needles) {
|
|
358
|
+
if (this.hits.has(needle.name)) continue;
|
|
359
|
+
if (needle.buffers.some((b) => window.includes(b))) this.hits.set(needle.name, needle);
|
|
360
|
+
}
|
|
361
|
+
this.tail = Buffer.from(window.subarray(Math.max(0, window.length - (this.max - 1))));
|
|
362
|
+
}
|
|
363
|
+
findings(file) {
|
|
364
|
+
return [...this.hits.values()].map((n) => ({ layer: "binary", rule: n.rule, file, reason: n.rule === "test-mode-capability"
|
|
365
|
+
? `test-mode-capability: contains ${TEST_MODE_MARKER} (the \`test-mode\` capability is compiled in)`
|
|
366
|
+
: `release-switch-names: contains the ${n.cls} selector name \`${n.name}\` (release code must not read it)` }));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// One in-memory buffer (tests, sealed files). Non-native buffers yield no findings (use scanSwitchPaths for files).
|
|
371
|
+
export function bufferSwitchFindings(buffer, { denyMap = denyNamesFor(), file = "<buffer>" } = {}) {
|
|
372
|
+
const bytes = Buffer.from(buffer);
|
|
373
|
+
if (!isNativeBinaryHeader(bytes.subarray(0, 4))) return [];
|
|
374
|
+
const scanner = new SwitchScanner(denyMap);
|
|
375
|
+
for (let at = 0; at < bytes.length; at += CHUNK) scanner.push(bytes.subarray(at, Math.min(bytes.length, at + CHUNK)));
|
|
376
|
+
return scanner.findings(file);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function scanFile(file, denyMap) {
|
|
380
|
+
const fd = openSync(file, "r");
|
|
381
|
+
try {
|
|
382
|
+
const head = Buffer.alloc(4);
|
|
383
|
+
const got = readSync(fd, head, 0, 4, 0);
|
|
384
|
+
if (!isNativeBinaryHeader(head.subarray(0, got))) return null;
|
|
385
|
+
const scanner = new SwitchScanner(denyMap);
|
|
386
|
+
const hash = createHash("sha256");
|
|
387
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
388
|
+
let bytes = 0;
|
|
389
|
+
for (;;) {
|
|
390
|
+
const read = readSync(fd, buffer, 0, CHUNK, null);
|
|
391
|
+
if (read === 0) break;
|
|
392
|
+
const chunk = buffer.subarray(0, read);
|
|
393
|
+
hash.update(chunk); bytes += read; scanner.push(chunk);
|
|
394
|
+
}
|
|
395
|
+
return { sha256: hash.digest("hex"), bytes, findings: scanner.findings(file) };
|
|
396
|
+
} finally { closeSync(fd); }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// paths: files or directories. Returns { findings, receipt: { files: [{ file, sha256, bytes }], binaries } }.
|
|
400
|
+
export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
|
|
401
|
+
const findings = []; const files = [];
|
|
402
|
+
const visit = (target) => {
|
|
403
|
+
let st;
|
|
404
|
+
try { st = lstatSync(target); } catch { return; }
|
|
405
|
+
if (st.isSymbolicLink()) return;
|
|
406
|
+
if (st.isDirectory()) { for (const name of readdirSync(target).sort()) visit(path.join(target, name)); return; }
|
|
407
|
+
if (!st.isFile()) return;
|
|
408
|
+
const result = scanFile(target, denyMap);
|
|
409
|
+
if (!result) return;
|
|
410
|
+
files.push({ file: target, sha256: result.sha256, bytes: result.bytes });
|
|
411
|
+
findings.push(...result.findings);
|
|
412
|
+
};
|
|
413
|
+
for (const target of paths) visit(target);
|
|
414
|
+
return { findings, receipt: { files, binaries: files.length } };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
418
|
+
// Combined gate.
|
|
419
|
+
|
|
420
|
+
// Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
|
|
421
|
+
// found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
|
|
422
|
+
// layer independently; release.mjs never passes it.
|
|
423
|
+
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost } = {}) {
|
|
424
|
+
const findings = [];
|
|
425
|
+
const bypassed = new Set(bypass);
|
|
426
|
+
const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
|
|
427
|
+
if (!bypassed.has("feature-closure")) {
|
|
428
|
+
if (treeText != null) {
|
|
429
|
+
findings.push(...featureClosureFindings(treeText));
|
|
430
|
+
findings.push(...remoteServiceFindings(treeText, { remoteHost }));
|
|
431
|
+
}
|
|
432
|
+
for (const argv of commands) findings.push(...commandSwitchFindings(argv));
|
|
433
|
+
}
|
|
434
|
+
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, inventoryText, extraDeny: appForbid }));
|
|
435
|
+
let receipt = { files: [], binaries: 0 };
|
|
436
|
+
if (binaryPaths.length) {
|
|
437
|
+
const scanned = scanSwitchPaths(binaryPaths, { denyMap });
|
|
438
|
+
findings.push(...scanned.findings); receipt = scanned.receipt;
|
|
439
|
+
if (requireBinary && receipt.binaries === 0 && findings.length === 0) findings.push({ layer: "binary", rule: "unscannable", file: binaryPaths.join(", "), reason: "unscannable: no Mach-O, PE or ELF file found to scan; cannot verify" });
|
|
440
|
+
}
|
|
441
|
+
return { findings, receipt };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function assertNoReleaseSwitches(input, label = "release") {
|
|
445
|
+
const { findings, receipt } = releaseSwitchFindings(input);
|
|
446
|
+
if (findings.length === 0) return receipt;
|
|
447
|
+
throw new Error(
|
|
448
|
+
`${label}: release code must not contain or read a test, data-root, executable, library or identity selector; `
|
|
449
|
+
+ `${findings.length} finding(s):\n${findings.map((f) => `- ${f.file}: ${f.reason}`).join("\n")}\n`
|
|
450
|
+
+ "Build release and update tiers without the `test-mode` feature, and register every environment read in the app's switches.json.",
|
|
451
|
+
);
|
|
452
|
+
}
|