@rightkit/release 0.2.164 → 0.2.172
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 +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +14 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +78 -46
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +315 -13
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +141 -22
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/local-publish.mjs +11 -1
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +283 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +26 -0
- package/rightkit-versions.json +110 -62
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/swap-helper-bundle.mjs +105 -0
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
package/disk-policy.mjs
CHANGED
|
@@ -10,6 +10,12 @@ export const DEFAULT_POLICY_FILE = path.join(path.dirname(fileURLToPath(import.m
|
|
|
10
10
|
export function readDiskPolicy(file = DEFAULT_POLICY_FILE) {
|
|
11
11
|
const policy = JSON.parse(readFileSync(file, "utf8"));
|
|
12
12
|
if (policy.schema !== 1 || !Array.isArray(policy.writers)) throw new Error("disk policy: unsupported schema");
|
|
13
|
+
for (const w of policy.writers) {
|
|
14
|
+
// `escalate` lets a report-only cache sweep its idle children once it is over cap by `factor` (see planSweep).
|
|
15
|
+
if (!w.escalate) continue;
|
|
16
|
+
const e = w.escalate;
|
|
17
|
+
if (w.action !== "report" || w.unit !== "children" || !(e.factor >= 1) || !(e.staleDays >= 0)) throw new Error(`disk policy: ${w.id} escalate needs action report, unit children, factor >= 1, staleDays >= 0`);
|
|
18
|
+
}
|
|
13
19
|
return policy;
|
|
14
20
|
}
|
|
15
21
|
|
|
@@ -112,6 +118,20 @@ export function isInsideRoot(candidate, root) {
|
|
|
112
118
|
} catch { return false; }
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// True when a directory strictly between `literal` (the writer's literal root) and `target` is a symlink: a cache reached
|
|
122
|
+
// through a symlinked cache dir (some Mac caches now live on /Volumes/D) is never swept.
|
|
123
|
+
export function symlinkAncestor(target, literal) {
|
|
124
|
+
const rel = path.relative(literal, target);
|
|
125
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
126
|
+
const parts = rel.split(path.sep).slice(0, -1);
|
|
127
|
+
let cur = literal;
|
|
128
|
+
for (const part of parts) {
|
|
129
|
+
cur = path.join(cur, part);
|
|
130
|
+
try { if (lstatSync(cur).isSymbolicLink()) return true; } catch { return false; }
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
export function writerMatches(writer, ctx) {
|
|
116
136
|
const pattern = expandPolicyPath(writer.path, ctx);
|
|
117
137
|
return expandGlob(pattern);
|
|
@@ -160,9 +180,33 @@ export function ensureScratchRoots({ policy, platform = process.platform, env =
|
|
|
160
180
|
|
|
161
181
|
/** Top-level entries of each workspace root that are neither tracked by the workspace repo, nested git checkouts,
|
|
162
182
|
* nor on the allowlist. Symlinks are listed (`symlink: true`) but sweep never touches them. */
|
|
183
|
+
// Names that are always known at a workspace root, whatever the policy file says: agent instructions and agent state.
|
|
184
|
+
export const WORKSPACE_ALWAYS_ALLOWED = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", ".claude", ".codex", ".agent", ".agents", ".membrane", "cockpit", "contributions"];
|
|
185
|
+
const PROJECT_MARKERS = [".git", "package.json", "Cargo.toml"];
|
|
186
|
+
|
|
187
|
+
/** Host-local opt-in for moving workspace-unknown entries. Without it the target is report-only on every host. */
|
|
188
|
+
export function workspaceSweepFlagPath({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
189
|
+
if (platform === "win32" || platform === "win") {
|
|
190
|
+
if (!env.LOCALAPPDATA) return null;
|
|
191
|
+
return path.win32.join(env.LOCALAPPDATA, "Orthic Labs", "rightkit-release", "workspace-sweep-allowed");
|
|
192
|
+
}
|
|
193
|
+
return path.posix.join(home, "Library", "Application Support", "Orthic Labs", "rightkit-release", "workspace-sweep-allowed");
|
|
194
|
+
}
|
|
195
|
+
export function workspaceSweepAllowed(options = {}) {
|
|
196
|
+
const file = workspaceSweepFlagPath(options);
|
|
197
|
+
return Boolean(file) && existsSync(file);
|
|
198
|
+
}
|
|
199
|
+
export function setWorkspaceSweepAllowed(on, options = {}) {
|
|
200
|
+
const file = workspaceSweepFlagPath(options);
|
|
201
|
+
if (!file) throw new Error("disk: cannot place the host-local flag (LOCALAPPDATA is not set)");
|
|
202
|
+
if (on) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, `workspace-unknown sweep allowed on this host ${new Date().toISOString()}\n`); }
|
|
203
|
+
else renameSync(file, `${file}.revoked`);
|
|
204
|
+
return file;
|
|
205
|
+
}
|
|
206
|
+
|
|
163
207
|
export function scanWorkspaceRoots({ policy, platform = process.platform, env = process.env, home = os.homedir(), tracked = (r) => workspaceTracked(r, { platform }) } = {}) {
|
|
164
208
|
const key = platformKey(platform);
|
|
165
|
-
const allow = new Set((policy.workspaceAllowlist ?? []).map((n) => fold(n, key)));
|
|
209
|
+
const allow = new Set([...WORKSPACE_ALWAYS_ALLOWED, ...(policy.workspaceAllowlist ?? [])].map((n) => fold(n, key)));
|
|
166
210
|
const unknown = [];
|
|
167
211
|
const skipped = [];
|
|
168
212
|
for (const entry of policy.workspaceRoots?.[key] ?? []) {
|
|
@@ -177,10 +221,12 @@ export function scanWorkspaceRoots({ policy, platform = process.platform, env =
|
|
|
177
221
|
for (const name of names) {
|
|
178
222
|
const folded = fold(name, key);
|
|
179
223
|
if (allow.has(folded) || extra.has(folded) || trackedNames.has(folded)) continue;
|
|
224
|
+
if (/\.md$/i.test(name)) continue; // any markdown file at the root is documentation
|
|
180
225
|
const full = path.join(root, name);
|
|
181
226
|
let st;
|
|
182
227
|
try { st = lstatSync(full); } catch { continue; }
|
|
183
|
-
|
|
228
|
+
// a nested git checkout or any project directory (own package.json / Cargo.toml) is known
|
|
229
|
+
if (!st.isSymbolicLink() && st.isDirectory() && PROJECT_MARKERS.some((m) => existsSync(path.join(full, m)))) continue;
|
|
184
230
|
unknown.push({ root, name, path: full, symlink: st.isSymbolicLink(), mtimeMs: st.mtimeMs });
|
|
185
231
|
}
|
|
186
232
|
}
|
|
@@ -206,7 +252,7 @@ export function checkDisk({ policy, platform = process.platform, env = process.e
|
|
|
206
252
|
for (const w of writers) if (w.over) warnings.push(`${w.id} holds ${formatBytes(w.bytes)} over its ${formatBytes(w.capBytes)} cap`);
|
|
207
253
|
const scanned = scan({ policy, platform, env, home });
|
|
208
254
|
const unknown = scanned.unknown.map((u) => ({ root: u.root, name: u.name, path: u.path, symlink: u.symlink, bytes: u.symlink ? 0 : size(u.path), ageDays: Math.max(0, Math.floor((now - u.mtimeMs) / 86400_000)) }));
|
|
209
|
-
for (const u of unknown) warnings.push(`unknown top-level entry ${u.path} (${u.symlink ? "symlink" : formatBytes(u.bytes)}, ${u.ageDays}d old): track it
|
|
255
|
+
for (const u of unknown) warnings.push(`unknown top-level entry ${u.path} (${u.symlink ? "symlink" : formatBytes(u.bytes)}, ${u.ageDays}d old): track it or add it to workspaceAllowlist (sweep only moves it on a host that ran \`disk allow-workspace-sweep\`)`);
|
|
210
256
|
for (const s of scanned.skipped) warnings.push(`workspace root ${s.path}: tracked paths cannot be read from git, so unknown entries are not evaluated or swept`);
|
|
211
257
|
return { schema: 1, platform: key, floor, writers, unknown, warnings, warn: warnings.length > 0 };
|
|
212
258
|
}
|
|
@@ -272,18 +318,25 @@ export function gitTracksPath(target) {
|
|
|
272
318
|
return String(listed.stdout).length > 0;
|
|
273
319
|
}
|
|
274
320
|
|
|
275
|
-
export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays, openPaths, inUse, tracked = gitTracksPath, size = (p) => pathSize(p, { platform }), scan = scanWorkspaceRoots } = {}) {
|
|
321
|
+
export function planSweep({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), staleDays, openPaths, inUse, tracked = gitTracksPath, size = (p) => pathSize(p, { platform }), scan = scanWorkspaceRoots, workspaceSweep = workspaceSweepAllowed({ platform, env, home }) } = {}) {
|
|
276
322
|
const key = platformKey(platform);
|
|
277
323
|
const effectiveDays = staleDays ?? policy.staleDays;
|
|
278
324
|
const candidates = [];
|
|
279
325
|
const skipped = [];
|
|
280
326
|
const seen = new Set();
|
|
281
327
|
const lockCheck = inUse ?? (key === "win" ? windowsLockProbe : (p) => inUseByOpenPaths(p, openPaths));
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const cutoff = now - (staleDays ?? writer.staleDays ?? policy.staleDays) * 86400_000;
|
|
328
|
+
// Sweep writers, plus report writers with `escalate` when they are over cap by its factor (pure caches: idle children go).
|
|
329
|
+
for (const writer of policy.writers.filter((w) => w.platform === key && (w.action === "sweep" || w.escalate))) {
|
|
285
330
|
let roots; let literal;
|
|
286
331
|
try { roots = writerMatches(writer, { env, home }); literal = literalRoot(expandPolicyPath(writer.path, { env, home })); } catch { continue; }
|
|
332
|
+
// `--days N` overrides every writer; otherwise a writer may carry its own window (QA evidence: 3 days, caches: 2).
|
|
333
|
+
let window = staleDays ?? writer.staleDays ?? policy.staleDays;
|
|
334
|
+
if (writer.action !== "sweep") {
|
|
335
|
+
const total = roots.reduce((sum, r) => sum + size(r), 0);
|
|
336
|
+
if (!(total > writer.capBytes * writer.escalate.factor)) continue;
|
|
337
|
+
window = staleDays ?? writer.escalate.staleDays;
|
|
338
|
+
}
|
|
339
|
+
const cutoff = now - window * 86400_000;
|
|
287
340
|
const units = [];
|
|
288
341
|
for (const r of roots) {
|
|
289
342
|
if (writer.unit !== "children") { units.push({ unit: r, root: literal }); continue; }
|
|
@@ -302,6 +355,7 @@ export function planSweep({ policy, platform = process.platform, env = process.e
|
|
|
302
355
|
try { st = lstatSync(unit); } catch { continue; }
|
|
303
356
|
if (st.isSymbolicLink()) { skipped.push({ ...base, reason: "symlink" }); continue; }
|
|
304
357
|
if (!isInsideRoot(unit, root)) { skipped.push({ ...base, reason: "outside-root" }); continue; }
|
|
358
|
+
if (symlinkAncestor(unit, literal)) { skipped.push({ ...base, reason: "symlink-ancestor" }); continue; }
|
|
305
359
|
if (isProtectedPath(unit, policy)) { skipped.push({ ...base, reason: "protected" }); continue; }
|
|
306
360
|
if (writer.skipTracked && tracked(unit)) { skipped.push({ ...base, reason: "tracked" }); continue; }
|
|
307
361
|
if (hasFreshContent(unit, cutoff)) { skipped.push({ ...base, reason: "fresh" }); continue; }
|
|
@@ -309,6 +363,10 @@ export function planSweep({ policy, platform = process.platform, env = process.e
|
|
|
309
363
|
candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
|
|
310
364
|
}
|
|
311
365
|
}
|
|
366
|
+
// Overlapping writers (a whole cache dir and one of its children): the outer unit wins, the nested one is not moved twice.
|
|
367
|
+
const nested = candidates.filter((c) => candidates.some((o) => o !== c && c.path.startsWith(o.path + path.sep)));
|
|
368
|
+
for (const c of nested) skipped.push({ writer: c.writer, path: c.path, reason: "nested" });
|
|
369
|
+
if (nested.length) candidates.splice(0, candidates.length, ...candidates.filter((c) => !nested.includes(c)));
|
|
312
370
|
// Allowlisted workspace roots: unknown top-level entries older than 3 days and idle.
|
|
313
371
|
const scanned = scan({ policy, platform, env, home });
|
|
314
372
|
skipped.push(...scanned.skipped);
|
|
@@ -326,7 +384,11 @@ export function planSweep({ policy, platform = process.platform, env = process.e
|
|
|
326
384
|
if (lockCheck(unit)) { skipped.push({ ...base, reason: "in-use" }); return; }
|
|
327
385
|
candidates.push({ ...base, root, bytes: size(unit), mtime: new Date(st.mtimeMs).toISOString() });
|
|
328
386
|
};
|
|
329
|
-
|
|
387
|
+
// Report-only unless this host opted in (`disk allow-workspace-sweep`): unknown entries are warned about, never moved.
|
|
388
|
+
for (const u of scanned.unknown) {
|
|
389
|
+
if (!workspaceSweep) { skipped.push({ writer: "workspace-unknown", path: u.path, reason: "report-only" }); continue; }
|
|
390
|
+
consider("workspace-unknown", u.path, u.root, unknownCutoff);
|
|
391
|
+
}
|
|
330
392
|
// Scratch roots: every child older than 3 days and idle (the same window).
|
|
331
393
|
for (const root of scratchRoots(policy, { platform, env, home })) {
|
|
332
394
|
let rst;
|
|
@@ -373,26 +435,52 @@ const isUnder = (candidate, prefix, win) => {
|
|
|
373
435
|
return c === p || c.startsWith(withSep);
|
|
374
436
|
};
|
|
375
437
|
|
|
438
|
+
// True when a volume is mounted at `mount` and is not the item's own volume (`itemDev`).
|
|
439
|
+
export function defaultMounted(mount, itemDev) {
|
|
440
|
+
try {
|
|
441
|
+
const dev = deviceOf(mount);
|
|
442
|
+
return existsSync(mount) && dev !== null && dev !== itemDev;
|
|
443
|
+
} catch { return false; }
|
|
444
|
+
}
|
|
445
|
+
|
|
376
446
|
// Candidate holding bases for an item, best first: policy per-volume rules (longest prefix), then the item's own volume root.
|
|
447
|
+
// A rule with `mount` is a cross-volume base: it applies only to items on the policy floor's volume (the Mac internal drive),
|
|
448
|
+
// and only while that mount is present (checked at pick time). Entries are `{ path, mount? }`.
|
|
377
449
|
export function holdingBases(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
378
450
|
const key = platformKey(platform);
|
|
379
451
|
const out = [];
|
|
452
|
+
let floorDev;
|
|
380
453
|
const rules = (policy.holding?.[key] ?? []).flatMap((r) => {
|
|
381
|
-
try {
|
|
454
|
+
try {
|
|
455
|
+
return [{ prefix: expandPolicyPath(r.prefix, { env, home }), path: expandPolicyPath(r.path, { env, home }), mount: r.mount ? expandPolicyPath(r.mount, { env, home }) : undefined }];
|
|
456
|
+
} catch { return []; }
|
|
382
457
|
}).filter((r) => isUnder(candidate, r.prefix, key === "win")).sort((a, b) => b.prefix.length - a.prefix.length);
|
|
383
|
-
for (const r of rules)
|
|
384
|
-
|
|
458
|
+
for (const r of rules) {
|
|
459
|
+
if (r.mount) {
|
|
460
|
+
if (floorDev === undefined) {
|
|
461
|
+
const floorPath = policy.floors?.[key]?.path;
|
|
462
|
+
floorDev = floorPath ? deviceOf(expandPolicyPath(floorPath, { env, home })) : null;
|
|
463
|
+
}
|
|
464
|
+
if (floorDev === null || deviceOf(path.dirname(candidate)) !== floorDev) continue;
|
|
465
|
+
}
|
|
466
|
+
out.push({ path: r.path, ...(r.mount ? { mount: r.mount } : {}) });
|
|
467
|
+
}
|
|
468
|
+
out.push({ path: path.join(volumeRoot(path.dirname(candidate)), HOLDING_DIR) });
|
|
385
469
|
return out;
|
|
386
470
|
}
|
|
387
471
|
|
|
388
|
-
// Create (and mark) today's holding dir
|
|
389
|
-
|
|
472
|
+
// Create (and mark) today's holding dir; null when no holding dir is possible. A same-volume base must sit on the item's
|
|
473
|
+
// own volume; a mount base must sit on a mounted volume other than the item's (`isMounted`, injectable for tests).
|
|
474
|
+
export function pickHolding(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir(), date = new Date(), bases, isMounted = defaultMounted } = {}) {
|
|
390
475
|
const dev = deviceOf(path.dirname(candidate));
|
|
391
|
-
for (const
|
|
476
|
+
for (const entry of bases ?? holdingBases(candidate, { policy, platform, env, home })) {
|
|
477
|
+
const base = typeof entry === "string" ? entry : entry.path;
|
|
478
|
+
const mount = typeof entry === "string" ? undefined : entry.mount;
|
|
392
479
|
try {
|
|
393
|
-
|
|
480
|
+
// checked before creating anything: a same-volume base on another volume would free nothing
|
|
481
|
+
if (mount ? !isMounted(mount, dev) : deviceOf(base) !== dev) continue;
|
|
394
482
|
mkdirSync(base, { recursive: true });
|
|
395
|
-
if (
|
|
483
|
+
if (lstatSync(base).isSymbolicLink() || (!mount && deviceOf(base) !== dev)) continue;
|
|
396
484
|
const marker = path.join(base, HOLDING_MARKER);
|
|
397
485
|
if (!existsSync(marker)) writeFileSync(marker, "created by right-release disk; date-named children are purged after the retention window\n");
|
|
398
486
|
const dir = path.join(base, date.toISOString().slice(0, 10));
|
|
@@ -403,14 +491,45 @@ export function pickHolding(candidate, { policy, platform = process.platform, en
|
|
|
403
491
|
return null;
|
|
404
492
|
}
|
|
405
493
|
|
|
494
|
+
// Entries (lstat-visited, symlinks not followed) and allocated bytes under `root`.
|
|
495
|
+
export function treeStats(root) {
|
|
496
|
+
let entries = 0;
|
|
497
|
+
let bytes = 0;
|
|
498
|
+
const stack = [root];
|
|
499
|
+
while (stack.length) {
|
|
500
|
+
const dir = stack.pop();
|
|
501
|
+
let st;
|
|
502
|
+
try { st = lstatSync(dir); } catch { continue; }
|
|
503
|
+
entries += 1;
|
|
504
|
+
bytes += st.blocks ? st.blocks * 512 : st.size;
|
|
505
|
+
if (!st.isDirectory()) continue;
|
|
506
|
+
let names = [];
|
|
507
|
+
try { names = readdirSync(dir); } catch { continue; }
|
|
508
|
+
for (const name of names) stack.push(path.join(dir, name));
|
|
509
|
+
}
|
|
510
|
+
return { entries, bytes };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const pathExists = (p) => { try { lstatSync(p); return true; } catch { return false; } };
|
|
514
|
+
|
|
406
515
|
export function moveEntry(source, destination) {
|
|
407
516
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
408
517
|
try { renameSync(source, destination); return "rename"; } catch (error) {
|
|
409
518
|
if (error.code !== "EXDEV") throw error;
|
|
410
519
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
520
|
+
// Cross-volume: copy, verify the copy against the source, and only then remove the source. Never overwrite.
|
|
521
|
+
if (pathExists(destination)) throw new Error(`destination exists: ${destination}; source kept`);
|
|
522
|
+
const before = treeStats(source);
|
|
523
|
+
try {
|
|
524
|
+
cpSync(source, destination, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
|
|
525
|
+
const after = treeStats(destination);
|
|
526
|
+
if (after.entries !== before.entries || after.bytes < before.bytes) {
|
|
527
|
+
throw new Error(`copy verification failed for ${source} (entries ${before.entries} -> ${after.entries}, bytes ${before.bytes} -> ${after.bytes}); source kept`);
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
rmSync(destination, { recursive: true, force: true }); // our partial copy only: the source is untouched
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
414
533
|
rmSync(source, { recursive: true, force: true }); // completes the move after verified copy
|
|
415
534
|
return "copy-verify-remove";
|
|
416
535
|
}
|
|
@@ -422,17 +541,17 @@ export function logEvent(logFile, event) {
|
|
|
422
541
|
} catch { /* logging must never fail the sweep */ }
|
|
423
542
|
}
|
|
424
543
|
|
|
425
|
-
export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
|
|
544
|
+
export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, isMounted, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
|
|
426
545
|
const logFile = path.join(policy.heavyVolume[platformKey(platform)], "disk-hygiene.jsonl");
|
|
427
546
|
const results = [];
|
|
428
547
|
for (const c of plan.candidates) {
|
|
429
548
|
try {
|
|
430
549
|
// Re-verify at apply time: still the planned unit, not a symlink, still inside its root.
|
|
431
550
|
if (!c.root || lstatSync(c.path).isSymbolicLink() || !isInsideRoot(c.path, c.root)) throw new Error("refused: path is not a sweepable unit inside its target root");
|
|
432
|
-
if (c.writer === "workspace-unknown" && existsSync(path.join(c.path,
|
|
551
|
+
if (c.writer === "workspace-unknown" && PROJECT_MARKERS.some((m) => existsSync(path.join(c.path, m)))) throw new Error("refused: a nested git checkout or project directory is never swept");
|
|
433
552
|
if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); continue; }
|
|
434
553
|
// An item stays on its own volume: moving it elsewhere frees nothing on the volume that is short of space.
|
|
435
|
-
const holding = pick(c.path, { policy, platform, env, home, date });
|
|
554
|
+
const holding = pick(c.path, { policy, platform, env, home, date, isMounted });
|
|
436
555
|
if (!holding) {
|
|
437
556
|
remove(c.path);
|
|
438
557
|
results.push({ ...c, action: "delete", how: "no-holding-on-volume", ok: true });
|
package/disk-stable-root.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { cpSync, existsSync,
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
|
|
5
6
|
|
|
6
7
|
// The scheduled disk job must never bind to a pnpm store, a pnpm global hash directory or a checkout: those move or vanish
|
|
7
8
|
// on upgrade. `disk schedule install` copies the installed package into this per-user root and binds the job to it.
|
|
@@ -64,8 +65,8 @@ export function collectDependencies(pkgRoot) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** Copy the installed @rightkit/release package (and its dependencies) into the stable root. The new tree is built and
|
|
67
|
-
*
|
|
68
|
-
* `current
|
|
68
|
+
* built in the fixed `current.staging`, swapped in by rename (the old copy stays as `current.previous`), then smoke-tested at
|
|
69
|
+
* `current`; a failed swap or smoke run renames `current.previous` back. Returns `{ stable, cli, version, replaced, unchanged }`. */
|
|
69
70
|
export function installStableRoot({ cli, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), nodePath = process.execPath, smoke = true } = {}) {
|
|
70
71
|
const stable = stableRootPath({ platform, env, home });
|
|
71
72
|
const stableCli = stableCliPath(stable, platform);
|
|
@@ -81,10 +82,13 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
|
|
|
81
82
|
|
|
82
83
|
const parent = path.dirname(stable);
|
|
83
84
|
mkdirSync(parent, { recursive: true });
|
|
85
|
+
// Legacy per-pid names from earlier versions; the fixed names are `current.staging` and `current.previous`.
|
|
84
86
|
for (const name of readdirSync(parent)) {
|
|
85
87
|
if (/^current\.(staging|prev)-/.test(name)) try { rmSync(path.join(parent, name), { recursive: true, force: true }); } catch { /* in use: leave it */ }
|
|
86
88
|
}
|
|
87
|
-
const staging =
|
|
89
|
+
const staging = stagingPathOf(stable);
|
|
90
|
+
const previous = previousPathOf(stable);
|
|
91
|
+
rmSync(staging, { recursive: true, force: true });
|
|
88
92
|
const copy = (from, to, filter) => cpSync(from, to, { recursive: true, dereference: true, force: true, filter });
|
|
89
93
|
try {
|
|
90
94
|
const target = path.join(staging, "node_modules", "@rightkit", "release");
|
|
@@ -98,30 +102,29 @@ export function installStableRoot({ cli, platform = process.platform, env = proc
|
|
|
98
102
|
writeFileSync(path.join(staging, "install.json"), `${JSON.stringify({ package: pkg.name, version: pkg.version, source: pkgRoot, installedAt: new Date(now).toISOString(), node: nodePath }, null, 2)}\n`);
|
|
99
103
|
const stagedCli = stableCliPath(staging, platform);
|
|
100
104
|
if (!existsSync(stagedCli)) throw new Error("disk: the copied package has no cli/right-release.mjs");
|
|
101
|
-
if (smoke) {
|
|
102
|
-
const r = spawnSync(nodePath, [stagedCli, "--version"], { encoding: "utf8" });
|
|
103
|
-
if (r.status !== 0) throw new Error(`disk: the staged copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
|
|
104
|
-
}
|
|
105
105
|
} catch (error) {
|
|
106
106
|
rmSync(staging, { recursive: true, force: true });
|
|
107
107
|
throw error;
|
|
108
108
|
}
|
|
109
|
+
// Swap first, then smoke-run at the fixed `current` path (never from the staging path). A failed run renames
|
|
110
|
+
// `current.previous` back. Exactly one `.previous` is kept.
|
|
109
111
|
let replaced = false;
|
|
110
|
-
let prev;
|
|
111
112
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
const result = swapInPlace({
|
|
114
|
+
staging, destination: stable, previous,
|
|
115
|
+
verify: () => {
|
|
116
|
+
if (!smoke) return;
|
|
117
|
+
const r = spawnSync(nodePath, [stableCli, "--version"], { encoding: "utf8" });
|
|
118
|
+
if (r.status !== 0) throw new Error(`the installed copy does not run: ${r.stderr || r.stdout || r.error?.message}`);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
replaced = result.previous !== null;
|
|
114
122
|
} catch (error) {
|
|
115
|
-
if (prev && !existsSync(stable)) try { renameSync(prev, stable); } catch { /* reported below */ }
|
|
116
|
-
rmSync(staging, { recursive: true, force: true });
|
|
117
123
|
throw new Error(`disk: could not replace ${stable}: ${error.message}`);
|
|
118
124
|
}
|
|
119
|
-
if (prev) try { rmSync(prev, { recursive: true, force: true }); } catch { /* a later install removes it */ }
|
|
120
125
|
return { stable, cli: stableCli, version: pkg.version, replaced, unchanged: false };
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
function isLink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
124
|
-
|
|
125
128
|
/** Registered schedule target: the CLI path the launchd job or Windows task runs, or null when none is registered. */
|
|
126
129
|
export function readScheduleBinding({ platform = process.platform, home = os.homedir(), file, run = spawnSync, label = "RightKit Disk Hygiene", plistFile } = {}) {
|
|
127
130
|
if (platform === "darwin") {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// right-release engine-locate: the verified route to the CutRight and GenRight engines (Orthic-Labs/rightkit#316).
|
|
3
|
+
// Callers such as PostRight get the engine path ONLY from here. The route is fixed per platform (the installed app's stable
|
|
4
|
+
// `current` folder, see in-place-install.mjs) and every candidate is verified before its path is returned: a regular file (no
|
|
5
|
+
// symlink or junction), signed by the Orthic Labs release identity (macOS: Developer ID, hardened runtime, pinned Team ID and
|
|
6
|
+
// Identifier, not ad hoc; Windows: valid Authenticode, pinned signer organisation), and, when the caller pins one, an exact sha256.
|
|
7
|
+
// There is no environment override, no path argument and no PATH search: a path the user or a build supplies is never trusted.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { spawnSync } from "./background-process.mjs";
|
|
14
|
+
import { assertPackagedIdentity, parseCodesignDisplay } from "./packaged-app-identity.mjs";
|
|
15
|
+
|
|
16
|
+
export const ENGINE_TEAM_IDENTIFIER = "6KLGD3LLKF";
|
|
17
|
+
export const ENGINE_SIGNER_ORGANIZATION = "Orthic Labs";
|
|
18
|
+
|
|
19
|
+
/** engine id -> installed app folder, executable name (no extension) and macOS codesign Identifier. */
|
|
20
|
+
export const ENGINES = Object.freeze({
|
|
21
|
+
cutright: Object.freeze({ app: "CutRight", exe: "cutright", identifier: "com.orthiclabs.cutright.engine" }),
|
|
22
|
+
genright: Object.freeze({ app: "GenRight", exe: "genright", identifier: "com.orthiclabs.genright.engine" }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export class EngineLocateError extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "EngineLocateError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fail = (code, message) => { throw new EngineLocateError(code, message); };
|
|
34
|
+
|
|
35
|
+
/** The single fixed path for an engine on a platform. Reads no override variable: `localAppData` / `home` only name the user's profile roots. */
|
|
36
|
+
export function engineRoute(engine, { platform = process.platform, home = os.homedir(), localAppData = process.env.LOCALAPPDATA } = {}) {
|
|
37
|
+
const spec = ENGINES[engine];
|
|
38
|
+
if (!spec) fail("ENGINE_UNKNOWN", `unknown engine ${JSON.stringify(engine)} (cutright or genright)`);
|
|
39
|
+
if (platform === "win32") {
|
|
40
|
+
if (!localAppData) fail("ENGINE_ROUTE_UNAVAILABLE", "LOCALAPPDATA is not set, so the Windows engine route cannot be formed");
|
|
41
|
+
return path.win32.join(localAppData, "Orthic Labs", spec.app, "current", "bin", `${spec.exe}.exe`);
|
|
42
|
+
}
|
|
43
|
+
if (platform === "darwin") return path.posix.join(home, "Library", "Application Support", "Orthic Labs", spec.app, "current", "bin", spec.exe);
|
|
44
|
+
return fail("ENGINE_PLATFORM_UNSUPPORTED", `no engine route for platform ${platform}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const capture = (command, args, run = spawnSync) => {
|
|
48
|
+
const result = run(command, args, { encoding: "utf8", windowsHide: true, shell: false });
|
|
49
|
+
return { status: result.status ?? 1, output: `${result.stdout ?? ""}\n${result.stderr ?? ""}` };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const defaultVerifyMac = (file, spec, run) => {
|
|
53
|
+
const strict = capture("codesign", ["--verify", "--strict", file], run);
|
|
54
|
+
if (strict.status !== 0) fail("ENGINE_SIGNATURE_INVALID", `codesign --verify --strict failed for ${file}: ${strict.output.trim()}`);
|
|
55
|
+
const display = capture("codesign", ["-dv", "--verbose=4", file], run);
|
|
56
|
+
if (display.status !== 0) fail("ENGINE_SIGNATURE_INVALID", `codesign could not read ${file}: ${display.output.trim()}`);
|
|
57
|
+
const failures = assertPackagedIdentity(parseCodesignDisplay(display.output), [], { identifier: spec.identifier, team: ENGINE_TEAM_IDENTIFIER });
|
|
58
|
+
if (failures.length) fail("ENGINE_IDENTITY_MISMATCH", `${file}: ${failures.join("; ")}`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const defaultVerifyWindows = (file, _spec, run) => {
|
|
62
|
+
const script = "$s = Get-AuthenticodeSignature -LiteralPath $args[0]; [pscustomobject]@{ status = [string]$s.Status; subject = if ($s.SignerCertificate) { $s.SignerCertificate.Subject } else { '' } } | ConvertTo-Json -Compress";
|
|
63
|
+
const result = capture("powershell", ["-NoProfile", "-NonInteractive", "-Command", script, file], run);
|
|
64
|
+
let parsed;
|
|
65
|
+
try { parsed = JSON.parse(result.output.trim().split(/\r?\n/).filter(Boolean).at(-1)); } catch { fail("ENGINE_SIGNATURE_INVALID", `Authenticode could not be read for ${file}: ${result.output.trim()}`); }
|
|
66
|
+
if (parsed.status !== "Valid") fail("ENGINE_SIGNATURE_INVALID", `${file}: Authenticode status is ${parsed.status}, not Valid`);
|
|
67
|
+
if (!new RegExp(`(^|,\\s*)O=${ENGINE_SIGNER_ORGANIZATION}(,|$)`).test(parsed.subject)) fail("ENGINE_IDENTITY_MISMATCH", `${file}: signer ${JSON.stringify(parsed.subject)} is not ${ENGINE_SIGNER_ORGANIZATION}`);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Locate and verify an engine. Returns { engine, path, sha256, platform }. Throws EngineLocateError (ENGINE_NOT_INSTALLED,
|
|
72
|
+
* ENGINE_NOT_REGULAR_FILE, ENGINE_SIGNATURE_INVALID, ENGINE_IDENTITY_MISMATCH, ENGINE_HASH_MISMATCH, ...). Injectable for tests:
|
|
73
|
+
* `route`, `verifySignature`, `lstat`, `readBytes`. `expectedSha256` is an optional exact pin from the caller's own release record.
|
|
74
|
+
*/
|
|
75
|
+
export function locateEngine(engine, {
|
|
76
|
+
platform = process.platform, home, localAppData, expectedSha256, run = spawnSync,
|
|
77
|
+
route = engineRoute(engine, { platform, ...(home ? { home } : {}), ...(localAppData ? { localAppData } : {}) }),
|
|
78
|
+
verifySignature, lstat = lstatSync, readBytes = readFileSync, exists = existsSync,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const spec = ENGINES[engine];
|
|
81
|
+
if (!spec) fail("ENGINE_UNKNOWN", `unknown engine ${JSON.stringify(engine)} (cutright or genright)`);
|
|
82
|
+
if (!exists(route)) fail("ENGINE_NOT_INSTALLED", `${spec.app} engine is not installed at ${route}; install ${spec.app} from RightApps (there is no path override)`);
|
|
83
|
+
const info = lstat(route);
|
|
84
|
+
if (info.isSymbolicLink() || !info.isFile()) fail("ENGINE_NOT_REGULAR_FILE", `${route} must be a regular file, not a symlink, junction or directory`);
|
|
85
|
+
const verify = verifySignature ?? (platform === "win32" ? defaultVerifyWindows : platform === "darwin" ? defaultVerifyMac : () => fail("ENGINE_PLATFORM_UNSUPPORTED", `no engine verification for platform ${platform}`));
|
|
86
|
+
verify(route, spec, run);
|
|
87
|
+
const sha256 = createHash("sha256").update(readBytes(route)).digest("hex");
|
|
88
|
+
if (expectedSha256 !== undefined) {
|
|
89
|
+
if (!/^[0-9a-f]{64}$/.test(expectedSha256)) fail("ENGINE_HASH_MISMATCH", `expectedSha256 must be a lowercase SHA-256: ${JSON.stringify(expectedSha256)}`);
|
|
90
|
+
if (sha256 !== expectedSha256) fail("ENGINE_HASH_MISMATCH", `${route}: sha256 ${sha256} is not the pinned ${expectedSha256}`);
|
|
91
|
+
}
|
|
92
|
+
return { engine, path: route, sha256, platform };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function main(args = process.argv.slice(2), options = {}) {
|
|
96
|
+
try {
|
|
97
|
+
const known = new Set(["--engine", "--sha256", "--json"]);
|
|
98
|
+
for (const arg of args) if (arg.startsWith("--") && !known.has(arg)) fail("ENGINE_USAGE", `unknown option ${arg} (there is deliberately no path option)`);
|
|
99
|
+
const take = (flag) => {
|
|
100
|
+
const index = args.indexOf(flag);
|
|
101
|
+
if (index < 0) return undefined;
|
|
102
|
+
const value = args[index + 1];
|
|
103
|
+
if (!value || value.startsWith("--")) fail("ENGINE_USAGE", `${flag} requires a value`);
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
const engine = take("--engine");
|
|
107
|
+
if (!engine) fail("ENGINE_USAGE", "usage: right-release engine-locate --engine cutright|genright [--sha256 <hex>] [--json]");
|
|
108
|
+
const result = locateEngine(engine, { expectedSha256: take("--sha256"), ...options });
|
|
109
|
+
console.log(args.includes("--json") ? JSON.stringify(result) : result.path);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(`right-release engine-locate: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();
|
package/external-bin.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Stage, sign and receipt any number of Tauri externalBin sidecars (Orthic-Labs/rightkit#270).
|
|
2
|
+
//
|
|
3
|
+
// targets.mac.externalBin = {
|
|
4
|
+
// names: ["mailright-engine", "mailright-core"], // bare names, as in tauri.conf externalBin "binaries/<name>"
|
|
5
|
+
// arch: "aarch64", // default: build host; one arch per build
|
|
6
|
+
// stageDir: "src-tauri/binaries", // default; files are <name>-<target triple>[.exe]
|
|
7
|
+
// bundleDir: "Contents/MacOS", // mac only (or "Contents/Resources")
|
|
8
|
+
// stage: { cmd: "node", args: ["scripts/build-engine.mjs"] }, // optional: the app's own build of the files
|
|
9
|
+
// }
|
|
10
|
+
//
|
|
11
|
+
// This is the generic form of the ffmpeg sidecar from #246 and reuses its naming, hashing, staged-file checks and signing
|
|
12
|
+
// evidence checks (media-sidecar.mjs); it adds no catalog, notices or release-manifest seal. It never signs: release.mjs
|
|
13
|
+
// signs the staged files with the app (Windows raw-EXE step, macOS nested code) and writes external-bin.json.
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
bundledFileName, hostCatalogArch, sidecarTargetTriple, stagedFileName, verifyStagedSidecars,
|
|
17
|
+
} from "./media-sidecar.mjs";
|
|
18
|
+
|
|
19
|
+
export const EXTERNAL_BIN_CONTRACT = "rightkit-external-bin-v1";
|
|
20
|
+
export const EXTERNAL_BIN_RECEIPT_NAME = "external-bin.json";
|
|
21
|
+
|
|
22
|
+
const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
|
|
23
|
+
const ARCHES = new Set(["aarch64", "x86_64"]);
|
|
24
|
+
const CONFIG_KEYS = new Set(["names", "arch", "stageDir", "bundleDir", "stage"]);
|
|
25
|
+
const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
|
|
26
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
27
|
+
|
|
28
|
+
const invalid = (code, message) => Object.assign(new Error(message), { code });
|
|
29
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
/** Validate and normalise `targets.<platform>.externalBin`. Every problem is reported at once. */
|
|
32
|
+
export function validateExternalBinConfig(raw, { platform, hostArch = hostCatalogArch(), reservedNames = [] } = {}) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const os = PLATFORM_OS[platform];
|
|
35
|
+
if (!os) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", `externalBin is not supported on platform ${JSON.stringify(platform)} (mac, win or linux)`);
|
|
36
|
+
if (!isObject(raw)) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", "externalBin must be an object");
|
|
37
|
+
for (const key of Object.keys(raw)) if (!CONFIG_KEYS.has(key)) errors.push(`unknown key ${JSON.stringify(key)} (allowed: ${[...CONFIG_KEYS].join(", ")})`);
|
|
38
|
+
const names = raw.names;
|
|
39
|
+
if (!Array.isArray(names) || !names.length || new Set(names).size !== names.length
|
|
40
|
+
|| names.some((name) => typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) || name !== path.basename(name) || /\.exe$/i.test(name))) {
|
|
41
|
+
errors.push("names must be a non-empty list of unique bare sidecar names (no path, no .exe, no target triple)");
|
|
42
|
+
} else {
|
|
43
|
+
for (const name of names) if (reservedNames.includes(name)) errors.push(`${name} is already staged by mediaSidecar`);
|
|
44
|
+
}
|
|
45
|
+
const arch = raw.arch ?? hostArch;
|
|
46
|
+
if (!ARCHES.has(arch)) errors.push(`arch must be one of ${[...ARCHES].join(", ")} (one architecture per build): ${JSON.stringify(arch)}`);
|
|
47
|
+
else {
|
|
48
|
+
try { sidecarTargetTriple(os, arch); } catch (error) { errors.push(error.message); }
|
|
49
|
+
}
|
|
50
|
+
const stageDirRaw = raw.stageDir ?? "src-tauri/binaries";
|
|
51
|
+
let stageDir;
|
|
52
|
+
if (typeof stageDirRaw !== "string" || !stageDirRaw.trim()) errors.push("stageDir must be a non-empty relative path");
|
|
53
|
+
else {
|
|
54
|
+
const normalized = stageDirRaw.replaceAll("\\", "/");
|
|
55
|
+
if (path.isAbsolute(stageDirRaw) || /^[A-Za-z]:/.test(stageDirRaw) || normalized.split("/").includes("..")) errors.push(`stageDir must be relative and stay inside the app: ${stageDirRaw}`);
|
|
56
|
+
else stageDir = normalized.replace(/\/+$/, "");
|
|
57
|
+
}
|
|
58
|
+
let bundleDir;
|
|
59
|
+
if (os === "macos") {
|
|
60
|
+
bundleDir = raw.bundleDir ?? "Contents/MacOS";
|
|
61
|
+
if (!BUNDLE_DIRS.has(bundleDir)) errors.push(`bundleDir must be ${[...BUNDLE_DIRS].join(" or ")}`);
|
|
62
|
+
} else if (raw.bundleDir !== undefined) errors.push("bundleDir applies to macOS only");
|
|
63
|
+
let stage;
|
|
64
|
+
if (raw.stage !== undefined) {
|
|
65
|
+
if (!isObject(raw.stage) || typeof raw.stage.cmd !== "string" || !raw.stage.cmd || (raw.stage.args !== undefined && !Array.isArray(raw.stage.args))) errors.push("stage must be { cmd, args? }");
|
|
66
|
+
else stage = raw.stage;
|
|
67
|
+
}
|
|
68
|
+
if (errors.length) throw invalid("EXTERNAL_BIN_CONFIG_INVALID", `invalid externalBin configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
69
|
+
return { os, arch, names: [...names], stageDir, ...(bundleDir ? { bundleDir } : {}), ...(stage ? { stage } : {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The staged file (and, on macOS, bundle path) for every configured sidecar. Same shape `verifyStagedSidecars` reads. */
|
|
73
|
+
export function planExternalBin({ config, root }) {
|
|
74
|
+
return {
|
|
75
|
+
config,
|
|
76
|
+
files: config.names.map((name) => ({
|
|
77
|
+
tool: name,
|
|
78
|
+
stagedPath: path.resolve(root, config.stageDir, stagedFileName(name, config.os, config.arch)),
|
|
79
|
+
bundledName: bundledFileName(name, config.os),
|
|
80
|
+
...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(name, config.os)}` } : {}),
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
|
|
86
|
+
export function externalBinNestedCode(config) {
|
|
87
|
+
if (config.os !== "macos") return {};
|
|
88
|
+
return Object.fromEntries(config.names.map((name) => [`${config.bundleDir}/${bundledFileName(name, "macos")}`, { hardenedRuntime: true }]));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Before signing: every staged file exists, is non-empty, and its hash is recorded (reuses the ffmpeg check). */
|
|
92
|
+
export function verifyStagedExternalBins(plan, deps) {
|
|
93
|
+
return verifyStagedSidecars(plan, deps).map((file) => ({ ...file }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** After signing: `signed` maps name to { sha256, sizeBytes }. */
|
|
97
|
+
export function buildExternalBinReceipt(plan, preSign, signed) {
|
|
98
|
+
const { config } = plan;
|
|
99
|
+
return {
|
|
100
|
+
schema: 1,
|
|
101
|
+
contract: EXTERNAL_BIN_CONTRACT,
|
|
102
|
+
os: config.os,
|
|
103
|
+
arch: config.arch,
|
|
104
|
+
signing: config.os === "macos" ? "developer-id-hardened-runtime" : config.os === "windows" ? "authenticode" : "none",
|
|
105
|
+
files: preSign.map((file) => {
|
|
106
|
+
const after = signed[file.tool];
|
|
107
|
+
if (!after || !SHA256.test(after.sha256 ?? "")) throw invalid("EXTERNAL_BIN_RECEIPT_INVALID", `no signed digest for ${file.tool}`);
|
|
108
|
+
return {
|
|
109
|
+
name: file.tool,
|
|
110
|
+
preSignSha256: file.preSignSha256,
|
|
111
|
+
signedSha256: after.sha256,
|
|
112
|
+
sizeBytes: after.sizeBytes,
|
|
113
|
+
...(file.bundlePath ? { bundlePath: file.bundlePath } : { bundledName: file.bundledName }),
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function validateExternalBinReceipt(receipt, expected = {}) {
|
|
120
|
+
const fail = (message) => { throw invalid("EXTERNAL_BIN_RECEIPT_INVALID", `external bin receipt: ${message}`); };
|
|
121
|
+
if (!isObject(receipt) || receipt.schema !== 1 || receipt.contract !== EXTERNAL_BIN_CONTRACT) fail("unrecognized contract");
|
|
122
|
+
if (!Array.isArray(receipt.files) || !receipt.files.length) fail("no files");
|
|
123
|
+
for (const file of receipt.files) {
|
|
124
|
+
if (typeof file.name !== "string" || !file.name) fail("file without a name");
|
|
125
|
+
for (const key of ["preSignSha256", "signedSha256"]) if (!SHA256.test(file[key] ?? "")) fail(`${file.name}.${key} must be a SHA-256`);
|
|
126
|
+
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes <= 0) fail(`${file.name}.sizeBytes must be positive`);
|
|
127
|
+
if (file.preSignSha256 === file.signedSha256) fail(`${file.name} was not changed by signing`);
|
|
128
|
+
}
|
|
129
|
+
if (new Set(receipt.files.map((file) => file.name)).size !== receipt.files.length) fail("duplicate name");
|
|
130
|
+
if (expected.os && receipt.os !== expected.os) fail(`os ${receipt.os} is not ${expected.os}`);
|
|
131
|
+
if (expected.names && JSON.stringify([...expected.names].sort()) !== JSON.stringify(receipt.files.map((file) => file.name).sort())) fail("names differ from the configuration");
|
|
132
|
+
return receipt;
|
|
133
|
+
}
|
package/github-release.mjs
CHANGED
|
@@ -329,7 +329,7 @@ function verifyRemoteAsset(tag, repo, asset, run, evidence) {
|
|
|
329
329
|
if (inspectRemoteAsset(tag, repo, asset, run, evidence) !== "match") throw new Error(`GitHub release asset missing; inspect remote asset ${repo}/${tag}/${path.basename(asset)}`);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
function inspectRemoteAsset(tag, repo, asset, run, evidence) {
|
|
332
|
+
export function inspectRemoteAsset(tag, repo, asset, run, evidence) {
|
|
333
333
|
const verifyDir = `${path.dirname(asset)}/verify-${path.basename(asset)}`;
|
|
334
334
|
rmSync(verifyDir, { recursive: true, force: true });
|
|
335
335
|
mkdirSync(verifyDir, { recursive: true });
|
|
@@ -350,7 +350,7 @@ function sha256(file) {
|
|
|
350
350
|
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
function runCommand(command, args, { allowFailure = false, cwd } = {}) {
|
|
353
|
+
export function runCommand(command, args, { allowFailure = false, cwd } = {}) {
|
|
354
354
|
const result = spawnSync(command, args, { cwd, encoding: "utf8", windowsHide: true });
|
|
355
355
|
const output = result.stdout?.trim() ?? "";
|
|
356
356
|
if (result.status !== 0) {
|