@rightkit/release 0.6.16 → 0.6.21
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 +7 -3
- package/admission-host.mjs +9 -5
- package/app-closure.mjs +9 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +481 -103
- package/bin/rightkit-linux-setup.mjs +15 -0
- package/broker-admission.mjs +184 -0
- package/broker-wire.mjs +15 -3
- package/build-launch.mjs +4 -1
- package/build-ownership.mjs +125 -36
- package/build-release.mjs +39 -7
- package/cargo-contract.mjs +4 -1
- package/cargo-guard.mjs +25 -5
- package/cargo-target-shim.mjs +56 -7
- package/clippy-route.mjs +8 -7
- package/crate-graph.mjs +5 -1
- package/credential-provider.mjs +174 -0
- package/deterministic-scans.mjs +253 -42
- package/development.mjs +8 -0
- package/disk-policy.mjs +2 -2
- package/gate-executor.mjs +5 -3
- package/gate-stages.mjs +34 -3
- package/gradle-lock.mjs +206 -0
- package/heavy-command.mjs +33 -2
- package/linux-artifacts.mjs +2 -1
- package/linux-setup.mjs +106 -0
- package/local-publish.mjs +21 -10
- package/local-qualify.mjs +9 -3
- package/managed-cargo.mjs +54 -18
- package/ownership-lockfiles.mjs +14 -3
- package/ownership.mjs +5 -1
- package/package.json +17 -11
- package/protected-credentials.mjs +13 -5
- package/publication-gate.mjs +9 -4
- package/python-lock.mjs +209 -0
- package/qa-admission-policy.json +32 -0
- package/qa-layout.mjs +229 -0
- package/qa-runner.mjs +156 -0
- package/release-artifact-path.mjs +29 -3
- package/release-config.mjs +1 -1
- package/release-qa-command.mjs +58 -8
- package/release-qa-policy.mjs +5 -3
- package/release-source-identity.mjs +1 -0
- package/release.mjs +10 -2
- package/rightkit-ownership.json +1465 -79
- package/rightkit-registry-resolver.mjs +7 -3
- package/rightkit-versions.json +173 -36
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +5 -1
- package/shell-contract.mjs +1 -1
- package/snapshot-broker.mjs +114 -9
- package/snapshot-check.mjs +6 -1
- package/snapshot-request.mjs +20 -3
- package/snapshot.mjs +11 -4
- package/stage-commands.mjs +2 -1
- package/stage-ledger.mjs +15 -0
- package/stage-sidecar-command.mjs +7 -1
- package/stage-sidecar.mjs +17 -3
- package/suite-signing.mjs +40 -4
- package/swift-lock.mjs +334 -0
- package/test-command.mjs +13 -3
- package/tool-build.mjs +156 -0
- package/unit-gate.mjs +9 -2
- package/windows-identity-plan.mjs +119 -0
- package/windows-identity.mjs +136 -0
package/build-release.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.
|
|
|
46
46
|
import { planIosNativeBuild, resolveIosBuildContext } from "./ios-build-context.mjs";
|
|
47
47
|
import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
|
|
48
48
|
import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
49
|
-
import {
|
|
49
|
+
import { acquireRunHeavySlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree, withLocalHeavySlot } from "./heavy-command.mjs";
|
|
50
50
|
import { processGroupCpuSeconds } from "./process-liveness.mjs";
|
|
51
51
|
import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
|
|
52
52
|
import { beginArtifactLease, completeArtifactLease, materializeArtifacts, retainArtifacts, resolveArtifactDir, resolveManagedRoot } from "./artifact-storage.mjs";
|
|
@@ -171,6 +171,8 @@ let child = null;
|
|
|
171
171
|
let suiteSlot;
|
|
172
172
|
let cacheLease;
|
|
173
173
|
let heavySlot;
|
|
174
|
+
// The run's slot decision (heavySlotDecision): on a broker host the run holds no slot and takes one only around its own local steps (runLocalStep).
|
|
175
|
+
let heavyDecisionOfRun = null;
|
|
174
176
|
let interrupted;
|
|
175
177
|
let requestActiveRunStop = null;
|
|
176
178
|
|
|
@@ -385,19 +387,43 @@ try {
|
|
|
385
387
|
delete env[name];
|
|
386
388
|
}
|
|
387
389
|
}
|
|
388
|
-
// A split's Cargo
|
|
389
|
-
// the bound target the seal reads would not be the one Cargo wrote
|
|
390
|
-
|
|
390
|
+
// A split's Cargo environment is the one the gate bound and handed this process (row 7.09, R-T1): Cache V2's target, cargo home and compiler wrapper
|
|
391
|
+
// do not replace it, or the bound target the seal reads would not be the one Cargo wrote, and a prepare step that re-runs a build the compile stage
|
|
392
|
+
// already staged (stage-sidecar) would resolve crates from another cargo home (`--frozen` cannot fetch them) and rebuild the staged bytes
|
|
393
|
+
// (HeardRight Windows package, 2026-11-01: STAGE_SIDECAR_CATALOG_UNREADABLE, then "a staged entry is immutable for the door run").
|
|
394
|
+
if (splitSource && !managedCargoTarget) {
|
|
395
|
+
for (const name of ["CARGO_HOME", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "SCCACHE_IGNORE_SERVER_IO_ERROR"]) {
|
|
396
|
+
if (typeof process.env[name] === "string" && process.env[name] !== "") env[name] = process.env[name];
|
|
397
|
+
else delete env[name];
|
|
398
|
+
}
|
|
399
|
+
// The target only when the gate bound one process-wide: with several workspaces on a direct POSIX host the gate's per-call wrapper binds
|
|
400
|
+
// each and hands no CARGO_TARGET_DIR, and the target bridge below still needs Cache V2's.
|
|
401
|
+
if (typeof process.env.CARGO_TARGET_DIR === "string" && path.isAbsolute(process.env.CARGO_TARGET_DIR)) env.CARGO_TARGET_DIR = process.env.CARGO_TARGET_DIR;
|
|
402
|
+
}
|
|
391
403
|
if (cacheMode !== "shared") delete env.RIGHT_RELEASE_CACHE_OWNER;
|
|
392
404
|
if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
|
|
393
405
|
if (cacheMode !== "shared" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
|
|
394
|
-
|
|
406
|
+
// The host-wide heavy-work slot (rightkit#1367 step 2). A direct host takes it for the whole run. On a broker host every hook the build runs (cargo, SwiftPM, xcodebuild,
|
|
407
|
+
// pnpm, node, QA) goes through build-launch and becomes a broker job, which the broker's scheduler and resource budget admit: no slot and no memory/CPU wait for the run.
|
|
408
|
+
// What this process runs itself, outside build-launch, is local work the broker never sees: today its own `pnpm install` (prepare, below), which takes the slot around just
|
|
409
|
+
// that step (runLocalStep). A child of a skipped slot is not told it has one (no RIGHTSUITE_HEAVY_WORK_OWNER), so a heavy-command it starts takes its own. The worker's
|
|
410
|
+
// signing, notarization and disk-image steps (sign-macos, notarize-macos, dmg-build) are not builds and hold no slot on a broker host.
|
|
411
|
+
const { decision: heavyDecision, slot: runSlot } = acquireRunHeavySlot({ env: process.env, brokerHost: brokerManagedHost(process.env) });
|
|
412
|
+
heavyDecisionOfRun = heavyDecision;
|
|
413
|
+
if (runSlot) heavySlot = runSlot;
|
|
414
|
+
else if (heavyDecision.skipReason) {
|
|
415
|
+
delete env.RIGHTSUITE_HEAVY_WORK_OWNER;
|
|
416
|
+
console.error(`[right-release] heavy-work slot not held for the run (${heavyDecision.skipReason})`);
|
|
417
|
+
}
|
|
418
|
+
// The shared-cache suite slot (acquireSuiteBuildSlot, below) is one-at-a-time per cache layout, not a build count of the broker's. It runs only in cache mode "shared", which a
|
|
419
|
+
// broker host never has: a Cargo build there is forced to "broker" (managedCargoTarget above) and SwiftPM and native iOS plans are always "legacy" (swift-build-context,
|
|
420
|
+
// ios-build-context). So on a broker host it is skipped, with no exception; a direct host keeps it.
|
|
395
421
|
if (cacheMode === "shared") {
|
|
396
422
|
suiteSlot = acquireSuiteBuildSlot({ layout: sharedLayout, pid: process.pid, argv: process.argv, waitMs: Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000 });
|
|
397
423
|
cacheLease = acquireCacheLease({ layout: sharedLayout, pid: process.pid, argv: process.argv });
|
|
398
424
|
}
|
|
399
425
|
// Pre-broker wait attribution: every layer, ms, and blocker identity (Step 1 instrumentation).
|
|
400
|
-
const preBrokerWaits = summarizePreBrokerWaits({ heavySlot, suiteSlot, heavyOwnerInherited:
|
|
426
|
+
const preBrokerWaits = summarizePreBrokerWaits({ heavySlot, suiteSlot, heavyOwnerInherited: heavyDecision.inherited, heavySkipped: heavyDecision.skipReason });
|
|
401
427
|
console.error(`[right-release] pre-broker waits: ${preBrokerWaits.summary}`);
|
|
402
428
|
throwIfInterrupted();
|
|
403
429
|
const managedRoot = resolveManagedRoot({ env: process.env });
|
|
@@ -540,7 +566,7 @@ try {
|
|
|
540
566
|
if (linked.action === "moved-aside") console.error(`[right-release] hosted CI: moved existing ${targetLink} aside to ${linked.aside}`);
|
|
541
567
|
}
|
|
542
568
|
// A Cargo-less native iOS app installs Node dependencies only when it has a package.json.
|
|
543
|
-
if (nativeIos ? hasPackageJson : resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
|
|
569
|
+
if (nativeIos ? hasPackageJson : resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runLocalStep(() => runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, heavyDecisionOfRun?.localSteps ? { ...env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } : env, cacheTarget, path.join(stateRoot, "stall-install.log")));
|
|
544
570
|
},
|
|
545
571
|
build: async () => {
|
|
546
572
|
throwIfInterrupted();
|
|
@@ -917,6 +943,12 @@ function collectToolVersions(packageManager, probeDir = process.cwd()) {
|
|
|
917
943
|
};
|
|
918
944
|
}
|
|
919
945
|
|
|
946
|
+
/** On a broker host: the heavy-work slot, held around just the local step `run` (the release's own `pnpm install`), never around broker jobs. Elsewhere `run` runs as it is (the run holds the slot or inherits it). */
|
|
947
|
+
function runLocalStep(run) {
|
|
948
|
+
const outer = heavySlot;
|
|
949
|
+
return withLocalHeavySlot(heavyDecisionOfRun, run, { onSlot: (slot) => { heavySlot = slot ?? outer; } });
|
|
950
|
+
}
|
|
951
|
+
|
|
920
952
|
async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = null, { livenessOwnerTimeoutMs = null } = {}) {
|
|
921
953
|
const { inactivityMs, absoluteMs } = releaseProgressLimits(env, { livenessOwnerTimeoutMs });
|
|
922
954
|
let lastProgress = Date.now();
|
package/cargo-contract.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
|
-
import { directPlatform, linuxBrokerDeclared, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
|
|
5
|
+
import { directPlatform, gateNoBroker, linuxBrokerDeclared, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
|
|
6
6
|
|
|
7
7
|
const SKIP_DIRECTORIES = new Set([".audit", ".cache", ".git", ".claude", ".right-release", ".worktrees", "node_modules", "target", "vendor"]);
|
|
8
8
|
const CRATES_IO_SOURCES = new Set([
|
|
@@ -30,6 +30,9 @@ export function managedCargoShimOnPath(env = process.env, platform = process.pla
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function brokerManagedHost(env = process.env, platform = process.platform, exists = existsSync) {
|
|
33
|
+
// A gate run under Adrian's recorded no-broker exception runs Cargo directly (managed-cargo gateNoBroker): the broker owns no
|
|
34
|
+
// target or cache for it, so nothing may drop the target the gate pinned, whatever RustRight is installed.
|
|
35
|
+
if (gateNoBroker(env)) return false;
|
|
33
36
|
// A Linux host is broker-managed only through its explicit declared route (managed-cargo linuxBrokerDeclared), whatever socket or control root the env names.
|
|
34
37
|
if (directPlatform(platform)) return linuxBrokerDeclared(env, platform);
|
|
35
38
|
return Boolean(managedRuntimeClient(env, platform, exists)) || Boolean(env.RUSTRIGHT_BUILD_BROKER_SOCKET || env.RIGHTKIT_BUILD_BROKER_SOCKET) || managedCargoShimOnPath(env, platform, exists);
|
package/cargo-guard.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
|
|
9
9
|
import { resolveSharedCacheRoot, resolveStableRoot } from "./cache-policy.mjs";
|
|
10
10
|
import { brokerManagedHost } from "./cargo-contract.mjs";
|
|
11
|
+
import { brokerClientRoute } from "./managed-cargo.mjs";
|
|
11
12
|
import { runHeavyCommand } from "./heavy-command.mjs";
|
|
12
13
|
|
|
13
14
|
const LIGHT_COMMANDS = new Set(["fmt", "metadata", "tree", "fetch", "search", "locate-project", "read-manifest", "help", "version", "--version", "-V"]);
|
|
@@ -137,16 +138,25 @@ export function resolveManagedCargoShim({ env = process.env, platform = process.
|
|
|
137
138
|
const win = platform === "win32" || platform === "win";
|
|
138
139
|
const delimiter = win ? ";" : ":";
|
|
139
140
|
const agentBin = /(?:^|[\\/])(?:\.rightkit-managed|rightkitmanagedagent|rightkit[\\/]build-control)[\\/]agent-bin[\\/]?$/i;
|
|
141
|
+
// Every extension cargo-contract.mjs managedCargoShimOnPath accepts (PATHEXT, else .COM;.EXE;.BAT;.CMD), so a shim that predicate sees is a shim this finds.
|
|
142
|
+
const extensions = win ? String(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((extension) => extension.toLowerCase()) : [""];
|
|
140
143
|
for (const directory of String(env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
141
144
|
if (!agentBin.test(directory)) continue;
|
|
142
|
-
for (const
|
|
143
|
-
const candidate = path.join(directory,
|
|
145
|
+
for (const extension of extensions) {
|
|
146
|
+
const candidate = path.join(directory, `cargo${extension}`);
|
|
144
147
|
if (exists(candidate)) return candidate;
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
return null;
|
|
148
151
|
}
|
|
149
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The broker's own client for a broker host whose cargo shim is not on PATH (a Dell socket or managed client with no agent-bin directory, a non-login shell, the declared
|
|
155
|
+
* Linux route): the shared route managed-cargo.mjs runCargoSync takes (`brokerClientRoute`): `{ client, env }`, run as `<client> cargo <args>`; `{ refusal }` when the declared
|
|
156
|
+
* route is invalid or the Mac's socket is unavailable; null when no client is reachable.
|
|
157
|
+
*/
|
|
158
|
+
export const resolveBrokerClientRoute = brokerClientRoute;
|
|
159
|
+
|
|
150
160
|
function pathApi(platform) {
|
|
151
161
|
return platform === "win32" || platform === "win" ? path.win32 : path.posix;
|
|
152
162
|
}
|
|
@@ -229,7 +239,7 @@ function spawnCargo(command, args, env) {
|
|
|
229
239
|
});
|
|
230
240
|
}
|
|
231
241
|
|
|
232
|
-
export async function runCargoGuard(args, { env = process.env, policyOptions = {}, resolveCargo = resolveRealCargo, resolveRustc = resolveRealRustc, runHeavy = runHeavyCommand, runLight = spawnCargo, resolveShim = resolveManagedCargoShim, isBrokerHost = brokerManagedHost } = {}) {
|
|
242
|
+
export async function runCargoGuard(args, { env = process.env, policyOptions = {}, resolveCargo = resolveRealCargo, resolveRustc = resolveRealRustc, runHeavy = runHeavyCommand, runLight = spawnCargo, resolveShim = resolveManagedCargoShim, resolveClientRoute = resolveBrokerClientRoute, isBrokerHost = brokerManagedHost } = {}) {
|
|
233
243
|
assertComputePolicyAllowsCargo(args, { ...policyOptions, env });
|
|
234
244
|
// On a broker-managed host the build broker owns the toolchain, cache, and
|
|
235
245
|
// quotas. Resolving the real Cargo here and running it via runHeavy would
|
|
@@ -240,8 +250,18 @@ export async function runCargoGuard(args, { env = process.env, policyOptions = {
|
|
|
240
250
|
if (isBrokerHost(env)) {
|
|
241
251
|
const shim = resolveShim({ env });
|
|
242
252
|
if (shim) return runLight(shim, args, env);
|
|
243
|
-
// No shim
|
|
244
|
-
//
|
|
253
|
+
// No shim on PATH (a Dell socket or managed client with no agent-bin, a non-login shell, the declared Linux route): hand the call to the managed or declared runtime
|
|
254
|
+
// client, the client brokerManagedHost resolves, as `<client> cargo <args>`. Running the real cargo here would be a build outside the broker, which neither counts nor
|
|
255
|
+
// admits it (the heavy-work slot is not taken on a broker host, rightkit#1367 step 2), so a call that guards (a build) is refused when no client is reachable.
|
|
256
|
+
// Only a command that guards (a build) goes to the client; a light command (fmt, metadata, --version, ...) builds nothing and runs the real cargo as it always did.
|
|
257
|
+
if (shouldGuardCargo(args)) {
|
|
258
|
+
const route = resolveClientRoute({ env });
|
|
259
|
+
if (route && !route.refusal) return runLight(route.client, ["cargo", ...args], route.env);
|
|
260
|
+
console.error(route?.refusal
|
|
261
|
+
? `cargo-guard: this machine is broker-managed and no broker shim is on PATH, and the RustRight client route is refused (${route.refusal}); refusing to run cargo outside the broker. Start or repair the broker, then run again.`
|
|
262
|
+
: "cargo-guard: this machine is broker-managed but neither the broker's cargo shim nor a RustRight client was found; refusing to run cargo outside the broker. Start or repair the broker, then run again.");
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
245
265
|
}
|
|
246
266
|
const cargo = resolveCargo({ env });
|
|
247
267
|
if (shouldGuardCargo(args)) {
|
package/cargo-target-shim.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// overridden the value the gate passed: a residual of the trusted per-user model, not refused (map 7.12).
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import path from "node:path";
|
|
31
|
-
import { createHash } from "node:crypto";
|
|
31
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
32
32
|
import { spawnSync } from "./background-process.mjs";
|
|
33
33
|
import { spawnBrokerClient } from "./broker-client.mjs";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
@@ -158,9 +158,25 @@ export function decideCargoInvocation({ args, cwd, env, map, locate, realpath =
|
|
|
158
158
|
* Broker mode (`broker: { checkoutDirs, section }`, the Mac): `checkoutDirs` is the gate's { [cargoDir]: absolute path of that
|
|
159
159
|
* workspace in the primary checkout } (the snapshot-to-checkout map, the only source of a request's primaryCwd) and `section` the
|
|
160
160
|
* public part of the gate's snapshot broker (brokerSection). `realCargo` then only runs `version` and `help`; it may be null.
|
|
161
|
+
*
|
|
162
|
+
* Windows, broker mode only (rightkit#1350, rustright#215): a child started without a shell finds only an .exe, so `bin/cargo.exe` is a
|
|
163
|
+
* byte copy of the live RustRight client (`broker.section.client`) and `bin/gate-trampoline.json` beside it pins node, this module and the
|
|
164
|
+
* map by path and sha256. The client finds that sidecar beside its own executable and runs `node <module> --map <map> -- <args>` and
|
|
165
|
+
* nothing else (gate-trampoline mode). The client must answer `trampoline-capability` with the schema and the copy must hash to it before
|
|
166
|
+
* the sidecar is written; the client's digest is part of the identity, and so is the run's ownership handle (broker section), so two live
|
|
167
|
+
* runs never share a wrapper directory. A direct Windows host still has no wrapper.
|
|
161
168
|
*/
|
|
162
|
-
export
|
|
163
|
-
|
|
169
|
+
export const TRAMPOLINE_SCHEMA = "rustright.gate-trampoline/1";
|
|
170
|
+
const sha256File = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
171
|
+
const probeTrampoline = (exe) => {
|
|
172
|
+
const r = spawnSync(exe, ["trampoline-capability"], { encoding: "utf8", windowsHide: true });
|
|
173
|
+
return r.status === 0 ? String(r.stdout ?? "").trim() : `exit ${r.status ?? r.signal ?? r.error?.message}`;
|
|
174
|
+
};
|
|
175
|
+
export function installCargoShim({ base, snapshotRoot, targetDirs, broker = null, realCargo, node = process.execPath, platform = process.platform, realpath = (p) => fs.realpathSync.native(p), onDir = () => {}, write = (file, text, mode) => fs.writeFileSync(file, text, { mode }), copyFile = (from, to) => fs.copyFileSync(from, to), hashFile = sha256File, probe = probeTrampoline }) {
|
|
176
|
+
if (platform === "win32" && !broker) throw Object.assign(new Error("the runtime cargo wrapper needs a POSIX host or a Windows broker host: a Windows child finds only an .exe on PATH"), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
|
|
177
|
+
const trampoline = platform === "win32";
|
|
178
|
+
if (trampoline && (typeof broker.section?.client !== "string" || !path.win32.isAbsolute(broker.section.client))) throw Object.assign(new Error(`a Windows broker wrapper needs the absolute RustRight client (${JSON.stringify(broker.section?.client)})`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
|
|
179
|
+
const clientSha256 = trampoline ? hashFile(broker.section.client) : null;
|
|
164
180
|
const mode = broker ? "broker" : "direct";
|
|
165
181
|
if (mode === "direct" && (typeof realCargo !== "string" || !path.isAbsolute(realCargo))) throw Object.assign(new Error(`no absolute cargo to wrap (${JSON.stringify(realCargo)})`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
|
|
166
182
|
if (mode === "broker" && ((realCargo !== null && realCargo !== undefined && !path.isAbsolute(realCargo)) || !broker.section || !broker.checkoutDirs)) throw Object.assign(new Error("a broker wrapper needs the gate's checkout map and broker section"), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
|
|
@@ -171,12 +187,15 @@ export function installCargoShim({ base, snapshotRoot, targetDirs, broker = null
|
|
|
171
187
|
const identityHash = createHash("sha256").update(JSON.stringify({ realCargo: realCargo ?? null, node, workspaces: sorted, ...(broker ? { mode, section: broker.section } : {}) })).update(fs.readFileSync(self));
|
|
172
188
|
// The broker half of the wrapper is part of its identity: any change to the code that carries and verifies a call is another wrapper.
|
|
173
189
|
if (broker) for (const name of ["snapshot-broker.mjs", "snapshot-request.mjs", "snapshot-metadata.mjs", "provenance-verify.mjs", "cargo-ledger.mjs"]) identityHash.update(fs.readFileSync(new URL(`./${name}`, import.meta.url)));
|
|
190
|
+
if (trampoline) identityHash.update(`\0client:${clientSha256}`);
|
|
174
191
|
const identity = identityHash.digest("hex");
|
|
175
192
|
const dir = path.join(base, `rk-cargo-shim-${identity.slice(0, 16)}`);
|
|
176
193
|
const bin = path.join(dir, "bin");
|
|
177
|
-
const shim = path.join(bin, "cargo");
|
|
194
|
+
const shim = path.join(bin, trampoline ? "cargo.exe" : "cargo");
|
|
178
195
|
const mapFile = path.join(dir, "cargo-target-map.json");
|
|
179
|
-
|
|
196
|
+
// The wrapper directory is identity-keyed and outlives a killed run, so each install gets its own ledger: a dead run's
|
|
197
|
+
// pending record is never read as this run's call.
|
|
198
|
+
const ledgerDir = path.join(dir, `ledger-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
180
199
|
// The caller learns the directory before anything is written into it (its cleanup owns it from here), and a failed install removes
|
|
181
200
|
// what it wrote before it throws.
|
|
182
201
|
onDir(dir);
|
|
@@ -188,7 +207,17 @@ export function installCargoShim({ base, snapshotRoot, targetDirs, broker = null
|
|
|
188
207
|
const sentinel = mode === "broker" || Object.keys(sorted).length > 1 ? path.join(dir, "unbound") : null;
|
|
189
208
|
const section = broker ? { ...broker.section, sentinel } : null;
|
|
190
209
|
put(mapFile, `${JSON.stringify({ schema: SHIM_MAP_SCHEMA, mode, identity, realCargo: realCargo ?? null, shim, ledgerDir, workspaces: sorted, ...(section ? { broker: section } : {}) }, null, 2)}\n`, 0o644);
|
|
191
|
-
|
|
210
|
+
if (trampoline) {
|
|
211
|
+
// The probe asks the client under its own name: a copy named cargo.exe is a managed cargo shim to RustRight (shims.rs), which would send the
|
|
212
|
+
// probe to the broker as a cargo request instead of answering it.
|
|
213
|
+
const answer = probe(broker.section.client);
|
|
214
|
+
if (answer !== TRAMPOLINE_SCHEMA) throw Object.assign(new Error(`the RustRight client ${broker.section.client} has no gate-trampoline mode (trampoline-capability answered ${JSON.stringify(answer)}, not ${TRAMPOLINE_SCHEMA}); install a RustRight runtime with rustright#215`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
|
|
215
|
+
copyFile(broker.section.client, shim);
|
|
216
|
+
const copied = hashFile(shim);
|
|
217
|
+
if (copied !== clientSha256) throw Object.assign(new Error(`the copy ${shim} hashes to ${copied}, not the client's ${clientSha256}`), { code: "RK_CARGO_SHIM_UNSUPPORTED" });
|
|
218
|
+
const pin = (file) => ({ path: file, sha256: hashFile(file) });
|
|
219
|
+
put(path.join(bin, "gate-trampoline.json"), `${JSON.stringify({ schema: TRAMPOLINE_SCHEMA, node: pin(node), module: pin(self), map: pin(mapFile) }, null, 2)}\n`, 0o644);
|
|
220
|
+
} else put(shim, `#!/bin/sh\nexec ${quote(node)} ${quote(self)} --map ${quote(mapFile)} -- "$@"\n`, 0o755);
|
|
192
221
|
return { dir, bin, shim, mapFile, ledgerDir, mode, workspaces: sorted, identity, sentinel };
|
|
193
222
|
} catch (error) {
|
|
194
223
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -230,12 +259,32 @@ export function locateWorkspaceByWalk(manifestPath, cwd, { read = (f) => fs.read
|
|
|
230
259
|
}
|
|
231
260
|
|
|
232
261
|
/** The wrapper itself: `cargo-target-shim.mjs --map <file> -- <cargo args>`. Returns the exit status. */
|
|
262
|
+
/** The wrapper's own reader of its map file (runShim reads it through this): throws when the file is unreadable or is another schema. */
|
|
263
|
+
export function readShimMap(file) {
|
|
264
|
+
const map = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
265
|
+
if (map?.schema !== SHIM_MAP_SCHEMA) throw new Error(`schema ${JSON.stringify(map?.schema)}`);
|
|
266
|
+
return map;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The per-call wrapper this process runs under, from its environment: CARGO names the wrapper (`<dir>/bin/cargo`) and the map it reads
|
|
271
|
+
* is `<dir>/cargo-target-map.json`. Returns the map only for a direct-mode wrapper whose own `shim` is that CARGO (a stale or foreign CARGO
|
|
272
|
+
* is no wrapper), else null. `map.workspaces` is { [real snapshot workspace]: the checkout's target directory }.
|
|
273
|
+
*/
|
|
274
|
+
export function shimMapOfEnv(env = process.env, { read = readShimMap } = {}) {
|
|
275
|
+
const cargo = env?.CARGO;
|
|
276
|
+
if (typeof cargo !== "string" || !path.isAbsolute(cargo)) return null;
|
|
277
|
+
let map;
|
|
278
|
+
try { map = read(path.join(path.dirname(path.dirname(cargo)), "cargo-target-map.json")); } catch { return null; }
|
|
279
|
+
return map?.mode === "direct" && map.shim === cargo && map.workspaces && typeof map.workspaces === "object" ? map : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
233
282
|
export function runShim(argv, { env = process.env, cwd = process.cwd(), spawn = spawnSync, err = (m) => process.stderr.write(`${m}\n`) } = {}) {
|
|
234
283
|
const sep = argv.indexOf("--");
|
|
235
284
|
const mapAt = argv.indexOf("--map");
|
|
236
285
|
if (mapAt < 0 || sep < 0 || mapAt > sep) { err("rightkit-gate: RK_CARGO_SHIM_ERROR: usage: cargo-target-shim.mjs --map <file> -- <cargo args>"); return SHIM_REFUSAL_STATUS; }
|
|
237
286
|
let map;
|
|
238
|
-
try { map =
|
|
287
|
+
try { map = readShimMap(argv[mapAt + 1]); }
|
|
239
288
|
catch (error) { err(`rightkit-gate: RK_CARGO_SHIM_ERROR: the cargo target map cannot be read (${error.message}); nothing ran`); return SHIM_REFUSAL_STATUS; }
|
|
240
289
|
const words = argv.slice(sep + 1);
|
|
241
290
|
const token = env[SCAN_TOKEN_ENV];
|
package/clippy-route.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The Clippy route: `rightkit-gate clippy [<cargo clippy args>]` (GATE-CONTRACT §1 "Clippy route & batching", E13 to E15; item 8 of
|
|
2
2
|
// the joint plan, rows 8.1 to 8.16 of the signed map). A gate-owned destination for a request such as
|
|
3
3
|
// `cargo clippy --workspace --all-targets -- -D warnings`: it builds HEAD's commit (never the checkout) from the gate-owned
|
|
4
|
-
// snapshot, under the
|
|
4
|
+
// snapshot, under the repository's build ownership, after FULL class A (D8-2), and stops after diagnostics. It has no stage chain, runs no
|
|
5
5
|
// package, sign, publish or release stage, and writes nothing a door receipt, qualification or the full gate could take as stage
|
|
6
6
|
// evidence (row 8.5).
|
|
7
7
|
//
|
|
@@ -28,7 +28,7 @@ import fs from "node:fs";
|
|
|
28
28
|
import os from "node:os";
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import { resolveExecutor as defaultResolveExecutor } from "./gate-executor.mjs";
|
|
31
|
-
import { directPlatform } from "./managed-cargo.mjs";
|
|
31
|
+
import { directPlatform, realCargoOf } from "./managed-cargo.mjs";
|
|
32
32
|
import { defaultSpawnAsync, sidecarBoundCrate } from "./deterministic-scans.mjs";
|
|
33
33
|
import { gateStateDir } from "./gate-stages.mjs";
|
|
34
34
|
import { ProvenanceError, verifyAttributionDetail } from "./provenance-verify.mjs";
|
|
@@ -362,11 +362,12 @@ export async function clippyCommand(o, args, deps = {}) {
|
|
|
362
362
|
const snap = deps.snapshot ?? await import("./snapshot.mjs");
|
|
363
363
|
const sc = deps.snapshotCheck ?? await import("./snapshot-check.mjs");
|
|
364
364
|
const ownershipApi = deps.ownership ?? await import("./build-ownership.mjs");
|
|
365
|
-
let selected; let snapshotsAt; let snapshotDir;
|
|
365
|
+
let selected; let snapshotsAt; let snapshotDir; let repoKey;
|
|
366
366
|
try {
|
|
367
367
|
selected = snap.resolveCommit(root, "HEAD");
|
|
368
368
|
snapshotsAt = o.snapshotRoot ?? snap.snapshotRoot({ platform: o.platform, env: o.env });
|
|
369
|
-
|
|
369
|
+
repoKey = snap.repositoryOf(root).key;
|
|
370
|
+
snapshotDir = path.join(snapshotsAt, repoKey);
|
|
370
371
|
} catch (e) { o.err(`${tag} cannot select HEAD's commit: ${e.message}; nothing ran`); return REFUSED_EXIT; }
|
|
371
372
|
|
|
372
373
|
// The output directory is the checkout's, resolved by the gate (row 8.12): a caller's CARGO_TARGET_DIR that points into the snapshot would
|
|
@@ -399,9 +400,9 @@ export async function clippyCommand(o, args, deps = {}) {
|
|
|
399
400
|
if (preBound.length) { o.err(sidecarRefusal(tag, preBound, "read from the commit")); resultLine(o, tag, { result: "BLOCKED", code: ROUTE_CODES.sidecarBound, blockedOn: ["item7:7.05"], bound: preBound, launched: false }); return BLOCKED; }
|
|
400
401
|
|
|
401
402
|
const timings = {}; const lap = (name, since) => { timings[name] = Date.now() - since; };
|
|
402
|
-
// Build ownership first (E6): held from before the sync until the diagnostics are written. A busy
|
|
403
|
+
// Build ownership first (E6): held from before the sync until the diagnostics are written. A busy repository runs nothing and syncs nothing.
|
|
403
404
|
let own;
|
|
404
|
-
try { own = (deps.acquireOwnership ?? ownershipApi.acquireBuildOwnership)({ file: ownershipApi.ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: "clippy", snapshot: snapshotDir, commit: selected.commit, platform: o.platform, env: o.env }); }
|
|
405
|
+
try { own = (deps.acquireOwnership ?? ownershipApi.acquireBuildOwnership)({ file: ownershipApi.ownershipFile({ platform: o.platform, beside: o.snapshotRoot, repository: repoKey }), stage: "clippy", snapshot: snapshotDir, commit: selected.commit, platform: o.platform, env: o.env }); }
|
|
405
406
|
catch (e) { if (e.code !== "busy" && e.code !== "refused") throw e; o.err(`${tag} BLOCKED: ${e.message}; ${e.code === "refused" ? "nothing ran, nothing created" : "the route never re-syncs a snapshot another request holds; nothing ran"}`); return BLOCKED; }
|
|
406
407
|
o.out(`${tag} build ownership ${own.joined ? "joined" : "held"} ${own.handle}`);
|
|
407
408
|
const abort = o.abort ?? new AbortController();
|
|
@@ -432,7 +433,7 @@ export async function clippyCommand(o, args, deps = {}) {
|
|
|
432
433
|
// The snapshot's working directory for the request, and the Cargo workspace it names, resolved from the commit's own manifests.
|
|
433
434
|
const snapCwd = relCwd ? path.join(synced.path, ...relCwd.split("/")) : synced.path;
|
|
434
435
|
if (!fs.existsSync(snapCwd)) { o.err(`${tag} BLOCKED: the working directory ${relCwd} is not in commit ${synced.commit.slice(0, 12)}; run from a directory the commit holds; nothing compiled`); return BLOCKED; }
|
|
435
|
-
const realCargo = which("cargo")
|
|
436
|
+
const realCargo = realCargoOf(which("cargo"));
|
|
436
437
|
if (!realCargo) { o.err(`${tag} BLOCKED: no cargo on PATH to wrap for the snapshot compile; nothing compiled`); return BLOCKED; }
|
|
437
438
|
const located = o.spawn("cargo", ["locate-project", "--workspace", "--message-format", "plain", ...(parsed.manifestPath ? ["--manifest-path", parsed.manifestPath] : [])], { cwd: snapCwd, env: o.env });
|
|
438
439
|
if (failed(located) || !path.isAbsolute(text(located).trim())) { o.err(`${tag} BLOCKED: the request's Cargo workspace cannot be resolved in the snapshot (${why(located) || "no manifest"}); nothing compiled`); return BLOCKED; }
|
package/crate-graph.mjs
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// closure and Cargo.lock dependency closure. Pure TOML parsing (smol-toml); nothing shells out to cargo.
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
// smol-toml loads on first parse, not at import: CI jobs whose closure reaches this module without installing
|
|
7
|
+
// @rightkit/release dependencies (publish.yml crates pack, bind) never parse TOML here (rightkit#1341).
|
|
8
|
+
let smolTomlParse;
|
|
9
|
+
const parse = (text) => (smolTomlParse ??= createRequire(import.meta.url)("smol-toml").parse)(text);
|
|
6
10
|
|
|
7
11
|
const DEP_TABLES = ["dependencies", "build-dependencies", "dev-dependencies"];
|
|
8
12
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The OS credential provider (`rightkit-credentials`, crates/rightkit-credentials) that updater signing runs, obtained the one sanctioned way: the gate's
|
|
2
|
+
// tool route (`rightkit-gate --to tool --unit cargo:rightkit-credentials`, tool-build.mjs), never a PATH binary on a Mac or Windows host. It is the
|
|
3
|
+
// pattern of qa-runner.mjs:
|
|
4
|
+
//
|
|
5
|
+
// record `<managed data root>/credential-provider/<mac|win>/record.json`, `{schema, platform, providers: {<version>: {path, sha256, version}}, enrolledAt}`,
|
|
6
|
+
// and the binary at `<managed data root>/credential-provider/<platform>/<version>/rightkit-credentials[.exe]`: a COPY the enrol operation made, so a
|
|
7
|
+
// later `cargo clean` cannot change what signing executes. Outside every repository and snapshot.
|
|
8
|
+
// enrol `enrollCredentialProvider`: the gate-built file of the crate's committed version (>= MIN_PROVIDER_VERSION). A second enrolment of the same
|
|
9
|
+
// version is a rotation and needs `rotate`.
|
|
10
|
+
// use `resolveCredentialProvider`: the NEWEST enrolled version; its file must still be a regular file inside the managed directory whose sha256 IS
|
|
11
|
+
// the recorded one. Anything else is a typed refusal naming the sanctioned install.
|
|
12
|
+
//
|
|
13
|
+
// The binary has NO `--version` (its CLI is `status|set|delete|ensure-key|exec`; any other first word, with no `--namespace`, answers
|
|
14
|
+
// `{"ok":false,"error":"invalid --namespace: required"}` and exits 1). So the version is not attested by the binary: it is the version of the commit the gate built
|
|
15
|
+
// it from (the tool route passes the crate's committed version), bound to the copy by its sha256. What enrolment does check is that the file is this CLI
|
|
16
|
+
// (the identity probe below), which is a regular executable that touches no credential store.
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { spawnSync } from "./background-process.mjs";
|
|
22
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
23
|
+
|
|
24
|
+
export const CREDENTIAL_PROVIDER_SCHEMA = "rightkit.credential-provider/1";
|
|
25
|
+
export const CREDENTIAL_PROVIDER_DIR = "credential-provider";
|
|
26
|
+
export const CREDENTIAL_PROVIDER_PLATFORMS = Object.freeze(["mac", "win"]);
|
|
27
|
+
export const MIN_PROVIDER_VERSION = "0.2.2";
|
|
28
|
+
/** The one sanctioned way to install the provider; every refusal names it. */
|
|
29
|
+
export const CREDENTIAL_PROVIDER_INSTALL = "rightkit-gate --to tool --unit cargo:rightkit-credentials";
|
|
30
|
+
const HOST_PLATFORM = { darwin: "mac", win32: "win" };
|
|
31
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
32
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
33
|
+
|
|
34
|
+
export class CredentialProviderError extends Error {
|
|
35
|
+
constructor(code, message) { super(message); this.name = "CredentialProviderError"; this.code = code; }
|
|
36
|
+
}
|
|
37
|
+
const fail = (code, message) => { throw new CredentialProviderError(code, message); };
|
|
38
|
+
|
|
39
|
+
/** The provider platform of a `process.platform` value, or null when the host has no managed provider (Linux, CI). */
|
|
40
|
+
export const providerPlatformOf = (hostPlatform) => HOST_PLATFORM[hostPlatform] ?? null;
|
|
41
|
+
export const providerExecutableName = (platform) => (platform === "win" ? "rightkit-credentials.exe" : "rightkit-credentials");
|
|
42
|
+
|
|
43
|
+
const parse = (version) => { const m = SEMVER.exec(String(version ?? "")); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; };
|
|
44
|
+
const compare = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i]; return 0; };
|
|
45
|
+
const checkPlatform = (platform) => { if (!CREDENTIAL_PROVIDER_PLATFORMS.includes(platform)) fail("RK_CRED_PROVIDER_PLATFORM", `platform must be ${CREDENTIAL_PROVIDER_PLATFORMS.join(" or ")}, not ${JSON.stringify(platform)}`); };
|
|
46
|
+
const checkDataRoot = (dataRoot) => { if (!dataRoot || !path.isAbsolute(dataRoot)) fail("RK_CRED_PROVIDER_ROOT", "the managed data root must be an absolute path"); };
|
|
47
|
+
const checkVersion = (version) => {
|
|
48
|
+
const v = parse(version);
|
|
49
|
+
if (!v) fail("RK_CRED_PROVIDER_VERSION", `${JSON.stringify(version)} is not a rightkit-credentials version (major.minor.patch)`);
|
|
50
|
+
if (compare(v, parse(MIN_PROVIDER_VERSION)) < 0) fail("RK_CRED_PROVIDER_VERSION", `rightkit-credentials ${version} is below ${MIN_PROVIDER_VERSION}, the first release with the \`exec\` child-environment command updater signing needs`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const credentialProviderDir = ({ dataRoot, platform }) => { checkDataRoot(dataRoot); checkPlatform(platform); return path.join(dataRoot, CREDENTIAL_PROVIDER_DIR, platform); };
|
|
54
|
+
export const credentialProviderRecordFile = (args) => path.join(credentialProviderDir(args), "record.json");
|
|
55
|
+
export const credentialProviderFile = ({ dataRoot, platform, version }) => path.join(credentialProviderDir({ dataRoot, platform }), String(version), providerExecutableName(platform));
|
|
56
|
+
|
|
57
|
+
const sha256OfFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
58
|
+
const isInside = (child, parent) => { const rel = path.relative(parent, child); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); };
|
|
59
|
+
|
|
60
|
+
/** The identity probe: with no arguments the CLI answers a JSON refusal naming `--namespace` and exits 1, and reads no store. A file that answers anything else is not rightkit-credentials. */
|
|
61
|
+
function assertIsCredentialCli(file, spawn) {
|
|
62
|
+
const run = spawn(file, [], { encoding: "utf8", windowsHide: true, timeout: 30000, stdio: ["ignore", "pipe", "pipe"] });
|
|
63
|
+
let answer = null;
|
|
64
|
+
try { answer = JSON.parse(String(run?.stdout ?? "").trim()); } catch { /* not JSON */ }
|
|
65
|
+
if (run?.status !== 1 || answer?.ok !== false || !String(answer?.error ?? "").includes("--namespace")) {
|
|
66
|
+
fail("RK_CRED_PROVIDER_IDENTITY", `${file} did not answer like rightkit-credentials (exit ${run?.status ?? run?.error?.message ?? "none"}, ${JSON.stringify(String(run?.stdout ?? "").slice(0, 80))}); it has no --version, so its identity is its refusal of a call with no --namespace`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readRecord({ dataRoot, platform }) {
|
|
71
|
+
const file = credentialProviderRecordFile({ dataRoot, platform });
|
|
72
|
+
let meta;
|
|
73
|
+
try { meta = fs.lstatSync(file); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
74
|
+
if (meta.isSymbolicLink() || !meta.isFile()) fail("RK_CRED_PROVIDER_RECORD", `${file} is not a regular file`);
|
|
75
|
+
if (process.platform !== "win32" && (meta.mode & 0o022)) fail("RK_CRED_PROVIDER_RECORD", `${file} is writable by group or others`);
|
|
76
|
+
let record;
|
|
77
|
+
try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { fail("RK_CRED_PROVIDER_RECORD", `${file} is not JSON (${error.message})`); }
|
|
78
|
+
if (record?.schema !== CREDENTIAL_PROVIDER_SCHEMA || record.platform !== platform || record.providers === null || typeof record.providers !== "object" || Array.isArray(record.providers)) {
|
|
79
|
+
fail("RK_CRED_PROVIDER_RECORD", `${file} is not a ${CREDENTIAL_PROVIDER_SCHEMA} record for ${platform}`);
|
|
80
|
+
}
|
|
81
|
+
return { file, record };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enrols a gate-built rightkit-credentials (`binary`, an absolute path) of `version`: a COPY under the managed directory, its sha256 recorded. Enrols on the
|
|
86
|
+
* host whose platform it is (the bytes are executed here for the identity probe). Returns `{ file, runnerFile, providerFile, entry, rotated }`.
|
|
87
|
+
*/
|
|
88
|
+
export function enrollCredentialProvider({ dataRoot, platform, binary, version, rotate = false, spawn = spawnSync, hostPlatform = process.platform, now = () => new Date() }) {
|
|
89
|
+
checkPlatform(platform);
|
|
90
|
+
if (HOST_PLATFORM[hostPlatform] !== platform) fail("RK_CRED_PROVIDER_PLATFORM", `a ${platform} provider is enrolled on a ${platform} host (this is ${hostPlatform}): its bytes are executed to check what they are`);
|
|
91
|
+
checkVersion(version);
|
|
92
|
+
const target = credentialProviderFile({ dataRoot, platform, version });
|
|
93
|
+
if (!binary || !path.isAbsolute(String(binary))) fail("RK_CRED_PROVIDER_PATH", "the provider must be named by an absolute path");
|
|
94
|
+
let source; let meta;
|
|
95
|
+
try { source = fs.realpathSync.native(binary); meta = fs.statSync(source); } catch (error) { fail("RK_CRED_PROVIDER_PATH", `the provider ${binary} cannot be resolved (${error.code ?? error.message})`); }
|
|
96
|
+
if (!meta.isFile()) fail("RK_CRED_PROVIDER_PATH", `${source} is not a regular file`);
|
|
97
|
+
// The execute bit is the real file system's, so it is read only where that file system has one (never on Windows, whatever host is simulated).
|
|
98
|
+
if (platform !== "win" && process.platform !== "win32" && (meta.mode & 0o111) === 0) fail("RK_CRED_PROVIDER_PATH", `${source} is not executable`);
|
|
99
|
+
assertIsCredentialCli(source, spawn);
|
|
100
|
+
const existing = readRecord({ dataRoot, platform });
|
|
101
|
+
const had = existing?.record.providers?.[version] ?? null;
|
|
102
|
+
if (had && !rotate) fail("RK_CRED_PROVIDER_EXISTS", `${existing.file} already enrols rightkit-credentials ${version} (sha256 ${String(had.sha256).slice(0, 12)}); replacing it is a rotation (pass rotate)`);
|
|
103
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
104
|
+
const staging = `${target}.${crypto.randomUUID()}.tmp`;
|
|
105
|
+
try {
|
|
106
|
+
fs.copyFileSync(source, staging, fs.constants.COPYFILE_EXCL);
|
|
107
|
+
fs.chmodSync(staging, 0o755);
|
|
108
|
+
assertIsCredentialCli(staging, spawn);
|
|
109
|
+
fs.renameSync(staging, target);
|
|
110
|
+
} finally { fs.rmSync(staging, { force: true }); }
|
|
111
|
+
const entry = { path: target, sha256: sha256OfFile(target), version };
|
|
112
|
+
const record = { schema: CREDENTIAL_PROVIDER_SCHEMA, platform, providers: { ...(existing?.record.providers ?? {}), [version]: entry }, enrolledAt: now().toISOString() };
|
|
113
|
+
const file = credentialProviderRecordFile({ dataRoot, platform });
|
|
114
|
+
const tmp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
115
|
+
try { fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 }); fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
116
|
+
return { file, runnerFile: target, providerFile: target, entry, rotated: Boolean(had) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The enrolled provider: the NEWEST recorded version, `{ file, sha256, version, source: "managed" }`, or a typed refusal. The file the record names must be the
|
|
121
|
+
* managed copy (inside the managed directory, not a link) and its bytes must still hash to the recorded sha256. It is never executed here and there is no
|
|
122
|
+
* fallback: a missing enrolment names the install.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveCredentialProvider({ dataRoot, platform }) {
|
|
125
|
+
checkPlatform(platform);
|
|
126
|
+
const read = readRecord({ dataRoot, platform });
|
|
127
|
+
const versions = Object.keys(read?.record.providers ?? {}).filter((v) => parse(v)).sort((a, b) => compare(parse(b), parse(a)));
|
|
128
|
+
if (!versions.length) fail("RK_CRED_PROVIDER_MISSING", `OS credential provider unavailable: rightkit-credentials >=${MIN_PROVIDER_VERSION} is not enrolled for ${platform} (${credentialProviderRecordFile({ dataRoot, platform })}); install it with \`${CREDENTIAL_PROVIDER_INSTALL}\``);
|
|
129
|
+
const version = versions[0];
|
|
130
|
+
const entry = read.record.providers[version];
|
|
131
|
+
const expected = credentialProviderFile({ dataRoot, platform, version });
|
|
132
|
+
if (entry.version !== version || !SHA256.test(String(entry.sha256)) || entry.path !== expected) fail("RK_CRED_PROVIDER_RECORD", `${read.file}: the entry for ${version} is malformed or names a file other than ${expected}`);
|
|
133
|
+
let meta;
|
|
134
|
+
try { meta = fs.lstatSync(expected); } catch (error) { fail("RK_CRED_PROVIDER_MISSING", `OS credential provider unavailable: the enrolled rightkit-credentials ${version} is gone (${expected}: ${error.code ?? error.message}); install it again with \`${CREDENTIAL_PROVIDER_INSTALL}\``); }
|
|
135
|
+
if (meta.isSymbolicLink() || !meta.isFile()) fail("RK_CRED_PROVIDER_RECORD", `${expected} is not a regular file`);
|
|
136
|
+
if (!isInside(fs.realpathSync.native(expected), fs.realpathSync.native(path.join(dataRoot, CREDENTIAL_PROVIDER_DIR)))) fail("RK_CRED_PROVIDER_RECORD", `${expected} resolves outside ${path.join(dataRoot, CREDENTIAL_PROVIDER_DIR)}`);
|
|
137
|
+
const actual = sha256OfFile(expected);
|
|
138
|
+
if (actual !== entry.sha256) fail("RK_CRED_PROVIDER_DIGEST", `the enrolled rightkit-credentials ${version} at ${expected} has sha256 ${actual}, not the recorded ${entry.sha256}; it changed after enrolment, so it is not run (install it again with \`${CREDENTIAL_PROVIDER_INSTALL}\`, then rotate)`);
|
|
139
|
+
return { file: expected, sha256: actual, version, source: "managed" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The provider updater signing runs on a host: the enrolled one on a Mac or Windows host (its managed data root, `hostPlatform` decides), the bare PATH name
|
|
144
|
+
* only where no managed data root exists (Linux, CI). Returns `{ file, managed }`; a managed host with no usable enrolment throws the typed refusal.
|
|
145
|
+
*/
|
|
146
|
+
export function updaterCredentialProvider({ env = process.env, hostPlatform = process.platform, dataRoot, resolve = resolveCredentialProvider } = {}) {
|
|
147
|
+
const platform = providerPlatformOf(hostPlatform);
|
|
148
|
+
if (!platform) return { file: "rightkit-credentials", managed: false };
|
|
149
|
+
return { file: resolve({ dataRoot: dataRoot ?? resolveManagedRoots({ env, platform: hostPlatform }).dataRoot, platform }).file, managed: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `node credential-provider.mjs enroll --platform mac|win --binary <built rightkit-credentials> --version <v> [--data-root <dir>] [--rotate]` or `show`. */
|
|
153
|
+
export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err = (m) => process.stderr.write(`${m}\n`), env = process.env, spawn, program = "credential-provider.mjs" } = {}) {
|
|
154
|
+
const [command, ...rest] = argv;
|
|
155
|
+
const opt = (name) => { const i = rest.indexOf(name); return i < 0 ? undefined : rest[i + 1]; };
|
|
156
|
+
try {
|
|
157
|
+
const platform = opt("--platform") ?? HOST_PLATFORM[process.platform];
|
|
158
|
+
const dataRoot = opt("--data-root") ?? resolveManagedRoots({ env }).dataRoot;
|
|
159
|
+
if (command === "enroll") {
|
|
160
|
+
const r = enrollCredentialProvider({ dataRoot, platform, binary: opt("--binary") && path.resolve(opt("--binary")), version: opt("--version"), rotate: rest.includes("--rotate"), ...(spawn ? { spawn } : {}) });
|
|
161
|
+
out(`enrolled rightkit-credentials ${r.entry.version} for ${platform} (sha256 ${r.entry.sha256}) at ${r.providerFile}${r.rotated ? " (rotation)" : ""}`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (command === "show") {
|
|
165
|
+
const read = readRecord({ dataRoot, platform });
|
|
166
|
+
out(read ? JSON.stringify(read.record) : "no enrolment");
|
|
167
|
+
return read ? 0 : 1;
|
|
168
|
+
}
|
|
169
|
+
err(`usage: ${program} enroll --platform mac|win --binary <built rightkit-credentials> --version <v> [--data-root <dir>] [--rotate]\n ${program} show [--platform mac|win] [--data-root <dir>]`);
|
|
170
|
+
return 2;
|
|
171
|
+
} catch (error) { err(`${error.code ?? "error"}: ${error.message}`); return 1; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (process.argv[1] && (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })()) process.exit(main(process.argv.slice(2)));
|