@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.
Files changed (65) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +481 -103
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +253 -42
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +114 -9
  52. package/snapshot-check.mjs +6 -1
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar-command.mjs +7 -1
  58. package/stage-sidecar.mjs +17 -3
  59. package/suite-signing.mjs +40 -4
  60. package/swift-lock.mjs +334 -0
  61. package/test-command.mjs +13 -3
  62. package/tool-build.mjs +156 -0
  63. package/unit-gate.mjs +9 -2
  64. package/windows-identity-plan.mjs +119 -0
  65. package/windows-identity.mjs +136 -0
package/managed-cargo.mjs CHANGED
@@ -125,12 +125,38 @@ function fileHead(file) {
125
125
  catch { return ""; } finally { if (fd !== null) try { closeSync(fd); } catch { /* closed */ } }
126
126
  }
127
127
 
128
+ /** True when `file` is a gate's own runtime cargo wrapper (cargo-target-shim.mjs installCargoShim writes it), not a toolchain cargo. */
129
+ export function isGateCargoWrapper(file, readHead = fileHead) {
130
+ const head = readHead(file);
131
+ return head.startsWith("#!/bin/sh\n") && head.includes("cargo-target-shim.mjs") && head.includes(" --map ");
132
+ }
133
+
134
+ /**
135
+ * The absolute cargo a gate wraps: the first of `candidates` (a PATH lookup, in order) that is not another gate's runtime wrapper, or the
136
+ * toolchain cargo such a wrapper's map records (a lookup that returns only the first match finds only the wrapper).
137
+ * A gate started inside a scan of an outer gate finds that gate's wrapper first on PATH; wrapping it would send every call through
138
+ * the outer map, which binds none of this gate's workspaces (0.6.18 Linux qualify: RK_CARGO_UNBOUND_WORKSPACE "resolved (none)").
139
+ */
140
+ export function realCargoOf(candidates, readHead = fileHead, readMap = (file) => JSON.parse(readFileSync(file, "utf8"))) {
141
+ for (const c of candidates) {
142
+ if (!path.isAbsolute(c)) continue;
143
+ if (!isGateCargoWrapper(c, readHead)) return c;
144
+ // A PATH lookup (`which`) names only the first match: the wrapper's own map records the toolchain cargo it wraps.
145
+ try {
146
+ const at = /--map '([^']+)'/.exec(readHead(c));
147
+ const wrapped = at ? readMap(at[1]).realCargo : null;
148
+ if (typeof wrapped === "string" && path.isAbsolute(wrapped) && !isGateCargoWrapper(wrapped, readHead)) return wrapped;
149
+ } catch { /* an unreadable map names nothing: keep looking */ }
150
+ }
151
+ return undefined;
152
+ }
153
+
128
154
  /**
129
155
  * On a direct (Linux) host: PATH without the directories whose `cargo` is a RustRight shim (the managed agent-bin, a configured
130
156
  * RIGHTKIT_MANAGED_AGENT_BIN, or a script that routes through the broker, read 4 KiB at most), and the first real cargo left on it.
131
157
  * Plain `cargo` then can only be a real toolchain; with none, a cargo launch fails as missing, never through a shim.
132
158
  */
133
- export function directCargoPath({ env = process.env, exists = existsSync, readHead = fileHead, platform = process.platform } = {}) {
159
+ export function directCargoPath({ env = process.env, exists = existsSync, readHead = fileHead, platform = process.platform, gateWrappers = false } = {}) {
134
160
  // `platform` only matters for a forced direct run on Windows (a recorded broker exception): ";" separators, backslash paths, cargo.exe.
135
161
  const win = platform === "win32";
136
162
  const norm = (d) => (win ? String(d).replaceAll("\\", "/") : d);
@@ -139,7 +165,10 @@ export function directCargoPath({ env = process.env, exists = existsSync, readHe
139
165
  for (const dir of String(env.PATH ?? "").split(win ? ";" : ":").filter(Boolean)) {
140
166
  const candidates = win ? ["cargo.exe", "cargo.cmd", "cargo.bat", "cargo"].map((n) => path.win32.join(dir, n)) : [path.posix.join(dir, "cargo")];
141
167
  const found = candidates.find((c) => exists(c));
142
- const isShim = Boolean(found) && (MANAGED_AGENT_BIN.test(norm(dir)) || (configured && path.posix.resolve(norm(dir)) === configured) || /cargo-guard\.mjs|rustright/i.test(readHead(found)));
168
+ // `gateWrappers`: a gate resolving its OWN executor also skips another gate's runtime cargo wrapper (a gate started inside an outer gate's scan,
169
+ // 0.6.18/0.6.19 Linux qualify). A gate's children never pass it: inside a stage the wrapper first on PATH is the binding they must go through. The
170
+ // one other caller is test-support/toolchain-cargo.mjs, for a test's throwaway fixtures under the temp directory, never anything in the snapshot.
171
+ const isShim = Boolean(found) && (MANAGED_AGENT_BIN.test(norm(dir)) || (configured && path.posix.resolve(norm(dir)) === configured) || /cargo-guard\.mjs|rustright/i.test(readHead(found)) || (gateWrappers && isGateCargoWrapper(found, readHead)));
143
172
  if (isShim) { dropped.push(dir); continue; }
144
173
  kept.push(dir);
145
174
  if (!cargo && found) cargo = found;
@@ -201,28 +230,35 @@ function carriedSeam(args, options, { platform, spawn }) {
201
230
  return runCarriedCargo(args, options, { map: carried.map, spawn });
202
231
  }
203
232
 
233
+ /**
234
+ * The ONE route a call takes to the broker's client on a broker host (runCargoSync and cargo-guard both use it): the declared Linux route when the host declared one (only when it
235
+ * validates; an invalid one is `{ refusal }`, never a fallback to another client), else the installed runtime client with its discovered socket (on the Mac a missing socket is
236
+ * `{ refusal: "RightKit broker socket is unavailable" }`), else null. `{ client, env, mapUnavailable }` runs as `<client> cargo <args>`; `mapUnavailable` says a RK_BROKER_UNAVAILABLE
237
+ * answer of the client is to be reported as an unavailable broker (the declared route, and the Mac).
238
+ */
239
+ export function brokerClientRoute({ env = process.env, platform = process.platform, exists = existsSync, fsOps, linuxRoute = resolveLinuxBrokerRoute, installedClient = managedRuntimeClient, socketOf = discoverBrokerSocket } = {}) {
240
+ const linux = linuxRoute({ env, platform, fsOps });
241
+ if (linux.declared) return linux.ok ? { client: linux.client, env: { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: linux.socket }, mapUnavailable: true } : { refusal: linux.problem };
242
+ const client = installedClient(env, platform, exists);
243
+ if (!client) return null;
244
+ const socket = socketOf(env, { platform, exists });
245
+ if (platform === 'darwin' && (!socket || !exists(socket))) return { refusal: 'RightKit broker socket is unavailable' };
246
+ return { client, env: socket ? { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: socket } : env, mapUnavailable: platform === 'darwin' };
247
+ }
248
+
204
249
  export function runCargoSync(args, options = {}, { platform = process.platform, exists = existsSync, spawn = spawnSync, fsOps } = {}) {
205
250
  const env = options.env ?? process.env;
206
251
  const seam = carriedSeam(args, options, { platform, spawn });
207
252
  if (seam) return seam;
208
253
  // A gate run outside the broker by Adrian's recorded exception: the real toolchain, shims off PATH, on every platform (NO_BROKER_ENV).
209
254
  if (gateNoBroker(env)) return spawn("cargo", args, { ...options, env: directCargoEnv(env, platform, exists) });
210
- // A Linux host that declared a broker route: every call goes to the validated client at the declared endpoint, or is refused. Never direct.
211
- const linux = resolveLinuxBrokerRoute({ env, platform, fsOps });
212
- if (linux.declared) {
213
- if (!linux.ok) return unavailable(linux.problem);
214
- const result = spawn(linux.client, ["cargo", ...args], { ...options, env: { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: linux.socket } });
215
- if (result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) return { ...result, error: unavailable(String(result.stderr).trim()).error };
216
- return result;
217
- }
218
- const client = managedRuntimeClient(env, platform, exists);
219
- if (client) {
220
- const socket = discoverBrokerSocket(env, { platform, exists });
221
- if (platform === 'darwin' && (!socket || !exists(socket))) return unavailable('RightKit broker socket is unavailable');
222
- const childEnv = socket ? { ...env, RUSTRIGHT_BUILD_BROKER_SOCKET: socket } : env;
223
- const invocation = managedRuntimeCommand(client, ["cargo", ...args]);
224
- const result = spawn(invocation.command, invocation.args, { ...options, env: childEnv });
225
- if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
255
+ // A Linux host that declared a broker route: every call goes to the validated client at the declared endpoint, or is refused. Never direct. Otherwise the managed client (brokerClientRoute).
256
+ const route = brokerClientRoute({ env, platform, exists, fsOps });
257
+ if (route?.refusal) return unavailable(route.refusal);
258
+ if (route) {
259
+ const invocation = managedRuntimeCommand(route.client, ["cargo", ...args]);
260
+ const result = spawn(invocation.command, invocation.args, { ...options, env: route.env });
261
+ if (route.mapUnavailable && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
226
262
  return { ...result, error: unavailable(String(result.stderr).trim()).error };
227
263
  }
228
264
  return result;
@@ -1,5 +1,9 @@
1
1
  // Stage 1: resolved lock graphs, without feature or production-only pruning.
2
- import { parse as parseToml } from "smol-toml";
2
+ import { createRequire } from "node:module";
3
+ // smol-toml loads on first parse, not at import: CI jobs whose closure reaches this module without installing
4
+ // @rightkit/release dependencies (publish.yml crates pack, bind) never parse TOML here (rightkit#1341).
5
+ let smolTomlParse;
6
+ const parseToml = (text) => (smolTomlParse ??= createRequire(import.meta.url)("smol-toml").parse)(text);
3
7
  import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
4
8
  import path from "node:path";
5
9
  import { sha256 } from "./ownership-approval.mjs";
@@ -281,8 +285,14 @@ function pnpmGraph(text, manifests, lockDir, workspaceBytes) {
281
285
  if (typeof ref !== "string") fail("invalid pnpm dependency version");
282
286
  if (/^(?:link:|workspace:)/.test(ref)) {
283
287
  if (!ref.startsWith("link:")) throw new LockError("lockfile-unresolved-dependency", ref);
284
- const importer = node.id.replace(/^importer:/, "");
285
- return g.nodes.get(`importer:${path.posix.normalize(path.posix.join(importer, ref.slice(5)))}`);
288
+ const importer = node.id.replace(/^importer:/, ""), target = path.posix.normalize(path.posix.join(importer, ref.slice(5)));
289
+ const found = g.nodes.get(`importer:${target}`);
290
+ if (found) return found;
291
+ // A link to a directory of the scanned repository outside this lock's importers (RightKit's own sample apps link
292
+ // packages/*) is that repository's own source: a local node, never a gap. A link leaving the repository stays unresolved.
293
+ const repoRel = path.posix.normalize(path.posix.join(lockDir, target));
294
+ if (repoRel === ".." || repoRel.startsWith("../") || path.posix.isAbsolute(repoRel)) return undefined;
295
+ return g.nodes.get(`linked:${repoRel}`) ?? g.add(`linked:${repoRel}`, name, "0.0.0", `path:${repoRel}`, true);
286
296
  }
287
297
  const candidates = [ref, `${name}@${ref}`, `/${name}@${ref}`];
288
298
  const exact = candidates.map((id) => g.nodes.get(id)).find(Boolean);
@@ -292,6 +302,7 @@ function pnpmGraph(text, manifests, lockDir, workspaceBytes) {
292
302
  return g.nodes.get(normalized) ?? g.nodes.get(`/${normalized}`);
293
303
  };
294
304
  for (const node of g.nodes.values()) {
305
+ if (node.id.startsWith("linked:")) continue; // its own dependencies belong to its own manifest and lock
295
306
  const isImporter = node.id.startsWith("importer:"), pkg = isImporter ? lock.importers[node.id.slice(9)] : snapshots[node.id];
296
307
  for (const section of isImporter ? sections : ["dependencies", "optionalDependencies"]) {
297
308
  if (pkg[section] !== undefined && !object(pkg[section])) fail("invalid pnpm dependency map");
package/ownership.mjs CHANGED
@@ -2,7 +2,11 @@
2
2
  import { isBlockingFinding } from "./ownership-verdict.mjs";
3
3
  import { spawnSync } from "./background-process.mjs";
4
4
  import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash, resolveKeyLogTrust, sha256, verifyProtectedProvenance } from "./ownership-approval.mjs";
5
- import { parse as parseToml } from "smol-toml";
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 parseToml = (text) => (smolTomlParse ??= createRequire(import.meta.url)("smol-toml").parse)(text);
6
10
  import { sourceNonCode } from "./ownership-source.mjs";
7
11
  import { OWNERSHIP_LOCKFILES, transitiveDependencies } from "./ownership-lockfiles.mjs";
8
12
  import { ALLOWLIST_FILE, ALLOWLIST_FINDING_ID, evaluateDependency, sourceKind, validateAllowlist } from "./ownership-dependency-allowlist.mjs";
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.16",
3
+ "version": "0.6.21",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "right-release": "cli/right-release.mjs",
9
9
  "rightkit-gate": "bin/rightkit-gate.mjs",
10
+ "rightkit-linux-setup": "bin/rightkit-linux-setup.mjs",
10
11
  "rightkit-agent-hook": "bin/rightkit-agent-hook.mjs"
11
12
  },
12
13
  "exports": {
13
14
  "./bin/rightkit-agent-hook.mjs": "./bin/rightkit-agent-hook.mjs",
14
15
  "./bin/rightkit-gate.mjs": "./bin/rightkit-gate.mjs",
16
+ "./bin/rightkit-linux-setup.mjs": "./bin/rightkit-linux-setup.mjs",
15
17
  "./cargo-contract.mjs": "./cargo-contract.mjs",
16
18
  "./cargo-target.mjs": "./cargo-target.mjs",
17
19
  "./cli/right-release.mjs": "./cli/right-release.mjs",
@@ -23,6 +25,7 @@
23
25
  "./engine-locator.mjs": "./engine-locator.mjs",
24
26
  "./github-release.mjs": "./github-release.mjs",
25
27
  "./legal-contract.mjs": "./legal-contract.mjs",
28
+ "./linux-setup.mjs": "./linux-setup.mjs",
26
29
  "./macos-file-evidence.mjs": "./macos-file-evidence.mjs",
27
30
  "./macos-notarization.mjs": "./macos-notarization.mjs",
28
31
  "./macos-signing-identity.mjs": "./macos-signing-identity.mjs",
@@ -53,6 +56,9 @@
53
56
  "rightkit-gate": [
54
57
  "--version"
55
58
  ],
59
+ "rightkit-linux-setup": [
60
+ "--version"
61
+ ],
56
62
  "rightkit-agent-hook": {
57
63
  "args": [],
58
64
  "output": "{}"
@@ -80,14 +86,6 @@
80
86
  "!provenance-real-receipt.fixture.json"
81
87
  ],
82
88
  "sideEffects": false,
83
- "scripts": {
84
- "test": "node test-support/run-test-shards.mjs",
85
- "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
86
- "test:signer:windows": "node sign-release-manifest.integration.mjs",
87
- "prepublishOnly": "pnpm test && pnpm test:signer:windows && pnpm test:registry-parity",
88
- "doctor:all": "node --test right-suite-contract.test.mjs",
89
- "verify:standalone": "node standalone-clone-verify.mjs"
90
- },
91
89
  "publishConfig": {
92
90
  "registry": "https://registry.npmjs.org/",
93
91
  "access": "public"
@@ -97,9 +95,17 @@
97
95
  "url": "git+https://github.com/bogusyogi/rightkit.git",
98
96
  "directory": "packages/release"
99
97
  },
100
- "packageManager": "pnpm@12.16.1",
101
98
  "dependencies": {
102
99
  "@rightkit/outdated": "0.3.0",
103
100
  "smol-toml": "1.9.1"
101
+ },
102
+ "scripts": {
103
+ "test": "node test-support/run-test-shards.mjs --class=unit",
104
+ "test:broker": "node test-support/run-test-shards.mjs --class=broker",
105
+ "test:all": "node test-support/run-test-shards.mjs --class=all",
106
+ "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
107
+ "test:signer:windows": "node sign-release-manifest.integration.mjs",
108
+ "doctor:all": "node --test right-suite-contract.test.mjs",
109
+ "verify:standalone": "node standalone-clone-verify.mjs"
104
110
  }
105
- }
111
+ }
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { isManagedHost, refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
6
+ import { CREDENTIAL_PROVIDER_INSTALL, updaterCredentialProvider } from "./credential-provider.mjs";
6
7
 
7
8
  // Protected credential loaders for release sites that previously read only
8
9
  // ambient environment variables. The storage pattern matches release-token.mjs:
@@ -35,13 +36,17 @@ export class ProtectedSecretMissingError extends Error {
35
36
 
36
37
  // One-line command the operator runs once per host. macOS prompts for the value
37
38
  // (-w with no argument); Windows prompts with Read-Host -AsSecureString.
38
- export function protectedSecretStoreCommand(name, platform = process.platform) {
39
+ // `provider` is the enrolled rightkit-credentials a Windows store command runs (execWithUpdaterCredential's file); unset, the bare executable name.
40
+ export function protectedSecretStoreCommand(name, platform = process.platform, { provider, env = process.env } = {}) {
39
41
  const spec = PROTECTED_SECRETS[name];
40
42
  if (!spec) throw new Error(`unknown protected secret: ${name}`);
41
43
  if (platform === "darwin" && spec.darwinFile) return null;
42
44
  if (platform === "darwin") return `security add-generic-password -a "$USER" -s ${spec.keychain} -U -w`;
43
45
  if (platform === "win32") {
44
- if (spec.providerExec) return `$rightkitSecure = Read-Host -AsSecureString '${spec.label}'; $rightkitPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($rightkitSecure); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rightkitPointer) | & rightkit-credentials.exe set --namespace '${spec.keychain}' --account ([Environment]::UserName) --replace } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rightkitPointer) }`;
46
+ if (spec.providerExec && !provider && platform === process.platform) {
47
+ try { provider = updaterCredentialProvider({ env, hostPlatform: platform }).file; } catch { /* not enrolled: the hint names the bare executable; signing itself refuses with the install command */ }
48
+ }
49
+ if (spec.providerExec) return `$rightkitSecure = Read-Host -AsSecureString '${spec.label}'; $rightkitPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($rightkitSecure); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rightkitPointer) | & ${provider ? `'${String(provider).replaceAll("'", "''")}'` : "rightkit-credentials.exe"} set --namespace '${spec.keychain}' --account ([Environment]::UserName) --replace } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rightkitPointer) }`;
45
50
  return `powershell -NoProfile -Command "$s = Read-Host -AsSecureString '${spec.label}'; $d = Join-Path $env:APPDATA 'RightKit'; New-Item -ItemType Directory -Force -Path $d | Out-Null; [IO.File]::WriteAllText((Join-Path $d '${spec.file}'), [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)))"`;
46
51
  }
47
52
  return null;
@@ -120,9 +125,11 @@ export function resolveEnvBackedSecret({ name, envName, env = process.env, crede
120
125
 
121
126
  // M11 owns OS acquisition & secret lifetime. The release adapter supplies only
122
127
  // non-secret identity plus an absolute native child executable and literal argv.
128
+ // The provider is the ENROLLED rightkit-credentials (credential-provider.mjs; installed by `rightkit-gate --to tool --unit cargo:rightkit-credentials`) on a Mac or
129
+ // Windows host, resolved and re-hashed here; the bare PATH name is used only where no managed data root exists (Linux, CI). `provider` names a file explicitly.
123
130
  export function execWithUpdaterCredential(command, args, {
124
- env = process.env, cwd = process.cwd(), run = spawnSync,
125
- provider = process.platform === "win32" ? "rightkit-credentials.exe" : "rightkit-credentials",
131
+ env = process.env, cwd = process.cwd(), run = spawnSync, hostPlatform = process.platform, dataRoot,
132
+ provider,
126
133
  account = os.userInfo().username,
127
134
  } = {}) {
128
135
  for (const key of Object.keys(env)) {
@@ -131,10 +138,11 @@ export function execWithUpdaterCredential(command, args, {
131
138
  }
132
139
  }
133
140
  if (!path.isAbsolute(command)) throw new Error("credential provider child must be an absolute native executable");
141
+ provider ??= updaterCredentialProvider({ env, hostPlatform, dataRoot }).file;
134
142
  const result = run(provider, ["exec", "--namespace", PROTECTED_SECRETS["updater-signing-key"].keychain,
135
143
  "--account", account, "--env", "TAURI_SIGNING_PRIVATE_KEY", "--", command, ...args],
136
144
  { cwd, env: { ...env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" }, stdio: "inherit", shell: false, windowsHide: true });
137
- if (result.error?.code === "ENOENT") throw new Error("OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing");
145
+ if (result.error?.code === "ENOENT") throw new Error(`OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing (a Mac or Windows host installs it with \`${CREDENTIAL_PROVIDER_INSTALL}\`)`);
138
146
  return result;
139
147
  }
140
148
 
@@ -177,7 +177,12 @@ export function validatePublicationEvidence({ expected = {}, evidence, artifactB
177
177
  if (!SHA.test(String(commit ?? ""))) missing.push("expected commit is not a full 40-character SHA");
178
178
  const defaults = requiredHosts(kind);
179
179
  let hosts = defaults;
180
- if (expected.requiredHosts !== undefined) {
180
+ // GB-29 (rightkit#1341): a direct Linux qualification bundle proves its own Linux run; Mac and Windows are proved by the publish run's
181
+ // native verify jobs before upload. Only an explicit caller request with linux-direct evidence selects it; it never satisfies an upload.
182
+ if (expected.directLinux === true) {
183
+ if (evidence?.hostPolicy !== "linux-direct" || kind === "swift") missing.push("direct Linux qualification needs linux-direct cargo or npm evidence");
184
+ hosts = ["linux"];
185
+ } else if (expected.requiredHosts !== undefined) {
181
186
  const requested = expected.requiredHosts;
182
187
  if (!(Array.isArray(requested) && requested.length > 0 && new Set(requested).size === requested.length
183
188
  && Array.from(requested).every((host) => defaults.includes(host)))) {
@@ -197,7 +202,7 @@ export function validatePublicationEvidence({ expected = {}, evidence, artifactB
197
202
  const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
198
203
  if (runs.some((run) => run?.batch !== undefined) && !SHA.test(String(tree ?? ""))) missing.push("expected tree is not a full 40-character SHA for batch evidence");
199
204
  for (const host of hosts) {
200
- const label = host === "darwin" ? "macOS" : "Windows";
205
+ const label = host === "darwin" ? "macOS" : host === "linux" ? "Linux" : "Windows";
201
206
  const matching = runs.filter((run) => run?.host?.os === host);
202
207
  if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
203
208
  // Every run on the host must qualify: a failing run is not masked by a passing run beside it.
@@ -213,7 +218,7 @@ export function validatePublicationEvidence({ expected = {}, evidence, artifactB
213
218
  }
214
219
 
215
220
  // Acquire local source identity and cleanliness, then use the same content validator as CI.
216
- export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
221
+ export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, directLinux = false, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
217
222
  const missing = [];
218
223
  const head = git(["rev-parse", "HEAD"], packagePath);
219
224
  if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
@@ -230,7 +235,7 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
230
235
  // Local publication retains its recorded single-host grant. CI callers own their policy separately.
231
236
  const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
232
237
  const result = validatePublicationEvidence({ expected: { kind, name, version, commit: head, tree,
233
- ...(singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
238
+ ...(directLinux ? { directLinux: true } : singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
234
239
  for (const problem of result.missing) {
235
240
  if (problem === `evidence commit ${evidence?.commit} != expected commit ${head}`) {
236
241
  missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
@@ -0,0 +1,209 @@
1
+ // File-only Python lock check (class A scan `python-lock`). Reads committed files in one directory and executes nothing (no pip, uv or poetry).
2
+ // * `*pins.txt` (and `requirements*.txt` compiled from a sibling `<stem>.in`): every entry must be an exact `name==version` pin; when the
3
+ // `<stem>.in` (or `<stem>.txt`) it was pinned from exists, each of its requirements must be satisfied by the pin of that package.
4
+ // * `requirements*.txt` or `pyproject.toml` beside a `uv.lock` / `poetry.lock`: each direct requirement must be satisfied by the locked
5
+ // version of that package, and a requirements file's unpinned entries are fine only where no lock exists (they are then the declaration).
6
+ // A requirement line the checker cannot read literally (URL, VCS, editable, unknown specifier) is a finding, never a silent pass. An entry with
7
+ // an environment marker is checked when its package is pinned and not required to be pinned (a lock may be for another platform).
8
+
9
+ export const PYTHON_LOCK_FILE = /(^|\/)(requirements[^/]*\.(txt|in)|[^/]*pins?\.txt|pyproject\.toml|uv\.lock|poetry\.lock)$/;
10
+ const join = (dir, f) => (dir === "" || dir === "." ? f : `${dir}/${f}`);
11
+ export const normalizeName = (n) => String(n).toLowerCase().replace(/[-_.]+/g, "-");
12
+
13
+ // ---- versions (PEP 440 subset) --------------------------------------------------------------------------------------------
14
+ const PEP440 = /^v?(\d+(?:\.\d+)*)(?:[-_.]?(a|b|rc|alpha|beta|c|pre|preview)[-_.]?(\d*))?(?:[-_.]?(?:post|rev|r)[-_.]?(\d*))?(?:[-_.]?dev[-_.]?(\d*))?(?:\+[A-Za-z0-9.]+)?$/i;
15
+ export function parsePyVersion(text) {
16
+ const m = PEP440.exec(String(text).trim());
17
+ if (!m) return null;
18
+ const rank = { a: 0, alpha: 0, b: 1, beta: 1, c: 2, rc: 2, pre: 2, preview: 2 };
19
+ return { rel: m[1].split(".").map(Number), pre: m[2] ? [rank[m[2].toLowerCase()], Number(m[3] || 0)] : null, post: m[4] !== undefined ? Number(m[4] || 0) : null, dev: m[5] !== undefined ? Number(m[5] || 0) : null, text: String(text).trim() };
20
+ }
21
+ export function comparePyVersions(a, b) {
22
+ for (let i = 0; i < Math.max(a.rel.length, b.rel.length); i++) { const x = a.rel[i] ?? 0; const y = b.rel[i] ?? 0; if (x !== y) return x < y ? -1 : 1; }
23
+ const key = (v) => [v.dev !== null && v.pre === null && v.post === null ? -1 : 0, v.pre ? 0 : 1, ...(v.pre ?? [0, 0]), v.post ?? -1, v.dev === null ? 1 : 0, v.dev ?? 0];
24
+ const ka = key(a); const kb = key(b);
25
+ for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] < kb[i] ? -1 : 1;
26
+ return 0;
27
+ }
28
+
29
+ /** Clauses of a specifier string ("" for any): [{ op, version, wildcard }] or null when not readable. */
30
+ export function parseSpecifier(spec) {
31
+ const text = String(spec).trim().replace(/^\(|\)$/g, "");
32
+ if (text === "") return [];
33
+ const out = [];
34
+ for (const part of text.split(",")) {
35
+ const m = /^\s*(===|==|!=|~=|>=|<=|>|<)\s*([^\s,]+?)(\.\*)?\s*$/.exec(part);
36
+ if (!m) return null;
37
+ if (m[3] && !["==", "!="].includes(m[1])) return null;
38
+ const version = parsePyVersion(m[2]);
39
+ if (!version) return null;
40
+ out.push({ op: m[1], version, wildcard: Boolean(m[3]), raw: part.trim() });
41
+ }
42
+ return out;
43
+ }
44
+ /** true when `version` (parsed) satisfies every clause. */
45
+ export function satisfies(version, clauses) {
46
+ return clauses.every(({ op, version: v, wildcard }) => {
47
+ const c = comparePyVersions(version, v);
48
+ switch (op) {
49
+ case "===": case "==": return wildcard ? v.rel.every((n, i) => (version.rel[i] ?? 0) === n) : c === 0;
50
+ case "!=": return wildcard ? !v.rel.every((n, i) => (version.rel[i] ?? 0) === n) : c !== 0;
51
+ case ">=": return c >= 0;
52
+ case "<=": return c <= 0;
53
+ case ">": return c > 0;
54
+ case "<": return c < 0;
55
+ case "~=": { if (c < 0 || v.rel.length < 2) return false; return v.rel.slice(0, -1).every((n, i) => (version.rel[i] ?? 0) === n); }
56
+ default: return false;
57
+ }
58
+ });
59
+ }
60
+
61
+ // ---- requirement lines ----------------------------------------------------------------------------------------------------
62
+ /** { reqs: [{ line, name, spec, clauses, marker, pinned }], problems: [{ line, text }] } from requirements-file text. */
63
+ export function parseRequirements(text) {
64
+ const reqs = []; const problems = [];
65
+ const lines = String(text).split(/\r?\n/);
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const start = i + 1; let line = lines[i];
68
+ while (/\\\s*$/.test(line) && i + 1 < lines.length) { line = line.replace(/\\\s*$/, " ") + lines[++i]; }
69
+ line = line.replace(/(^|\s)#.*$/, "").trim();
70
+ if (!line) continue;
71
+ if (/^-(r|c|-requirement|-constraint)\b/.test(line) || /^--(index-url|extra-index-url|find-links|trusted-host|no-binary|only-binary|prefer-binary|pre|require-hashes|no-index|use-feature|prefer-binary)\b/.test(line) || /^-[ifz]\s/.test(line)) continue;
72
+ if (/^-e\b|^--editable\b|^-/.test(line)) { problems.push({ line: start, text: `option ${line.slice(0, 50)} cannot be read literally` }); continue; }
73
+ line = line.replace(/\s--hash[=\s]\S+/g, "").trim();
74
+ const [body, ...markerParts] = line.split(";");
75
+ const marker = markerParts.join(";").trim() || null;
76
+ if (/@|:\/\/|^\.{0,2}\//.test(body)) { problems.push({ line: start, text: `requirement ${body.trim().slice(0, 60)} is a URL, VCS or path reference, which cannot be read literally` }); continue; }
77
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(.*)$/.exec(body.trim());
78
+ if (!m) { problems.push({ line: start, text: `requirement ${body.trim().slice(0, 60)} cannot be read literally` }); continue; }
79
+ const clauses = parseSpecifier(m[2]);
80
+ if (clauses === null) { problems.push({ line: start, text: `specifier ${m[2].slice(0, 60)} of ${m[1]} cannot be read literally` }); continue; }
81
+ const pinned = clauses.length === 1 && ["==", "==="].includes(clauses[0].op) && !clauses[0].wildcard;
82
+ reqs.push({ line: start, name: normalizeName(m[1]), display: m[1], spec: m[2].trim(), clauses, marker, pinned });
83
+ }
84
+ return { reqs, problems };
85
+ }
86
+
87
+ // ---- locks ----------------------------------------------------------------------------------------------------------------
88
+ /** Map name -> [versions] from a uv.lock / poetry.lock parsed as TOML (`[[package]]` name + version). */
89
+ export function lockVersions(doc) {
90
+ const map = new Map();
91
+ for (const p of Array.isArray(doc?.package) ? doc.package : []) {
92
+ if (typeof p?.name !== "string" || typeof p?.version !== "string") continue;
93
+ const k = normalizeName(p.name);
94
+ map.set(k, [...(map.get(k) ?? []), p.version]);
95
+ }
96
+ return map;
97
+ }
98
+
99
+ /** PEP 508 strings or poetry specs from a parsed pyproject.toml -> [{ name, spec, marker, where }], plus problems. */
100
+ export function pyprojectRequirements(doc) {
101
+ const out = []; const problems = [];
102
+ const pep = (list, where) => { for (const s of Array.isArray(list) ? list : []) { if (typeof s !== "string") continue; const r = parseRequirements(s); r.reqs.forEach((q) => out.push({ name: q.name, display: q.display, clauses: q.clauses, spec: q.spec, marker: q.marker, where })); r.problems.forEach((p) => problems.push(`${where}: ${p.text}`)); } };
103
+ pep(doc?.project?.dependencies, "[project].dependencies");
104
+ for (const [k, v] of Object.entries(doc?.project?.["optional-dependencies"] ?? {})) pep(v, `[project.optional-dependencies].${k}`);
105
+ for (const [k, v] of Object.entries(doc?.["dependency-groups"] ?? {})) pep(Array.isArray(v) ? v.filter((x) => typeof x === "string") : [], `[dependency-groups].${k}`);
106
+ pep(doc?.tool?.uv?.["dev-dependencies"], "[tool.uv].dev-dependencies");
107
+ const poetry = (table, where) => {
108
+ for (const [name, raw] of Object.entries(table ?? {})) {
109
+ if (name.toLowerCase() === "python") continue;
110
+ const spec = typeof raw === "string" ? raw : typeof raw?.version === "string" ? raw.version : null;
111
+ if (spec === null) { if (!(raw && typeof raw === "object" && (raw.git || raw.path || raw.url))) problems.push(`${where}: ${name} has no literal version`); continue; }
112
+ const clauses = poetrySpec(spec);
113
+ if (clauses === null) problems.push(`${where}: ${name} specifier ${spec} cannot be read literally`);
114
+ else out.push({ name: normalizeName(name), display: name, clauses, spec, marker: raw?.markers ?? (raw?.python ? "python" : null), where });
115
+ }
116
+ };
117
+ poetry(doc?.tool?.poetry?.dependencies, "[tool.poetry.dependencies]");
118
+ poetry(doc?.tool?.poetry?.["dev-dependencies"], "[tool.poetry.dev-dependencies]");
119
+ for (const [g, v] of Object.entries(doc?.tool?.poetry?.group ?? {})) poetry(v?.dependencies, `[tool.poetry.group.${g}.dependencies]`);
120
+ return { reqs: out, problems };
121
+ }
122
+ /** Poetry version constraint -> clauses (caret and tilde expanded, bare version exact, `*` any); null when not readable. */
123
+ export function poetrySpec(spec) {
124
+ const t = String(spec).trim();
125
+ if (t === "*" || t === "") return [];
126
+ if (t.includes("||")) return null;
127
+ const clauses = [];
128
+ for (const part of t.split(",").map((x) => x.trim())) {
129
+ let m;
130
+ if ((m = /^\^\s*(\d+(?:\.\d+)*)$/.exec(part))) {
131
+ const r = m[1].split(".").map(Number); const at = r.findIndex((n) => n !== 0); const i = at < 0 ? r.length - 1 : at;
132
+ const upper = r.slice(0, i); upper.push(r[i] + 1);
133
+ clauses.push({ op: ">=", version: parsePyVersion(m[1]) }, { op: "<", version: parsePyVersion(upper.join(".")) });
134
+ } else if ((m = /^~=?\s*(\d+(?:\.\d+)*)$/.exec(part))) {
135
+ const r = m[1].split(".").map(Number); const upper = r.length === 1 ? [r[0] + 1] : [r[0], r[1] + 1];
136
+ clauses.push({ op: ">=", version: parsePyVersion(m[1]) }, { op: "<", version: parsePyVersion(upper.join(".")) });
137
+ } else if ((m = /^(\d+(?:\.\d+)*)$/.exec(part))) clauses.push({ op: "==", version: parsePyVersion(m[1]) });
138
+ else { const c = parseSpecifier(part); if (c === null || c.length !== 1) return null; clauses.push(c[0]); }
139
+ }
140
+ return clauses.every((c) => c.version) ? clauses : null;
141
+ }
142
+
143
+ // ---- the check ------------------------------------------------------------------------------------------------------------
144
+ const describe = (r) => `${r.display ?? r.name}${r.spec ? ` ${r.spec}` : ""}`;
145
+ function against(reqs, versionsOf, { file, source, kind }) {
146
+ const out = [];
147
+ for (const r of reqs) {
148
+ const have = versionsOf.get(r.name);
149
+ const at = `${file}${r.line ? `:${r.line}` : ""}${r.where ? ` ${r.where}` : ""}`;
150
+ if (!have || !have.length) { if (!r.marker) out.push(`${at}: ${describe(r)} has no ${kind} in ${source}`); continue; }
151
+ const parsed = have.map(parsePyVersion);
152
+ if (parsed.some((p) => p === null)) { out.push(`${source}: ${r.display ?? r.name} version ${have.join(", ")} is not a readable version`); continue; }
153
+ if (!parsed.some((p) => satisfies(p, r.clauses))) out.push(`${at}: ${describe(r)} is not satisfied by ${source}, which has ${have.join(", ")}`);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Findings for one directory. `names` = the directory's file names; `read(file)` takes repo-relative paths; `parseToml(text)` parses TOML.
160
+ */
161
+ export function pythonLockProblems({ dir, names, read, parseToml }) {
162
+ const out = []; const has = (n) => names.includes(n);
163
+ const f = (n) => join(dir, n);
164
+ const isPins = (n) => /pins?\.txt$/.test(n);
165
+ const reqFiles = names.filter((n) => /^requirements[^/]*\.txt$/.test(n) && !isPins(n));
166
+ const pinsFiles = names.filter(isPins);
167
+ const toml = (file) => { try { return parseToml(read(file)); } catch (e) { out.push(`${file}: cannot be parsed as TOML (${String(e.message).split("\n")[0]})`); return null; } };
168
+ const parsedTxt = new Map();
169
+ const reqsOf = (n) => { if (!parsedTxt.has(n)) { const r = parseRequirements(read(f(n))); parsedTxt.set(n, r); } return parsedTxt.get(n); };
170
+ const needPins = (n, r) => { for (const q of r.reqs) if (!q.pinned) out.push(`${f(n)}:${q.line}: ${describe(q)} is not an exact == pin in a lock file`); };
171
+ const pinMap = (r) => { const m = new Map(); for (const q of r.reqs) if (q.pinned) m.set(q.name, [...(m.get(q.name) ?? []), q.clauses[0].version.text]); return m; };
172
+ const problems = (n, r) => r.problems.forEach((p) => out.push(`${f(n)}:${p.line}: ${p.text}`));
173
+
174
+ // pins files
175
+ for (const n of pinsFiles) {
176
+ const pins = reqsOf(n); problems(n, pins); needPins(n, pins);
177
+ const stem = n.replace(/[._-]?pins?\.txt$/, "");
178
+ for (const src of [`${stem}.in`, `${stem}.txt`]) {
179
+ if (src === n || !has(src) || isPins(src)) continue;
180
+ const direct = reqsOf(src); problems(src, direct);
181
+ out.push(...against(direct.reqs, pinMap(pins), { file: f(src), source: f(n), kind: "pin" }));
182
+ break;
183
+ }
184
+ }
185
+ // requirements*.txt
186
+ const locks = [["uv.lock"], ["poetry.lock"]].map(([n]) => n).filter(has);
187
+ const lockMaps = locks.map((n) => { const doc = toml(f(n)); return doc ? { n, map: lockVersions(doc) } : null; }).filter(Boolean);
188
+ for (const n of reqFiles) {
189
+ const stem = n.replace(/\.txt$/, ""); const r = reqsOf(n);
190
+ if (has(`${stem}.in`)) {
191
+ problems(n, r); needPins(n, r);
192
+ const direct = reqsOf(`${stem}.in`); problems(`${stem}.in`, direct);
193
+ out.push(...against(direct.reqs, pinMap(r), { file: f(`${stem}.in`), source: f(n), kind: "pin" }));
194
+ } else if (lockMaps.length) {
195
+ problems(n, r);
196
+ for (const l of lockMaps) out.push(...against(r.reqs, l.map, { file: f(n), source: f(l.n), kind: "locked version" }));
197
+ } else if (pinsFiles.some((p) => p.replace(/[._-]?pins?\.txt$/, "") === stem)) { /* its pins file is the lock (checked above) */ }
198
+ }
199
+ // pyproject.toml
200
+ if (has("pyproject.toml") && lockMaps.length) {
201
+ const doc = toml(f("pyproject.toml"));
202
+ if (doc) {
203
+ const p = pyprojectRequirements(doc);
204
+ out.push(...p.problems.map((t) => `${f("pyproject.toml")} ${t}`));
205
+ for (const l of lockMaps) out.push(...against(p.reqs, l.map, { file: f("pyproject.toml"), source: f(l.n), kind: "locked version" }));
206
+ }
207
+ }
208
+ return out;
209
+ }
@@ -9,6 +9,38 @@
9
9
  "set_by": "adrian",
10
10
  "since": "2026-10-27",
11
11
  "note": "Dell runner, Adrian's go 2026-10-28, sha256 3c11788e…2986"
12
+ },
13
+ {
14
+ "sha256": "569069fee0feb5f9d60e75ef8a5825fbff44edbffd5aa927c25d4032e9dca7a0",
15
+ "version": "0.10.1",
16
+ "platform": "win",
17
+ "set_by": "adrian",
18
+ "since": "2026-11-01",
19
+ "note": "Dell runner built by rightkit-gate --to tool from e2f2d2152 (world QA root fix) on BOGUS-DELL, rotated in; replaces 9cb78352 (rightkit#1309: pins/trust routine)"
20
+ },
21
+ {
22
+ "sha256": "974b646b50f4630ff829c96e3747f800d17cf5125667ead0fa43ee809de4781f",
23
+ "version": "0.10.0",
24
+ "platform": "win",
25
+ "set_by": "adrian",
26
+ "since": "2026-11-01",
27
+ "note": "Dell runner built and enrolled by rightkit-gate --to tool from 05eb0d689 on BOGUS-DELL (rightkit#1309: pins/trust routine)"
28
+ },
29
+ {
30
+ "sha256": "a1e84a19d1b75d49a98fbff34e4ac1001014ff0aafcd129ce23721eb3297e4b3",
31
+ "version": "0.10.0",
32
+ "platform": "mac",
33
+ "set_by": "adrian",
34
+ "since": "2026-10-31",
35
+ "note": "Mac runner built by rightkit-gate --to tool from 24c5a9281 (rightkit#1309: pins/trust routine)"
36
+ },
37
+ {
38
+ "sha256": "14eba841b7af8f8b015d25be6dbf68ad3c96863a4c9c43adc104b1ab632f9f60",
39
+ "version": "0.10.1",
40
+ "platform": "mac",
41
+ "set_by": "adrian",
42
+ "since": "2026-11-01",
43
+ "note": "Mac runner built by rightkit-gate --to tool from e8da5a7b7 (world token + temp-length fixes), rotated in; replaces e7590e12 (rightkit#1309: pins/trust routine)"
12
44
  }
13
45
  ]
14
46
  }