@nimbus-dev/connectors 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * manifest-diff: the plan's diff, computed here from two `kubectl ... -o json` replies instead of
3
+ * by `kubectl diff` (which needs a `diff` program: Windows has none, and one named by the
4
+ * environment is not one to trust with the text an owner approves). Pure: no process, no file.
5
+ *
6
+ * - {@link unpackKubeJson} reads a kubectl reply (nothing, a bare object, or a List) into a map
7
+ * keyed `<resource prefix>/<metadata.name>`, failing closed on anything it cannot key.
8
+ * - {@link normaliseForDiff} drops the fields the API server changes on its own and renders one
9
+ * object as indented JSON, keys sorted, one value per line.
10
+ * - {@link planDiff} diffs the planned (server-side dry run) side against the live side, per
11
+ * object in code-unit order, with `shared/line-diff.ts`.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { LineDiffTooLargeError, unifiedLineDiff } from "../../../shared/line-diff.ts";
16
+
17
+ /** The `kubectl ... -o name` resource prefix of each allowlisted kind. */
18
+ export const RESOURCE_PREFIX: Readonly<Record<string, string>> = {
19
+ ConfigMap: "configmap",
20
+ Secret: "secret",
21
+ Service: "service",
22
+ Pod: "pod",
23
+ ServiceAccount: "serviceaccount",
24
+ PersistentVolumeClaim: "persistentvolumeclaim",
25
+ Deployment: "deployment.apps",
26
+ StatefulSet: "statefulset.apps",
27
+ DaemonSet: "daemonset.apps",
28
+ ReplicaSet: "replicaset.apps",
29
+ Job: "job.batch",
30
+ CronJob: "cronjob.batch",
31
+ Ingress: "ingress.networking.k8s.io",
32
+ NetworkPolicy: "networkpolicy.networking.k8s.io",
33
+ Role: "role.rbac.authorization.k8s.io",
34
+ RoleBinding: "rolebinding.rbac.authorization.k8s.io",
35
+ HorizontalPodAutoscaler: "horizontalpodautoscaler.autoscaling",
36
+ PodDisruptionBudget: "poddisruptionbudget.policy",
37
+ };
38
+
39
+ /** What a live object is rendered as when it does not exist yet. */
40
+ export const ABSENT_LIVE = "(absent: will be created)";
41
+
42
+ /** Domain separator of {@link diffSha256}. */
43
+ export const DIFF_HASH_DOMAIN = "nimbus-k8s-diff-v1";
44
+
45
+ /** sha256 (hex) of the domain, a newline and the diff text, as UTF-8. */
46
+ export function diffSha256(diff: string): string {
47
+ return createHash("sha256").update(`${DIFF_HASH_DOMAIN}\n${diff}`, "utf8").digest("hex");
48
+ }
49
+
50
+ type Obj = Record<string, unknown>;
51
+
52
+ function isObj(v: unknown): v is Obj {
53
+ return typeof v === "object" && v !== null && !Array.isArray(v);
54
+ }
55
+
56
+ /** Order by UTF-16 code unit, the same on every machine (not `localeCompare`: collation can tie). */
57
+ export function compareCodeUnits(a: string, b: string): number {
58
+ if (a < b) return -1;
59
+ return a > b ? 1 : 0;
60
+ }
61
+
62
+ /**
63
+ * The objects in a kubectl `-o json` reply, keyed `<resource prefix>/<metadata.name>`, in the
64
+ * order kubectl gave them. Nothing printed (`null` or an empty string) is no objects; a
65
+ * `{kind: "List", items}` is its items; any other object is itself. An item without a string kind
66
+ * and a string name, of a kind outside {@link RESOURCE_PREFIX}, or a second one with a key already
67
+ * seen, throws: a reply that cannot be keyed is never half-trusted.
68
+ */
69
+ export function unpackKubeJson(data: unknown): Map<string, Obj> {
70
+ const out = new Map<string, Obj>();
71
+ if (data === null || data === undefined || data === "") return out;
72
+ if (!isObj(data)) throw new Error("kubectl returned JSON that is not an object");
73
+ let items: unknown[];
74
+ if (data["kind"] === "List") {
75
+ if (!Array.isArray(data["items"])) throw new Error("kubectl returned a List without items");
76
+ items = data["items"] as unknown[];
77
+ } else {
78
+ items = [data];
79
+ }
80
+ for (const item of items) {
81
+ if (!isObj(item)) throw new Error("kubectl returned an item that is not an object");
82
+ const kind = item["kind"];
83
+ const meta = item["metadata"];
84
+ const name = isObj(meta) ? meta["name"] : undefined;
85
+ if (typeof kind !== "string" || kind === "" || typeof name !== "string" || name === "") {
86
+ throw new Error("kubectl returned an object without a kind and a name");
87
+ }
88
+ const prefix = Object.hasOwn(RESOURCE_PREFIX, kind) ? RESOURCE_PREFIX[kind] : undefined;
89
+ if (prefix === undefined) {
90
+ throw new Error(`kubectl returned a ${kind}, which is not on the allowlist`);
91
+ }
92
+ const key = `${prefix}/${name}`;
93
+ if (out.has(key)) throw new Error(`kubectl returned ${key} twice`);
94
+ out.set(key, item);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ const DROPPED_METADATA = [
100
+ "managedFields",
101
+ "resourceVersion",
102
+ "uid",
103
+ "generation",
104
+ "creationTimestamp",
105
+ "selfLink",
106
+ ] as const;
107
+ const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration";
108
+
109
+ /**
110
+ * The name the ServiceAccount admission plugin gives the token volume it injects into a Pod
111
+ * (`kube-api-access-` and five random characters). A new Pod's dry run gets a fresh one each time,
112
+ * so it would change the diff hash between the plan and the apply's re-plan.
113
+ */
114
+ const GENERATED_TOKEN_VOLUME = /^kube-api-access-[a-z0-9]{5}$/;
115
+
116
+ /** True for a volume the ServiceAccount admission plugin generated: the name and a projected token. */
117
+ function isGeneratedTokenVolume(v: unknown): boolean {
118
+ if (!isObj(v) || typeof v["name"] !== "string" || !GENERATED_TOKEN_VOLUME.test(v["name"])) {
119
+ return false;
120
+ }
121
+ const projected = v["projected"];
122
+ const sources = isObj(projected) ? projected["sources"] : undefined;
123
+ return Array.isArray(sources) && sources.some((s) => isObj(s) && isObj(s["serviceAccountToken"]));
124
+ }
125
+
126
+ /** What a Job label value derived from the Job's own (generated) uid is rendered as. */
127
+ export const GENERATED_JOB_UID = "(generated by the cluster)";
128
+
129
+ /**
130
+ * In a Job, every value of `spec.selector.matchLabels` and `spec.template.metadata.labels` equal
131
+ * to the Job's own `metadata.uid` becomes {@link GENERATED_JOB_UID}. The API server writes the
132
+ * uid into the automatic selector and its template labels (`batch.kubernetes.io/controller-uid`,
133
+ * `controller-uid`), and a new Job's dry run gets a fresh uid each time, which would change the
134
+ * diff hash between the plan and the apply's re-plan. A value the user wrote cannot equal a uid
135
+ * the server had not generated yet, so it is kept.
136
+ */
137
+ function maskGeneratedJobUid(copy: Obj): void {
138
+ const meta = copy["metadata"];
139
+ const uid = isObj(meta) ? meta["uid"] : undefined;
140
+ const spec = copy["spec"];
141
+ if (typeof uid !== "string" || uid === "" || !isObj(spec)) return;
142
+ const selector = spec["selector"];
143
+ const template = spec["template"];
144
+ const templateMeta = isObj(template) ? template["metadata"] : undefined;
145
+ const maps = [
146
+ isObj(selector) ? selector["matchLabels"] : undefined,
147
+ isObj(templateMeta) ? templateMeta["labels"] : undefined,
148
+ ];
149
+ for (const labels of maps) {
150
+ if (!isObj(labels)) continue;
151
+ for (const key of Object.keys(labels)) {
152
+ if (labels[key] === uid) labels[key] = GENERATED_JOB_UID;
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Drops, from a Pod's spec, the generated token volume and every container's mount of it. */
158
+ function dropGeneratedTokenVolume(spec: Obj): void {
159
+ const volumes = spec["volumes"];
160
+ if (!Array.isArray(volumes)) return;
161
+ const generated = new Set(
162
+ volumes.filter(isGeneratedTokenVolume).map((v) => (v as Obj)["name"] as string),
163
+ );
164
+ if (generated.size === 0) return;
165
+ const kept = volumes.filter((v) => !isGeneratedTokenVolume(v));
166
+ if (kept.length === 0) delete spec["volumes"];
167
+ else spec["volumes"] = kept;
168
+ for (const containers of [
169
+ spec["containers"],
170
+ spec["initContainers"],
171
+ spec["ephemeralContainers"],
172
+ ]) {
173
+ if (!Array.isArray(containers)) continue;
174
+ for (const c of containers) {
175
+ if (!isObj(c) || !Array.isArray(c["volumeMounts"])) continue;
176
+ const mounts = (c["volumeMounts"] as unknown[]).filter(
177
+ (m) => !(isObj(m) && typeof m["name"] === "string" && generated.has(m["name"])),
178
+ );
179
+ if (mounts.length === 0) delete c["volumeMounts"];
180
+ else c["volumeMounts"] = mounts;
181
+ }
182
+ }
183
+ }
184
+
185
+ /** `v` with every object's keys in code-unit order (arrays keep their order). */
186
+ function sortKeys(v: unknown): unknown {
187
+ if (Array.isArray(v)) return v.map(sortKeys);
188
+ if (!isObj(v)) return v;
189
+ return Object.fromEntries(
190
+ Object.keys(v)
191
+ .sort(compareCodeUnits)
192
+ .map((k) => [k, sortKeys(v[k])]),
193
+ );
194
+ }
195
+
196
+ /**
197
+ * One object as the lines that are diffed. Dropped, and nothing else: `metadata.managedFields`,
198
+ * `resourceVersion`, `uid`, `generation`, `creationTimestamp`, `selfLink`, the top-level `status`,
199
+ * and the annotation `kubectl.kubernetes.io/last-applied-configuration` (an `annotations` map that
200
+ * held only that one is then empty and is dropped with it), and, in a Pod, the token volume the
201
+ * ServiceAccount admission plugin generated (a `kube-api-access-xxxxx` projected
202
+ * `serviceAccountToken` volume) with every container's mount of it; in a Job, a selector or
203
+ * template label value equal to the Job's own uid is masked as {@link GENERATED_JOB_UID}. Keys sorted by code unit, rendered as
204
+ * 2-space-indented JSON, so a scalar is a line of its own. The input is not changed.
205
+ */
206
+ export function normaliseForDiff(obj: Obj): string[] {
207
+ const copy = structuredClone(obj);
208
+ delete copy["status"];
209
+ if (copy["kind"] === "Pod" && isObj(copy["spec"])) dropGeneratedTokenVolume(copy["spec"]);
210
+ if (copy["kind"] === "Job") maskGeneratedJobUid(copy);
211
+ const meta = copy["metadata"];
212
+ if (isObj(meta)) {
213
+ for (const key of DROPPED_METADATA) delete meta[key];
214
+ const ann = meta["annotations"];
215
+ if (isObj(ann) && Object.hasOwn(ann, LAST_APPLIED)) {
216
+ delete ann[LAST_APPLIED];
217
+ if (Object.keys(ann).length === 0) delete meta["annotations"];
218
+ }
219
+ }
220
+ return JSON.stringify(sortKeys(copy), null, 2).split("\n");
221
+ }
222
+
223
+ /**
224
+ * The plan's diff: per object, in code-unit order of `<prefix>/<name>`, a unified diff of the live
225
+ * object (or {@link ABSENT_LIVE}) against the planned one, headers `--- live <key>` and
226
+ * `+++ planned <key>`; an object that does not differ adds nothing. "" means no differences.
227
+ * Throws {@link LineDiffTooLargeError} when one object's edit script is past the diff bound.
228
+ */
229
+ export function planDiff(
230
+ planned: ReadonlyMap<string, Obj>,
231
+ live: ReadonlyMap<string, Obj>,
232
+ ): string {
233
+ let out = "";
234
+ for (const key of [...planned.keys()].sort(compareCodeUnits)) {
235
+ const plan = planned.get(key);
236
+ if (plan === undefined) continue;
237
+ const was = live.get(key);
238
+ out += unifiedLineDiff(
239
+ was === undefined ? [ABSENT_LIVE] : normaliseForDiff(was),
240
+ normaliseForDiff(plan),
241
+ {
242
+ fromLabel: `live ${key}`,
243
+ toLabel: `planned ${key}`,
244
+ },
245
+ );
246
+ }
247
+ return out;
248
+ }
249
+
250
+ export { LineDiffTooLargeError };
@@ -17,28 +17,66 @@ import {
17
17
  import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
18
18
  import { withScratchDir } from "../../../shared/private-scratch.ts";
19
19
  import { runCliCode, runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
20
+ import {
21
+ compareCodeUnits,
22
+ diffSha256,
23
+ planDiff,
24
+ RESOURCE_PREFIX,
25
+ unpackKubeJson,
26
+ } from "./manifest-diff.ts";
20
27
  import { type ScannedObject, scanNamespacedManifest } from "./manifest-scan.ts";
21
28
 
22
- function kubectlBase(): string[] {
23
- const args = ["kubectl"];
24
- const ctx = process.env["KUBE_CONTEXT"]?.trim();
25
- if (ctx !== undefined && ctx !== "") {
26
- args.push("--context", ctx);
29
+ /** `kubectl --context <ctx>`: every call names its context, none relies on the kubeconfig's current one. */
30
+ function kubectlBase(context: string): string[] {
31
+ return ["kubectl", "--context", context];
32
+ }
33
+
34
+ const CONTEXT_MISMATCH = "context does not match the configured context";
35
+
36
+ /**
37
+ * The context every call of this process works against: `KUBE_CONTEXT` if the owner set it, else
38
+ * what `kubectl config current-context` prints for the call's private kubeconfig (never a
39
+ * hand-parse of the file). Fails closed: a kubectl error, no output, or a value that is not a plain
40
+ * argument is an error, so no call ever falls back to some other context.
41
+ */
42
+ async function effectiveContext(kube: { readonly KUBECONFIG: string }): Promise<string> {
43
+ let ctx = process.env["KUBE_CONTEXT"]?.trim();
44
+ if (ctx === undefined || ctx === "") {
45
+ const r = await runCliCode(["kubectl", "config", "current-context"], kube);
46
+ if (r.code !== 0) {
47
+ throw new Error(`could not determine the kubectl context: kubectl exited ${String(r.code)}`);
48
+ }
49
+ ctx = r.stdout.trim();
50
+ if (ctx === "") {
51
+ throw new Error("could not determine the kubectl context: kubectl printed none");
52
+ }
27
53
  }
28
- return args;
54
+ if (!cliArg.safeParse(ctx).success) {
55
+ throw new Error("the configured kubectl context is not a plain value");
56
+ }
57
+ return ctx;
29
58
  }
30
59
 
31
60
  /**
32
- * Run `body` against a private copy of the kubeconfig `KUBECONFIG` names (shared/private-kubeconfig.ts):
33
- * one tool call, one copy, so a call can neither change the owner's file nor race another call.
61
+ * Run `body` on a private kubeconfig copy (shared/private-kubeconfig.ts: one tool call, one copy, so
62
+ * a call can neither change the owner's file nor race another) with the effective context. A
63
+ * `given` context that is not that context throws before `body` runs.
34
64
  */
35
- function withKube<T>(body: (env: { readonly KUBECONFIG: string }) => Promise<T>): Promise<T> {
36
- return withPrivateKubeconfig({ source: process.env["KUBECONFIG"] ?? "" }, body);
65
+ function withKube<T>(
66
+ given: string | undefined,
67
+ body: (kube: { readonly KUBECONFIG: string }, context: string) => Promise<T>,
68
+ ): Promise<T> {
69
+ return withPrivateKubeconfig({ source: process.env["KUBECONFIG"] ?? "" }, async (kube) => {
70
+ const context = await effectiveContext(kube);
71
+ if (given !== undefined && given !== context) throw new Error(CONTEXT_MISMATCH);
72
+ return body(kube, context);
73
+ });
37
74
  }
38
75
 
39
- function kubectlJson(rest: string[]): Promise<unknown> {
40
- const cmd = [...kubectlBase(), ...rest, "-o", "json"];
41
- return withKube((env) => runCliJsonThrowing(cmd, env));
76
+ function kubectlJson(rest: string[], given: string | undefined): Promise<unknown> {
77
+ return withKube(given, (env, context) =>
78
+ runCliJsonThrowing([...kubectlBase(context), ...rest, "-o", "json"], env),
79
+ );
42
80
  }
43
81
 
44
82
  /** A manifest may be at most this many UTF-8 bytes (the schema's character cap is a floor of it). */
@@ -47,9 +85,10 @@ const MANIFEST_MAX_BYTES = 262_144;
47
85
  const DIFF_MAX_BYTES = 65_536;
48
86
  /** The whole plan reply, which the gateway refuses above this size. */
49
87
  const PLAN_REPLY_MAX_BYTES = 262_144;
50
- /** Domain separator of the plan hash: sha256 of this, a newline, the namespace, a newline, the manifest. */
51
- const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-v1";
88
+ /** Domain separator of the plan hash (see {@link planHash}). */
89
+ const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-v2";
52
90
  const MISMATCH = "manifest does not match the approved hash";
91
+ const CLUSTER_CHANGED = "the cluster changed since the plan was approved";
53
92
 
54
93
  interface ManifestInput {
55
94
  readonly namespace: string;
@@ -58,37 +97,39 @@ interface ManifestInput {
58
97
 
59
98
  /**
60
99
  * The checks every manifest tool makes before it touches a file or a process, in this order: size
61
- * in UTF-8 bytes, the namespaced-subset scan (manifest-scan.ts), and the hash of the exact bytes
62
- * kubectl will be handed. Throws a fixed message.
100
+ * in UTF-8 bytes, then the namespaced-subset scan (manifest-scan.ts). Throws a fixed message.
63
101
  */
64
- function checkedManifest(p: ManifestInput): {
65
- bytes: Buffer;
66
- sha256: string;
67
- scanned: ScannedObject[];
68
- } {
102
+ function checkedManifest(p: ManifestInput): { bytes: Buffer; scanned: ScannedObject[] } {
69
103
  const bytes = Buffer.from(p.manifest, "utf8");
70
104
  if (bytes.length > MANIFEST_MAX_BYTES) {
71
105
  throw new Error(`manifest is larger than ${String(MANIFEST_MAX_BYTES)} bytes`);
72
106
  }
73
- const scanned = scanNamespacedManifest(p.manifest, p.namespace);
74
- // The hash binds the namespace to the manifest, so an approved plan for one namespace cannot be
75
- // replayed against another. The kube CONTEXT is owner environment (KUBE_CONTEXT), not caller
76
- // input, and is deliberately not part of it.
77
- const hashed = [PLAN_HASH_DOMAIN, p.namespace, p.manifest].join("\n");
78
- const sha256 = createHash("sha256").update(hashed, "utf8").digest("hex");
79
- return { bytes, sha256, scanned };
107
+ return { bytes, scanned: scanNamespacedManifest(p.manifest, p.namespace) };
108
+ }
109
+
110
+ /**
111
+ * The plan hash (v2): sha256 of the domain, the EFFECTIVE context, the namespace and the exact
112
+ * manifest text, newline-separated. The context and namespace are in it, so an approved plan
113
+ * cannot be replayed on another cluster or in another namespace. v1 (no context) is retired.
114
+ */
115
+ export function planHash(context: string, namespace: string, manifest: string): string {
116
+ return createHash("sha256")
117
+ .update([PLAN_HASH_DOMAIN, context, namespace, manifest].join("\n"), "utf8")
118
+ .digest("hex");
119
+ }
120
+
121
+ /** Constant-time equality of two lowercase-hex digests (equal length is checked first). */
122
+ function sameHex(a: string, b: string): boolean {
123
+ const want = Buffer.from(a, "hex");
124
+ const have = Buffer.from(b, "hex");
125
+ return want.length === have.length && timingSafeEqual(want, have);
80
126
  }
81
127
 
82
- /** {@link checkedManifest}, then the apply's own check: the manifest must hash to what was approved. */
83
- function approvedManifest(p: ManifestInput & { readonly planSha256: string }) {
84
- const checked = checkedManifest(p);
85
- const want = Buffer.from(p.planSha256, "hex");
86
- const have = Buffer.from(checked.sha256, "hex");
87
- // Equal length is checked first: timingSafeEqual throws on unequal buffers. The schema pins 64 hex.
88
- if (want.length !== have.length || !timingSafeEqual(want, have)) {
128
+ /** The apply's own check: the manifest must hash, under this context, to what was approved. */
129
+ function assertApprovedHash(context: string, p: ManifestInput & { readonly planSha256: string }) {
130
+ if (!sameHex(p.planSha256, planHash(context, p.namespace, p.manifest))) {
89
131
  throw new Error(MISMATCH);
90
132
  }
91
- return checked;
92
133
  }
93
134
 
94
135
  function refusalOf(check: () => unknown): string | undefined {
@@ -119,37 +160,6 @@ function kubectlFailure(
119
160
  /** What a plan's `diff` holds in place of kubectl's when the manifest holds a Secret. */
120
161
  export const SECRET_DIFF_WITHHELD = "(diff withheld: the manifest holds a Secret)";
121
162
 
122
- /** The `kubectl ... -o name` resource prefix of each allowlisted kind. */
123
- const RESOURCE_PREFIX: Readonly<Record<string, string>> = {
124
- ConfigMap: "configmap",
125
- Secret: "secret",
126
- Service: "service",
127
- Pod: "pod",
128
- ServiceAccount: "serviceaccount",
129
- PersistentVolumeClaim: "persistentvolumeclaim",
130
- Deployment: "deployment.apps",
131
- StatefulSet: "statefulset.apps",
132
- DaemonSet: "daemonset.apps",
133
- ReplicaSet: "replicaset.apps",
134
- Job: "job.batch",
135
- CronJob: "cronjob.batch",
136
- Ingress: "ingress.networking.k8s.io",
137
- NetworkPolicy: "networkpolicy.networking.k8s.io",
138
- Role: "role.rbac.authorization.k8s.io",
139
- RoleBinding: "rolebinding.rbac.authorization.k8s.io",
140
- HorizontalPodAutoscaler: "horizontalpodautoscaler.autoscaling",
141
- PodDisruptionBudget: "poddisruptionbudget.policy",
142
- };
143
-
144
- /**
145
- * Order by UTF-16 code unit, the same on every machine. Not `localeCompare`: collation can tie two
146
- * distinct strings, and these lists are compared element by element.
147
- */
148
- function compareCodeUnits(a: string, b: string): number {
149
- if (a < b) return -1;
150
- return a > b ? 1 : 0;
151
- }
152
-
153
163
  /** The sorted `<prefix>/<name>` list the scan expects kubectl to report. */
154
164
  function expectedNames(scanned: readonly ScannedObject[]): string[] {
155
165
  return scanned
@@ -184,16 +194,42 @@ async function writeManifestFile(dir: string, bytes: Buffer): Promise<string> {
184
194
  return file;
185
195
  }
186
196
 
187
- /** `kubectl apply --server-side --dry-run=server -o name`, cross-checked against the scan. */
188
- async function serverDryRun(
197
+ /** Parse a kubectl `-o json` reply; an unparsable one is an error that never echoes the text (it may hold a Secret). */
198
+ function parseKubeReply(stdout: string, what: string): unknown {
199
+ const trimmed = stdout.trim();
200
+ if (trimmed === "") return null;
201
+ try {
202
+ return JSON.parse(trimmed) as unknown;
203
+ } catch {
204
+ throw new Error(`kubectl ${what} returned invalid JSON`);
205
+ }
206
+ }
207
+
208
+ /** What the plan computes, and what the apply recomputes to compare. */
209
+ interface Computed {
210
+ readonly objects: string[];
211
+ /** The diff as the reply shows it (withheld when the manifest holds a Secret). */
212
+ readonly diff: string;
213
+ readonly hasDifferences: boolean;
214
+ readonly diffSha256: string;
215
+ }
216
+
217
+ /**
218
+ * The plan: a server-side dry run (`-o json`, the objects as the API server would admit them,
219
+ * cross-checked against the scan), one `kubectl get` of the same objects as they are now
220
+ * (`--ignore-not-found`), and the built-in diff of the two (manifest-diff.ts). Changes nothing.
221
+ */
222
+ async function computePlan(
189
223
  kube: { readonly KUBECONFIG: string },
224
+ context: string,
190
225
  ns: string,
191
226
  file: string,
192
227
  scanned: readonly ScannedObject[],
193
- ): Promise<string[]> {
228
+ ): Promise<Computed> {
229
+ const base = kubectlBase(context);
194
230
  const dry = await runCliCode(
195
231
  [
196
- ...kubectlBase(),
232
+ ...base,
197
233
  "apply",
198
234
  "--server-side",
199
235
  "--dry-run=server",
@@ -203,14 +239,43 @@ async function serverDryRun(
203
239
  "-f",
204
240
  file,
205
241
  "-o",
206
- "name",
242
+ "json",
207
243
  ],
208
244
  kube,
209
245
  );
210
246
  if (dry.code !== 0) throw kubectlFailure("kubectl", dry.code, dry.stderr, scanned);
211
- const objects = objectLines(dry.stdout);
247
+ const planned = unpackKubeJson(parseKubeReply(dry.stdout, "dry run"));
248
+ const objects = [...planned.keys()];
212
249
  assertSameObjects(objects, scanned, "dry run");
213
- return objects;
250
+
251
+ const names = [...new Set(expectedNames(scanned))];
252
+ const get = await runCliCode(
253
+ [...base, "get", ...names, "-n", ns, "-o", "json", "--ignore-not-found"],
254
+ kube,
255
+ );
256
+ if (get.code !== 0) throw kubectlFailure("kubectl get", get.code, get.stderr, scanned);
257
+ const live = unpackKubeJson(parseKubeReply(get.stdout, "get"));
258
+
259
+ const full = planDiff(planned, live);
260
+ if (Buffer.byteLength(full, "utf8") > DIFF_MAX_BYTES) {
261
+ throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
262
+ }
263
+ // A Secret's plaintext can sit in either side of the diff, so with a Secret in the manifest its
264
+ // part of the diff is withheld: the reply's diff is a fixed marker line, then the diff of every
265
+ // object that is NOT a Secret. The hash covers that text, so the apply's re-diff still sees a
266
+ // change to any other object (stated bound: it cannot see a change to a Secret).
267
+ const holdsSecret = scanned.some((o) => o.kind === "Secret");
268
+ const diff = holdsSecret
269
+ ? `${SECRET_DIFF_WITHHELD}
270
+ ${planDiff(withoutSecrets(planned), withoutSecrets(live))}`
271
+ : full;
272
+ return { objects, diff, hasDifferences: full !== "", diffSha256: diffSha256(diff) };
273
+ }
274
+
275
+ /** `objects` without its Secrets (keyed `<RESOURCE_PREFIX.Secret>/<name>`). */
276
+ function withoutSecrets<V>(objects: ReadonlyMap<string, V>): Map<string, V> {
277
+ const secret = `${RESOURCE_PREFIX["Secret"] ?? "secret"}/`;
278
+ return new Map([...objects].filter(([key]) => !key.startsWith(secret)));
214
279
  }
215
280
 
216
281
  function objectLines(stdout: string): string[] {
@@ -220,11 +285,14 @@ function objectLines(stdout: string): string[] {
220
285
  .filter((l) => l !== "");
221
286
  }
222
287
 
223
- const optionalNamespaceSchema = z.object({ namespace: cliArg.optional() });
288
+ /** Reads: `context` is optional; when given it must be the effective one. */
289
+ const readSchema = z.object({ context: cliArg.optional(), namespace: cliArg.optional() });
224
290
 
225
- async function k8sListNamespacedResource(p: { namespace?: string | undefined }, resource: string) {
226
- const ns = p.namespace ?? "default";
227
- const data = await kubectlJson(["get", resource, "-n", ns]);
291
+ async function k8sListNamespacedResource(
292
+ p: { context?: string | undefined; namespace?: string | undefined },
293
+ resource: string,
294
+ ) {
295
+ const data = await kubectlJson(["get", resource, "-n", p.namespace ?? "default"], p.context);
228
296
  return jsonResult(data ?? {});
229
297
  }
230
298
 
@@ -269,21 +337,22 @@ export function registerKubernetesTools(
269
337
 
270
338
  reg(
271
339
  "k8s_pod_list",
272
- "List pods in a namespace (default namespace: default).",
273
- optionalNamespaceSchema,
340
+ "List pods in a namespace (default namespace: default). `context` is optional; when given it must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one).",
341
+ readSchema,
274
342
  (p) => k8sListNamespacedResource(p, "pods"),
275
343
  );
276
344
 
277
345
  reg(
278
346
  "k8s_deployment_get",
279
- "Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale.",
280
- z.object({ namespace: cliArg.optional(), name: cliArg }),
347
+ "Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale. `context` is optional; when given it must be the configured context.",
348
+ z.object({ context: cliArg.optional(), namespace: cliArg.optional(), name: cliArg }),
281
349
  async (p) => {
282
350
  let data: unknown;
283
351
  try {
284
- // kubectlJson builds on kubectlBase(), so the configured KUBE_CONTEXT applies as it does
285
- // to every other call here.
286
- data = await kubectlJson(["get", "deployment", p.name, "-n", p.namespace ?? "default"]);
352
+ data = await kubectlJson(
353
+ ["get", "deployment", p.name, "-n", p.namespace ?? "default"],
354
+ p.context,
355
+ );
287
356
  } catch (e) {
288
357
  // Exit code of a missing object is not documented, so NotFound is read from the message.
289
358
  if (e instanceof Error && isKubectlNotFound(e.message)) {
@@ -295,12 +364,18 @@ export function registerKubernetesTools(
295
364
  },
296
365
  );
297
366
 
298
- reg("k8s_deployment_list", "List deployments in a namespace.", optionalNamespaceSchema, (p) =>
299
- k8sListNamespacedResource(p, "deployments"),
367
+ reg(
368
+ "k8s_deployment_list",
369
+ "List deployments in a namespace. `context` is optional; when given it must be the configured context.",
370
+ readSchema,
371
+ (p) => k8sListNamespacedResource(p, "deployments"),
300
372
  );
301
373
 
302
- reg("k8s_event_list", "List events in a namespace.", optionalNamespaceSchema, (p) =>
303
- k8sListNamespacedResource(p, "events"),
374
+ reg(
375
+ "k8s_event_list",
376
+ "List events in a namespace. `context` is optional; when given it must be the configured context.",
377
+ readSchema,
378
+ (p) => k8sListNamespacedResource(p, "events"),
304
379
  );
305
380
 
306
381
  registerWriteTool(
@@ -308,18 +383,30 @@ export function registerKubernetesTools(
308
383
  {
309
384
  mutates: "kubernetes.rollout.restart",
310
385
  recoverable: true,
311
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace ?? "default" }),
386
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
312
387
  },
313
- "Restart a rollout (e.g. deployment).",
388
+ "Restart a rollout (e.g. deployment). `context` and `namespace` are required; `context` must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one).",
314
389
  z.object({
315
- namespace: cliArg.optional(),
390
+ context: cliArg,
391
+ namespace: cliArg,
316
392
  resourceType: cliArg,
317
393
  name: cliArg,
318
394
  }),
319
395
  async (p) => {
320
- const ns = p.namespace ?? "default";
321
- const cmd = [...kubectlBase(), "rollout", "restart", p.resourceType, p.name, "-n", ns];
322
- await withKube((env) => runCliOkThrowing(cmd, env));
396
+ await withKube(p.context, (env, context) =>
397
+ runCliOkThrowing(
398
+ [
399
+ ...kubectlBase(context),
400
+ "rollout",
401
+ "restart",
402
+ p.resourceType,
403
+ p.name,
404
+ "-n",
405
+ p.namespace,
406
+ ],
407
+ env,
408
+ ),
409
+ );
323
410
  return jsonResult({ ok: true });
324
411
  },
325
412
  );
@@ -328,22 +415,25 @@ export function registerKubernetesTools(
328
415
  "k8s_pod_delete",
329
416
  {
330
417
  mutates: "kubernetes.pod.delete",
331
- // A deleted pod is not restorable; its identity is all that remains to record — including
332
- // the namespace the delete defaulted to, without which the record cannot say where it was.
418
+ // A deleted pod is not restorable; its identity is all that remains to record.
333
419
  recoverable: false,
334
420
  capturePreState: (p) =>
335
- Promise.resolve({ namespace: p.namespace ?? "default", podName: p.podName }),
336
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace ?? "default" }),
421
+ Promise.resolve({ context: p.context, namespace: p.namespace, podName: p.podName }),
422
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
337
423
  },
338
- "Delete a pod.",
424
+ "Delete a pod. `context` and `namespace` are required; `context` must be the configured context.",
339
425
  z.object({
340
- namespace: cliArg.optional(),
426
+ context: cliArg,
427
+ namespace: cliArg,
341
428
  podName: cliArg,
342
429
  }),
343
430
  async (p) => {
344
- const ns = p.namespace ?? "default";
345
- const cmd = [...kubectlBase(), "delete", "pod", p.podName, "-n", ns];
346
- await withKube((env) => runCliOkThrowing(cmd, env));
431
+ await withKube(p.context, (env, context) =>
432
+ runCliOkThrowing(
433
+ [...kubectlBase(context), "delete", "pod", p.podName, "-n", p.namespace],
434
+ env,
435
+ ),
436
+ );
347
437
  return jsonResult({ ok: true });
348
438
  },
349
439
  );
@@ -353,26 +443,30 @@ export function registerKubernetesTools(
353
443
  {
354
444
  mutates: "kubernetes.deployment.scale",
355
445
  recoverable: true,
356
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace ?? "default" }),
446
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
357
447
  },
358
- "Scale a deployment.",
448
+ "Scale a deployment. `context` and `namespace` are required; `context` must be the configured context.",
359
449
  z.object({
360
- namespace: cliArg.optional(),
450
+ context: cliArg,
451
+ namespace: cliArg,
361
452
  deploymentName: cliArg,
362
453
  replicas: z.number().int().min(0),
363
454
  }),
364
455
  async (p) => {
365
- const ns = p.namespace ?? "default";
366
- const cmd = [
367
- ...kubectlBase(),
368
- "scale",
369
- "deployment",
370
- p.deploymentName,
371
- `-n`,
372
- ns,
373
- `--replicas=${String(p.replicas)}`,
374
- ];
375
- await withKube((env) => runCliOkThrowing(cmd, env));
456
+ await withKube(p.context, (env, context) =>
457
+ runCliOkThrowing(
458
+ [
459
+ ...kubectlBase(context),
460
+ "scale",
461
+ "deployment",
462
+ p.deploymentName,
463
+ "-n",
464
+ p.namespace,
465
+ `--replicas=${String(p.replicas)}`,
466
+ ],
467
+ env,
468
+ ),
469
+ );
376
470
  return jsonResult({ ok: true });
377
471
  },
378
472
  );
@@ -380,60 +474,26 @@ export function registerKubernetesTools(
380
474
  registerPrepareTool(
381
475
  "k8s_manifest_plan",
382
476
  { scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }) },
383
- "Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace), then ask the API server for a server-side dry run and a server-side diff. Returns {planSha256, namespace, objects, scanned, diff, hasDifferences}; the apply must be given the same namespace, manifest and planSha256. Changes nothing.",
384
- z.object({ namespace: cliArg, manifest: z.string().min(1).max(MANIFEST_MAX_BYTES) }),
477
+ "Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace), ask the API server for a server-side dry run, read the same objects as they are now, and diff the two in this connector (no `diff` program). Returns {planSha256, diffSha256, namespace, objects, scanned, diff, hasDifferences}; the apply must be given the same context, namespace, manifest, planSha256 and diffSha256. `context` must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one) and is part of planSha256. Changes nothing.",
478
+ z.object({
479
+ context: cliArg,
480
+ namespace: cliArg,
481
+ manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
482
+ }),
385
483
  async (p) => {
386
- const { bytes, sha256, scanned } = checkedManifest(p);
387
- const ns = p.namespace;
484
+ const { bytes, scanned } = checkedManifest(p);
388
485
  const reply = await withScratchDir("nimbus-k8s-", async (dir) => {
389
486
  const file = await writeManifestFile(dir, bytes);
390
- return withKube(async (kube) => {
391
- // The dry run: the API server validates and admits the manifest and names each object,
392
- // and the names must be the ones the scan read (see assertSameObjects).
393
- const objects = await serverDryRun(kube, ns, file, scanned);
394
-
395
- // kubectl diff runs the program KUBECTL_EXTERNAL_DIFF names (default `diff -u -N`). The
396
- // variable is removed from the child whatever the owner's shell sets, so the diff is
397
- // never produced by an arbitrary program. TMPDIR/TMP/TEMP point kubectl's own temp files
398
- // (Go reads TMP and TEMP on Windows) into this call's private directory. Exit codes:
399
- // https://kubernetes.io/docs/reference/kubectl/generated/kubectl_diff/ (0 no
400
- // differences, 1 differences, >1 error). --force-conflicts is never passed.
401
- const diff = await runCliCode(
402
- [
403
- ...kubectlBase(),
404
- "diff",
405
- "--server-side",
406
- "--field-manager=nimbus",
407
- "-n",
408
- ns,
409
- "-f",
410
- file,
411
- ],
412
- {
413
- KUBECONFIG: kube.KUBECONFIG,
414
- KUBECTL_EXTERNAL_DIFF: undefined,
415
- TMPDIR: dir,
416
- TMP: dir,
417
- TEMP: dir,
418
- },
419
- );
420
- if (diff.code !== 0 && diff.code !== 1) {
421
- throw kubectlFailure("kubectl diff", diff.code, diff.stderr, scanned);
422
- }
423
- if (Buffer.byteLength(diff.stdout, "utf8") > DIFF_MAX_BYTES) {
424
- throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
425
- }
426
- // kubectl masks a Secret's `data`, but the diff's context lines can still carry its
427
- // plaintext (a `last-applied-configuration` annotation, `stringData`), so with a Secret
428
- // in the manifest only the fact of a difference is returned, never the body.
429
- const holdsSecret = scanned.some((o) => o.kind === "Secret");
487
+ return withKube(p.context, async (kube, context) => {
488
+ const plan = await computePlan(kube, context, p.namespace, file, scanned);
430
489
  return {
431
- planSha256: sha256,
432
- namespace: ns,
433
- objects,
490
+ planSha256: planHash(context, p.namespace, p.manifest),
491
+ diffSha256: plan.diffSha256,
492
+ namespace: p.namespace,
493
+ objects: plan.objects,
434
494
  scanned,
435
- diff: holdsSecret ? SECRET_DIFF_WITHHELD : diff.stdout,
436
- hasDifferences: diff.code === 1,
495
+ diff: plan.diff,
496
+ hasDifferences: plan.hasDifferences,
437
497
  };
438
498
  });
439
499
  });
@@ -452,37 +512,57 @@ export function registerKubernetesTools(
452
512
  recoverable: false,
453
513
  capturePreState: (p) =>
454
514
  Promise.resolve({
515
+ context: p.context,
455
516
  namespace: p.namespace,
456
517
  planSha256: p.planSha256,
518
+ diffSha256: p.diffSha256,
457
519
  objects: checkedManifest(p).scanned,
458
520
  }),
459
521
  scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
460
522
  // Standalone only: a manifest no consent could make safe is refused before any prompt. The
461
- // handler repeats the same checks, because gateway mode registers the raw handler.
462
- refuseBefore: (p) => refusalOf(() => approvedManifest(p)),
523
+ // handler repeats the same checks, because gateway mode registers the raw handler. The hash
524
+ // needs the effective context, which may take a kubectl call, so it is checked here only when
525
+ // KUBE_CONTEXT names it; the handler always checks it.
526
+ refuseBefore: (p) =>
527
+ refusalOf(() => {
528
+ checkedManifest(p);
529
+ const configured = process.env["KUBE_CONTEXT"]?.trim();
530
+ if (configured === undefined || configured === "") return;
531
+ if (p.context !== configured) throw new Error(CONTEXT_MISMATCH);
532
+ assertApprovedHash(configured, p);
533
+ }),
463
534
  },
464
- "Apply a manifest with a server-side apply, in the given namespace. The namespace and manifest must be the ones k8s_manifest_plan planned (planSha256) and the manifest must hold namespaced objects of allowlisted kinds only. Runs a server-side dry run first and applies only if kubectl reports the objects the scan read.",
535
+ "Apply a manifest with a server-side apply, in the given context and namespace. The context, namespace and manifest must be the ones k8s_manifest_plan planned (planSha256), and diffSha256 must be the plan's: the apply first recomputes the plan against the cluster and applies only if the diff is the one that was approved, else it throws 'the cluster changed since the plan was approved' and applies nothing. The manifest must hold namespaced objects of allowlisted kinds only. `context` must be the configured context.",
465
536
  z.object({
537
+ context: cliArg,
466
538
  namespace: cliArg,
467
539
  manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
468
540
  planSha256: z.string().regex(/^[0-9a-f]{64}$/),
541
+ diffSha256: z.string().regex(/^[0-9a-f]{64}$/),
469
542
  }),
470
543
  async (p) => {
471
- const { bytes, scanned } = approvedManifest(p);
544
+ const { bytes, scanned } = checkedManifest(p);
472
545
  return withScratchDir("nimbus-k8s-", async (dir) => {
473
546
  const file = await writeManifestFile(dir, bytes);
474
- return withKube(async (kube) => {
475
- // Dry run and cross-check FIRST, so an object kubectl would apply that the scan did not
476
- // read stops the call before anything changes.
477
- await serverDryRun(kube, p.namespace, file, scanned);
478
- // The file the real apply reads must still hold the approved bytes: the dry run was a
479
- // child process with the same file, and on Windows the 0600 mode is not enforced.
547
+ return withKube(p.context, async (kube, context) => {
548
+ assertApprovedHash(context, p);
549
+ // The re-diff: exactly the plan's dry run, get and diff, again, now. Any failure of it, or
550
+ // a diff that is not the approved one, means the cluster is not what the owner saw.
551
+ let again: Computed;
552
+ try {
553
+ again = await computePlan(kube, context, p.namespace, file, scanned);
554
+ } catch (e) {
555
+ throw new Error(CLUSTER_CHANGED, { cause: e });
556
+ }
557
+ if (!sameHex(again.diffSha256, p.diffSha256)) throw new Error(CLUSTER_CHANGED);
558
+ // The file the real apply reads must still hold the approved bytes: the re-diff ran
559
+ // child processes on the same file, and on Windows the 0600 mode is not enforced.
480
560
  if (!bytes.equals(await readFile(file))) {
481
561
  throw new Error("the manifest file changed before the apply");
482
562
  }
483
563
  const applied = await runCliCode(
484
564
  [
485
- ...kubectlBase(),
565
+ ...kubectlBase(context),
486
566
  "apply",
487
567
  "--server-side",
488
568
  "--field-manager=nimbus",
@@ -97,6 +97,20 @@ async function appGroupIds(appId: string): Promise<Set<string>> {
97
97
  return new Set(groups.map((g) => g["id"]).filter((id): id is string => typeof id === "string"));
98
98
  }
99
99
 
100
+ /**
101
+ * The ids, among `ids`, of beta groups that have a public link right now: one
102
+ * `GET /v1/betaGroups/{id}` each, read at dispatch so the answer is the group's state at the moment
103
+ * of the send, not at the time anyone was shown it.
104
+ */
105
+ async function publicLinkGroupIds(ids: readonly string[]): Promise<string[]> {
106
+ const open: string[] = [];
107
+ for (const id of ids) {
108
+ const root = asObj(await appstoreGet(`/v1/betaGroups/${encodeURIComponent(id)}`));
109
+ if (asObj(asObj(root["data"])["attributes"])["publicLinkEnabled"] === true) open.push(id);
110
+ }
111
+ return open;
112
+ }
113
+
100
114
  /** The app a build belongs to, from `GET /v1/builds/{id}?include=app`. */
101
115
  async function appOfBuild(buildId: string): Promise<string | undefined> {
102
116
  const root = asObj(await appstoreGet(`/v1/builds/${encodeURIComponent(buildId)}?include=app`));
@@ -192,11 +206,12 @@ export function registerTestflightTools(
192
206
  }),
193
207
  scopeTargetOf: (p: { appId: string }) => ({ kind: "app", value: p.appId }),
194
208
  },
195
- 'Add a TestFlight build to beta groups (`POST /v1/builds/{buildId}/relationships/betaGroups`, body `{ data: [{ type: "betaGroups", id }] }`). The build must belong to `appId`: it is read first (`GET /v1/builds/{buildId}?include=app`) and a build of another app sends nothing. Adding a build to a group makes it available to every tester in it, and a group with a public link reaches anyone holding the link (see `testflight_build_groups_get`). Apple may refuse (409/422) a build that is not VALID or a external group that has not passed beta review; the body is returned in the error. The reply is `accepted`.',
209
+ 'Add a TestFlight build to beta groups (`POST /v1/builds/{buildId}/relationships/betaGroups`, body `{ data: [{ type: "betaGroups", id }] }`). The build must belong to `appId`: it is read first (`GET /v1/builds/{buildId}?include=app`) and a build of another app sends nothing. Adding a build to a group makes it available to every tester in it, and a group with a public link reaches anyone holding the link (see `testflight_build_groups_get`): each target group is read (`GET /v1/betaGroups/{id}`) right before the send, and one with `publicLinkEnabled: true` is refused with `testflight_public_link_forbidden` unless `allowPublicLink` is `true` (default false). Apple may refuse (409/422) a build that is not VALID or a external group that has not passed beta review; the body is returned in the error. The reply is `accepted`.',
196
210
  z.object({
197
211
  appId: idArg,
198
212
  buildId: idArg,
199
213
  betaGroupIds: z.array(idArg).min(1).max(20),
214
+ allowPublicLink: z.boolean().optional(),
200
215
  }),
201
216
  async (p) => {
202
217
  if (new Set(p.betaGroupIds).size !== p.betaGroupIds.length) {
@@ -214,6 +229,16 @@ export function registerTestflightTools(
214
229
  `beta group(s) ${stray.join(", ")} do not belong to app ${p.appId}; nothing was sent`,
215
230
  );
216
231
  }
232
+ // A group with a public link reaches anyone who holds the link, so it is distributed to only
233
+ // when the caller said so. Read now, at dispatch, from each group itself.
234
+ if (p.allowPublicLink !== true) {
235
+ const open = await publicLinkGroupIds(p.betaGroupIds);
236
+ if (open.length > 0) {
237
+ throw new Error(
238
+ `testflight_public_link_forbidden: beta group(s) ${open.join(", ")} have a public link; pass allowPublicLink: true to distribute to them; nothing was sent`,
239
+ );
240
+ }
241
+ }
217
242
  await appstoreSend(
218
243
  "POST",
219
244
  `/v1/builds/${encodeURIComponent(p.buildId)}/relationships/betaGroups`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-dev/connectors",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,158 @@
1
+ /**
2
+ * line-diff: a pure unified diff of two lists of lines (Myers' O(ND) shortest edit script).
3
+ *
4
+ * It exists so a tool can show an approver exactly what will change without starting a `diff`
5
+ * program: Windows ships none, and a program the environment names is not one to trust with the text
6
+ * an owner approves. Equal inputs give "". The output has the two header lines, then one hunk
7
+ * `@@ -a,b +c,d @@` per run of changes with `context` lines either side (hunks whose context touches
8
+ * are merged), every line ending with "\n". Counts are always written, `,1` included.
9
+ *
10
+ * Bounded: an edit script longer than {@link MAX_EDIT_DISTANCE} throws {@link LineDiffTooLargeError}
11
+ * (the caller refuses, it never cuts), so a pathological input cannot hold memory or the event loop.
12
+ */
13
+
14
+ export const MAX_EDIT_DISTANCE = 2000;
15
+
16
+ /** `V[j]` from a trace window that holds `V[-d..d]` (index `j + d`); 0 outside it. */
17
+ function at(window: Int32Array, d: number, j: number): number {
18
+ return j < -d || j > d ? 0 : (window[j + d] ?? 0);
19
+ }
20
+
21
+ export class LineDiffTooLargeError extends Error {
22
+ constructor() {
23
+ super("the two sides differ by too many lines to diff");
24
+ this.name = "LineDiffTooLargeError";
25
+ }
26
+ }
27
+
28
+ type Op = { readonly kind: " " | "-" | "+"; readonly text: string };
29
+
30
+ /** The edit script from `a` to `b`: the equal prefix and suffix are peeled off, Myers does the rest. */
31
+ function editScript(a: readonly string[], b: readonly string[]): Op[] {
32
+ let start = 0;
33
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
34
+ let endA = a.length;
35
+ let endB = b.length;
36
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
37
+ endA -= 1;
38
+ endB -= 1;
39
+ }
40
+ const head: Op[] = a.slice(0, start).map((text) => ({ kind: " ", text }));
41
+ const tail: Op[] = a.slice(endA).map((text) => ({ kind: " ", text }));
42
+ return [...head, ...myers(a.slice(start, endA), b.slice(start, endB)), ...tail];
43
+ }
44
+
45
+ function myers(a: readonly string[], b: readonly string[]): Op[] {
46
+ const n = a.length;
47
+ const m = b.length;
48
+ if (n === 0) return b.map((text) => ({ kind: "+", text }));
49
+ if (m === 0) return a.map((text) => ({ kind: "-", text }));
50
+ const max = Math.min(n + m, MAX_EDIT_DISTANCE);
51
+ const offset = max + 1;
52
+ const v = new Int32Array(2 * max + 3);
53
+ // trace[d] is V[-d..d] as it stood BEFORE step d (index k + d), all the walk back reads. Storing
54
+ // the whole V at every step would hold (max + 1) * (2 * max + 3) values; the window holds about
55
+ // max * max, 16 MB at the bound.
56
+ const trace: Int32Array[] = [];
57
+ let found = -1;
58
+ for (let d = 0; d <= max && found < 0; d += 1) {
59
+ trace.push(v.slice(offset - d, offset + d + 1));
60
+ for (let k = -d; k <= d; k += 2) {
61
+ const down = k === -d || (k !== d && (v[offset + k - 1] ?? 0) < (v[offset + k + 1] ?? 0));
62
+ let x = down ? (v[offset + k + 1] ?? 0) : (v[offset + k - 1] ?? 0) + 1;
63
+ let y = x - k;
64
+ while (x < n && y < m && a[x] === b[y]) {
65
+ x += 1;
66
+ y += 1;
67
+ }
68
+ v[offset + k] = x;
69
+ if (x >= n && y >= m) {
70
+ found = d;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ if (found < 0) throw new LineDiffTooLargeError();
76
+ const ops: Op[] = [];
77
+ let x = n;
78
+ let y = m;
79
+ for (let d = found; d > 0; d -= 1) {
80
+ const prev = trace[d];
81
+ if (prev === undefined) throw new LineDiffTooLargeError();
82
+ const k = x - y;
83
+ const down = k === -d || (k !== d && at(prev, d, k - 1) < at(prev, d, k + 1));
84
+ const prevK = down ? k + 1 : k - 1;
85
+ const prevX = at(prev, d, prevK);
86
+ const prevY = prevX - prevK;
87
+ while (x > prevX && y > prevY) {
88
+ x -= 1;
89
+ y -= 1;
90
+ ops.push({ kind: " ", text: a[x] ?? "" });
91
+ }
92
+ if (down) {
93
+ y -= 1;
94
+ ops.push({ kind: "+", text: b[y] ?? "" });
95
+ } else {
96
+ x -= 1;
97
+ ops.push({ kind: "-", text: a[x] ?? "" });
98
+ }
99
+ }
100
+ while (x > 0 && y > 0) {
101
+ x -= 1;
102
+ y -= 1;
103
+ ops.push({ kind: " ", text: a[x] ?? "" });
104
+ }
105
+ return ops.reverse();
106
+ }
107
+
108
+ export function unifiedLineDiff(
109
+ from: readonly string[],
110
+ to: readonly string[],
111
+ opts: {
112
+ readonly fromLabel: string;
113
+ readonly toLabel: string;
114
+ readonly context?: number | undefined;
115
+ },
116
+ ): string {
117
+ const context = opts.context ?? 3;
118
+ const ops = editScript(from, to);
119
+ const changed: number[] = [];
120
+ ops.forEach((op, i) => {
121
+ if (op.kind !== " ") changed.push(i);
122
+ });
123
+ if (changed.length === 0) return "";
124
+
125
+ // Group the changes into hunks: [first, last] indexes into ops, widened by the context.
126
+ const ranges: [number, number][] = [];
127
+ for (const i of changed) {
128
+ const lo = Math.max(0, i - context);
129
+ const hi = Math.min(ops.length - 1, i + context);
130
+ const last = ranges[ranges.length - 1];
131
+ if (last !== undefined && lo <= last[1] + 1) last[1] = hi;
132
+ else ranges.push([lo, hi]);
133
+ }
134
+
135
+ // Lines of each side consumed before each op, so a hunk's start is a lookup.
136
+ const aBefore: number[] = [];
137
+ const bBefore: number[] = [];
138
+ let ai = 0;
139
+ let bi = 0;
140
+ for (const op of ops) {
141
+ aBefore.push(ai);
142
+ bBefore.push(bi);
143
+ if (op.kind !== "+") ai += 1;
144
+ if (op.kind !== "-") bi += 1;
145
+ }
146
+
147
+ const out = [`--- ${opts.fromLabel}`, `+++ ${opts.toLabel}`];
148
+ for (const [lo, hi] of ranges) {
149
+ const slice = ops.slice(lo, hi + 1);
150
+ const aCount = slice.filter((o) => o.kind !== "+").length;
151
+ const bCount = slice.filter((o) => o.kind !== "-").length;
152
+ const aStart = (aBefore[lo] ?? 0) + (aCount === 0 ? 0 : 1);
153
+ const bStart = (bBefore[lo] ?? 0) + (bCount === 0 ? 0 : 1);
154
+ out.push(`@@ -${String(aStart)},${String(aCount)} +${String(bStart)},${String(bCount)} @@`);
155
+ for (const op of slice) out.push(`${op.kind}${op.text}`);
156
+ }
157
+ return `${out.join("\n")}\n`;
158
+ }