@nimbus-dev/connectors 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/connectors/apple/src/server.ts +5 -1
- package/connectors/athena/src/tools.ts +12 -10
- package/connectors/aws/src/server.ts +5 -1
- package/connectors/aws/src/tools.ts +9 -7
- package/connectors/azure/src/server.ts +5 -1
- package/connectors/azure/src/tools.ts +10 -9
- package/connectors/bitbucket/src/server.ts +5 -1
- package/connectors/circleci/src/server.ts +5 -1
- package/connectors/cloud-logging/src/tools.ts +5 -8
- package/connectors/cloudwatch/src/tools.ts +6 -5
- package/connectors/confluence/src/server.ts +5 -1
- package/connectors/fastmail/src/server.ts +5 -1
- package/connectors/gcp/src/server.ts +5 -1
- package/connectors/gcp/src/tools.ts +11 -11
- package/connectors/github/src/server.ts +2 -0
- package/connectors/github-actions/src/server.ts +5 -1
- package/connectors/gitlab/src/server.ts +5 -1
- package/connectors/gmail/src/server.ts +5 -1
- package/connectors/google-drive/src/server.ts +5 -1
- package/connectors/iac/src/server.ts +5 -1
- package/connectors/iac/src/tools.ts +83 -25
- package/connectors/imap/src/server.ts +5 -1
- package/connectors/jenkins/src/server.ts +5 -1
- package/connectors/jira/src/server.ts +5 -1
- package/connectors/kubernetes/src/server.ts +5 -1
- package/connectors/kubernetes/src/tools.ts +9 -8
- package/connectors/linear/src/server.ts +5 -1
- package/connectors/notion/src/server.ts +5 -1
- package/connectors/obsidian/src/server.ts +5 -1
- package/connectors/onedrive/src/server.ts +5 -1
- package/connectors/outlook/src/server.ts +5 -1
- package/connectors/pagerduty/src/server.ts +5 -1
- package/connectors/protonmail/src/server.ts +5 -1
- package/connectors/sagemaker/src/tools.ts +5 -5
- package/connectors/slack/src/server.ts +5 -1
- package/connectors/teams/src/server.ts +5 -1
- package/package.json +1 -1
- package/shared/audit-chain.ts +588 -28
- package/shared/cli-json-kit.ts +35 -6
- package/shared/consent-kit.ts +115 -38
- package/shared/nimbus-spawn.ts +114 -27
- package/shared/run-read-only-mcp-connector.ts +7 -1
- package/shared/safe-cli-arg.ts +119 -17
- package/shared/windows-batch-args.ts +274 -0
- package/shared/write-budget.ts +100 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* windows-batch-args — refuse an argument cmd.exe would act on, when the CLI being spawned may
|
|
3
|
+
* start a Windows batch file.
|
|
4
|
+
*
|
|
5
|
+
* Every spawn in this package passes an argv array with no shell, so on Linux and macOS each
|
|
6
|
+
* argument reaches the CLI byte for byte. Windows breaks that for one kind of executable. A batch
|
|
7
|
+
* file — a `.cmd` or `.bat` — cannot be started on its own, so CreateProcess runs it as
|
|
8
|
+
* `cmd.exe /c <command line>`, and cmd.exe parses the whole command line again before the batch
|
|
9
|
+
* file sees any of it. `az` and `gcloud` are batch files on Windows. Measured with Bun 1.3.14, which
|
|
10
|
+
* resolves a bare `az` to `az.cmd` whenever the spawn environment spells the variable `PATH`, and
|
|
11
|
+
* whose `node:child_process` launches one the same way:
|
|
12
|
+
*
|
|
13
|
+
* - `&`, `|`, `<` and `>` in an argument the runtime did not quote — any argument without a space
|
|
14
|
+
* — start a second command, a pipe or a redirection: `x&echo,INJECTED` printed `INJECTED`.
|
|
15
|
+
* - `"` ends the quoting the runtime added, so an argument with a space is no defence:
|
|
16
|
+
* `q"&echo INJECTED&"` ran the `echo` too.
|
|
17
|
+
* - `%NAME%` is replaced by the value of an environment variable even inside quotes, and `!NAME!`
|
|
18
|
+
* is too in a batch file that turns on delayed expansion — so a credential in the connector's
|
|
19
|
+
* environment would be sent to the cloud API as the argument, where an error could quote it.
|
|
20
|
+
* - `(` and `)` close a parenthesised block early. `az.cmd` runs the CLI inside one, and in a
|
|
21
|
+
* batch file of that shape an unquoted argument holding `)` is a syntax error — "... was
|
|
22
|
+
* unexpected at this time" — before the CLI starts.
|
|
23
|
+
* - `^` is consumed as an escape, and a line break ends the command.
|
|
24
|
+
*
|
|
25
|
+
* Quoting for cmd.exe is possible, but it is a second grammar layered on the first, with `%` and
|
|
26
|
+
* `!` that no quote stops; refusing is simpler and can be verified. What the refusal costs was
|
|
27
|
+
* checked against every value the connectors pass: none of these characters is valid in a GCP or
|
|
28
|
+
* AWS resource name, an Azure app, cluster or node pool name, or a subscription id. An Azure
|
|
29
|
+
* resource group may hold `(` and `)`, but never a space, so the runtime never quotes it and
|
|
30
|
+
* `az.cmd` already failed on it as above. What did work and is now refused: an Azure subscription
|
|
31
|
+
* passed by a display name holding one of these characters and a space — its id still works. A
|
|
32
|
+
* CloudFormation template, full of quotes, was refused too whenever the `aws` that runs is a batch
|
|
33
|
+
* file, until it began to reach `aws` as a file instead of an argument.
|
|
34
|
+
*
|
|
35
|
+
* Which file runs is decided the way the runtime decides it — see {@link mayRunAsBatchFile} — so
|
|
36
|
+
* an `aws.exe` found first is never refused, whatever batch file of that name comes later.
|
|
37
|
+
*
|
|
38
|
+
* Node itself never gets this far: it resolves a bare name only to `.com` or `.exe`, and it has
|
|
39
|
+
* refused to spawn a `.cmd` or `.bat` without a shell since 18.20.2 / 20.12.2 (CVE-2024-27980).
|
|
40
|
+
* The rule applies to both runtimes all the same, since the check costs nothing where Node would
|
|
41
|
+
* have refused anyway.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { statSync } from "node:fs";
|
|
45
|
+
import { win32 } from "node:path";
|
|
46
|
+
|
|
47
|
+
/** The characters cmd.exe acts on when it parses a command line again, line breaks aside. */
|
|
48
|
+
const CMD_METACHARACTERS: ReadonlySet<string> = new Set([
|
|
49
|
+
"%",
|
|
50
|
+
"!",
|
|
51
|
+
'"',
|
|
52
|
+
"&",
|
|
53
|
+
"|",
|
|
54
|
+
"<",
|
|
55
|
+
">",
|
|
56
|
+
"^",
|
|
57
|
+
"(",
|
|
58
|
+
")",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** The extensions CreateProcess hands to cmd.exe. */
|
|
62
|
+
const BATCH_EXTENSIONS = [".cmd", ".bat"] as const;
|
|
63
|
+
|
|
64
|
+
/** The extensions Bun tries, in this order, on a name it looks up on Windows. */
|
|
65
|
+
const LOOKUP_EXTENSIONS = [".exe", ".cmd", ".bat"] as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The first character of `value` that cmd.exe would act on — one of `% ! " & | < > ^ ( )`, or a
|
|
69
|
+
* control character such as a line break — or `undefined` when there is none.
|
|
70
|
+
*/
|
|
71
|
+
export function cmdMetacharacterIn(value: string): string | undefined {
|
|
72
|
+
for (const ch of value) {
|
|
73
|
+
if (CMD_METACHARACTERS.has(ch) || (ch.codePointAt(0) ?? 0) < 0x20) {
|
|
74
|
+
return ch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether Windows runs a file of this name through cmd.exe: a `.cmd` or a `.bat`, in any case,
|
|
82
|
+
* after the trailing dots and spaces Windows drops from a file name — `az.cmd.` opens `az.cmd`.
|
|
83
|
+
*/
|
|
84
|
+
export function isBatchFileName(name: string): boolean {
|
|
85
|
+
const trimmed = name.replace(/[. ]+$/, "").toLowerCase();
|
|
86
|
+
return BATCH_EXTENSIONS.some((ext) => trimmed.endsWith(ext));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether Bun looks `name` up exactly as written instead of trying extensions on it: when it ends
|
|
91
|
+
* in `.exe`, `.cmd` or `.bat`, in any case. `.com` is not among them, so `x.com` is looked up as
|
|
92
|
+
* `x.com.exe`, `x.com.cmd` and `x.com.bat`.
|
|
93
|
+
*/
|
|
94
|
+
function endsWithLookupExtension(name: string): boolean {
|
|
95
|
+
return /\.(?:exe|cmd|bat)$/i.test(name);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The filesystem a batch-file check looks at. Production leaves both to their defaults. */
|
|
99
|
+
export interface BatchFileProbe {
|
|
100
|
+
/**
|
|
101
|
+
* Whether a FILE exists at a path. A directory does not count, as it does not for Bun's lookup.
|
|
102
|
+
* Defaults to a `statSync` check, which follows a link to what it points at.
|
|
103
|
+
*/
|
|
104
|
+
readonly exists?: (path: string) => boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The directory a relative name or `PATH` entry is resolved against. Defaults to
|
|
107
|
+
* `process.cwd()`: nothing here spawns with a `cwd` of its own.
|
|
108
|
+
*/
|
|
109
|
+
readonly cwd?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a file — not a directory — exists at `path`. */
|
|
113
|
+
function isFile(path: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
return statSync(path).isFile();
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The value of each PATH variable of `env` that is set and not empty, in every spelling of its
|
|
123
|
+
* name: `PATH`, the one Bun reads, and `Path`, which is how Windows spells it and which a copy of
|
|
124
|
+
* a Windows-launched process's environment keeps.
|
|
125
|
+
*/
|
|
126
|
+
function pathValues(env: Record<string, string | undefined>): string[] {
|
|
127
|
+
const values: string[] = [];
|
|
128
|
+
for (const [key, value] of Object.entries(env)) {
|
|
129
|
+
if (key.toLowerCase() === "path" && value !== undefined && value !== "") {
|
|
130
|
+
values.push(value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return values;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The orders in which a PATH value's directories are searched: its entries as written, which is
|
|
138
|
+
* how Bun reads them, and — when that differs — with whitespace and surrounding quotes removed
|
|
139
|
+
* from each, as cmd.exe reads them. An empty entry is skipped, as Bun skips it.
|
|
140
|
+
*/
|
|
141
|
+
function searchOrders(value: string): string[][] {
|
|
142
|
+
const written = value.split(win32.delimiter).filter((entry) => entry !== "");
|
|
143
|
+
const cleaned = written
|
|
144
|
+
.map((entry) => entry.trim().replace(/^"(.*)"$/, "$1"))
|
|
145
|
+
.filter((entry) => entry !== "");
|
|
146
|
+
const same =
|
|
147
|
+
cleaned.length === written.length && cleaned.every((entry, i) => entry === written[i]);
|
|
148
|
+
return same ? [written] : [written, cleaned];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the first of `bases` that a file `<base>.exe`, `<base>.cmd` or `<base>.bat` exists for
|
|
153
|
+
* has a `.cmd` or `.bat` one: the lookup stops at the first such place, and that place decides.
|
|
154
|
+
*/
|
|
155
|
+
function firstHolderIsBatch(bases: readonly string[], exists: (path: string) => boolean): boolean {
|
|
156
|
+
for (const base of bases) {
|
|
157
|
+
const found = LOOKUP_EXTENSIONS.filter((ext) => exists(base + ext));
|
|
158
|
+
if (found.length > 0) {
|
|
159
|
+
return found.some((ext) => ext !== ".exe");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether spawning `bin` with `env` may start a batch file — whether the file that runs would be a
|
|
167
|
+
* `.cmd` or a `.bat`.
|
|
168
|
+
*
|
|
169
|
+
* That depends on how the runtime turns a name into a file, and this answers it the way Bun does
|
|
170
|
+
* on Windows: read from its source, unchanged from 1.2.0 to 1.3.14, and held to what the real
|
|
171
|
+
* lookup does by `windows-batch-args.test.ts` on Windows.
|
|
172
|
+
*
|
|
173
|
+
* - A name ending in `.cmd` or `.bat`, after the trailing dots and spaces Windows drops from a
|
|
174
|
+
* file name, is one: every runtime that starts it hands it to cmd.exe.
|
|
175
|
+
* - A name ending in `.exe` is looked up as written, and an `.exe` never is one.
|
|
176
|
+
* - Bun looks any other name up only when the spawn environment has a non-empty variable spelled
|
|
177
|
+
* exactly `PATH`. A bare name is looked for in each of that variable's entries in turn, and a
|
|
178
|
+
* name written with a `\` in its own directory alone, each time as `<name>.exe`, `<name>.cmd`
|
|
179
|
+
* and `<name>.bat`, and the first file found is what runs. So an `aws.exe` found before any
|
|
180
|
+
* `aws.cmd` is not a batch file, whatever comes after it. The current directory is not searched
|
|
181
|
+
* for a bare name.
|
|
182
|
+
* - Otherwise — and for any name written with a `/` — Bun hands the name to libuv, as Node does,
|
|
183
|
+
* and libuv starts only a `.com` or an `.exe`: never a batch file.
|
|
184
|
+
*
|
|
185
|
+
* It says yes where Bun could change without notice, and nowhere else: a PATH variable counts in
|
|
186
|
+
* any spelling, not only `PATH` — Bun reads no other, though `nimbus-spawn.ts` respells the
|
|
187
|
+
* variable `PATH` before every spawn, so a bare `az` under `Path` starts too — and each is searched
|
|
188
|
+
* both as written and with whitespace and quotes stripped from its entries; a name written with a
|
|
189
|
+
* `/` is looked up in its directory as one with a `\` is; and the first place holding the name
|
|
190
|
+
* answers yes if it holds a `.cmd` or `.bat` by that name, even beside an `.exe` Bun would try
|
|
191
|
+
* first.
|
|
192
|
+
*/
|
|
193
|
+
export function mayRunAsBatchFile(
|
|
194
|
+
bin: string,
|
|
195
|
+
env: Record<string, string | undefined>,
|
|
196
|
+
probe: BatchFileProbe = {},
|
|
197
|
+
): boolean {
|
|
198
|
+
if (isBatchFileName(bin)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (bin === "" || endsWithLookupExtension(bin)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const paths = pathValues(env);
|
|
205
|
+
if (paths.length === 0) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
const exists = probe.exists ?? isFile;
|
|
209
|
+
const cwd = probe.cwd ?? process.cwd();
|
|
210
|
+
if (bin.includes("/") || bin.includes("\\")) {
|
|
211
|
+
return firstHolderIsBatch([win32.resolve(cwd, bin)], exists);
|
|
212
|
+
}
|
|
213
|
+
return paths.some((value) =>
|
|
214
|
+
searchOrders(value).some((dirs) =>
|
|
215
|
+
firstHolderIsBatch(
|
|
216
|
+
dirs.map((dir) => win32.resolve(cwd, dir, bin)),
|
|
217
|
+
exists,
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A character named the way a refusal message can show it. */
|
|
224
|
+
function describeCharacter(ch: string): string {
|
|
225
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
226
|
+
return code < 0x20
|
|
227
|
+
? `the control character U+${code.toString(16).toUpperCase().padStart(4, "0")}`
|
|
228
|
+
: JSON.stringify(ch);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Options for {@link batchArgumentRefusal}: the filesystem probe, and the platform it applies on. */
|
|
232
|
+
export interface BatchArgumentOptions extends BatchFileProbe {
|
|
233
|
+
/** Defaults to `process.platform`. The rule applies only on `"win32"`. */
|
|
234
|
+
readonly platform?: NodeJS.Platform;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Why `command` must not be spawned with `env`, or `undefined` when it may: on Windows, when the
|
|
239
|
+
* program may start a batch file ({@link mayRunAsBatchFile}) and an argument holds a character
|
|
240
|
+
* cmd.exe would act on ({@link cmdMetacharacterIn}).
|
|
241
|
+
*
|
|
242
|
+
* The arguments are scanned first, so the filesystem is consulted only for a command that would
|
|
243
|
+
* be refused if its program were a batch file — an ordinary spawn costs no lookups at all. The
|
|
244
|
+
* message names the argument by position and the character, never the value, which may be long
|
|
245
|
+
* or private.
|
|
246
|
+
*/
|
|
247
|
+
export function batchArgumentRefusal(
|
|
248
|
+
command: readonly string[],
|
|
249
|
+
env: Record<string, string | undefined>,
|
|
250
|
+
options: BatchArgumentOptions = {},
|
|
251
|
+
): string | undefined {
|
|
252
|
+
if ((options.platform ?? process.platform) !== "win32") {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const [bin, ...args] = command;
|
|
256
|
+
if (bin === undefined) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
for (const [index, arg] of args.entries()) {
|
|
260
|
+
const ch = cmdMetacharacterIn(arg);
|
|
261
|
+
if (ch === undefined) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!mayRunAsBatchFile(bin, env, options)) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return (
|
|
268
|
+
`refused to run ${JSON.stringify(bin)}: it may start a Windows batch file, which cmd.exe ` +
|
|
269
|
+
`parses again before the CLI sees its arguments, and argument ${String(index + 1)} holds ` +
|
|
270
|
+
`${describeCharacter(ch)}, which cmd.exe would act on. Nothing was run.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** The variable that sets how many mutations a standalone connector may perform in one session. */
|
|
2
|
+
export const WRITE_BUDGET_ENV = "NIMBUS_MCP_WRITE_BUDGET";
|
|
3
|
+
|
|
4
|
+
/** Mutations allowed per process lifetime when `NIMBUS_MCP_WRITE_BUDGET` is unset. */
|
|
5
|
+
export const DEFAULT_WRITE_BUDGET = 10;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The largest budget accepted, `2 ** 53 - 1`, which is `Number.MAX_SAFE_INTEGER`.
|
|
9
|
+
*
|
|
10
|
+
* Above it a budget stops counting down one mutation at a time: `2 ** 54 - 1` rounds back to
|
|
11
|
+
* `2 ** 54`, so a budget that large would never run out. That is an unlimited cap spelled as a
|
|
12
|
+
* number, and it is refused like any other value that cannot be enforced as written.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_WRITE_BUDGET = Number.MAX_SAFE_INTEGER;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plain decimal digits: no sign, fraction, exponent, `0x` prefix, separator or `Infinity`. In a
|
|
18
|
+
* JavaScript regex `\d` is ASCII `0`-`9` only, with or without the `u` flag, so a digit from
|
|
19
|
+
* another script does not match.
|
|
20
|
+
*/
|
|
21
|
+
const DIGITS = /^\d+$/;
|
|
22
|
+
|
|
23
|
+
function invalidBudget(raw: string): Error {
|
|
24
|
+
return new Error(
|
|
25
|
+
`${WRITE_BUDGET_ENV}=${JSON.stringify(raw)}: expected a whole number of mutations from 0 to ` +
|
|
26
|
+
`${String(MAX_WRITE_BUDGET)}, where 0 refuses every write; leave it unset for the default, ` +
|
|
27
|
+
`${String(DEFAULT_WRITE_BUDGET)}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse `NIMBUS_MCP_WRITE_BUDGET` into the number of mutations a session may perform.
|
|
33
|
+
*
|
|
34
|
+
* STRICT, and an invalid value THROWS at startup, as `parseWriteScope` does on a malformed scope
|
|
35
|
+
* term. The budget used to be read with `Number()`, and that failed open: `Number("abc")` is
|
|
36
|
+
* `NaN`, `NaN <= 0` is false, so the budget never ran out. Measured on argocd standalone with 25
|
|
37
|
+
* approved syncs, unset allowed 10 while `abc`, `ten` and `Infinity` each allowed all 25.
|
|
38
|
+
*
|
|
39
|
+
* Throwing rather than falling back to the default is deliberate. A fallback still runs under a
|
|
40
|
+
* cap the operator did not write, and it can be a LARGER one: an operator who wrote `none` or
|
|
41
|
+
* `zero` meant no writes at all and would get ten. A typo in a security setting should stop the
|
|
42
|
+
* connector, which the client reports as a failed server, not be repaired behind a stderr warning
|
|
43
|
+
* the operator may never see.
|
|
44
|
+
*
|
|
45
|
+
* Accepted: unset, meaning `DEFAULT_WRITE_BUDGET`, or plain decimal digits from `0` (every write
|
|
46
|
+
* refuses) to `MAX_WRITE_BUDGET`, whitespace around them ignored. Everything else throws: a sign,
|
|
47
|
+
* a fraction (even `10.0`), an exponent, a hex or separator spelling, `Infinity`, a number too
|
|
48
|
+
* large to count down exactly, and an EMPTY or whitespace-only value. Every other variable in this
|
|
49
|
+
* package reads empty as unset, but here that reading is not the safe one: `Number("")` is 0, so
|
|
50
|
+
* an empty budget allowed no writes, and reading it as unset would silently raise it to ten. Empty
|
|
51
|
+
* is as likely to mean "none" as "the default", so it is refused rather than guessed at.
|
|
52
|
+
*/
|
|
53
|
+
export function parseWriteBudget(raw: string | undefined): number {
|
|
54
|
+
if (raw === undefined) return DEFAULT_WRITE_BUDGET;
|
|
55
|
+
const text = raw.trim();
|
|
56
|
+
// Digits are checked BEFORE `Number()` sees the text: it would read `1e3`, `0x10`, `+5` and
|
|
57
|
+
// `10.0` as numbers the operator never wrote, `Infinity` as no limit at all, and an empty value
|
|
58
|
+
// as 0.
|
|
59
|
+
if (!DIGITS.test(text)) throw invalidBudget(raw);
|
|
60
|
+
// All digits, so never NaN or negative, but a long enough run of them is above the largest safe
|
|
61
|
+
// integer or is `Infinity`. Only a safe integer passes, so what comes out can be counted down
|
|
62
|
+
// exactly, one mutation at a time.
|
|
63
|
+
const n = Number(text);
|
|
64
|
+
if (!Number.isSafeInteger(n)) throw invalidBudget(raw);
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A session's write budget: how many more mutations it may perform.
|
|
70
|
+
*
|
|
71
|
+
* The count lives here and nowhere else, so nothing outside can assign it, and its one check,
|
|
72
|
+
* `hasLeft`, passes only a positive safe integer. `parseWriteBudget` yields nothing but a safe
|
|
73
|
+
* whole number, but the check does not rely on that: the count was once tested as
|
|
74
|
+
* `remaining <= 0`, which is false for NaN, so a NaN count never ran out. Written this way, a NaN,
|
|
75
|
+
* an `Infinity` or a fraction allows no writes at all, wherever it came from.
|
|
76
|
+
*/
|
|
77
|
+
export type WriteBudget = {
|
|
78
|
+
/** Whether at least one mutation is left. */
|
|
79
|
+
hasLeft(): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Spend one mutation if one is left, and report whether one was. The check and the decrement
|
|
82
|
+
* are one synchronous call, so calls in flight together cannot all pass the check before any
|
|
83
|
+
* of them spends.
|
|
84
|
+
*/
|
|
85
|
+
take(): boolean;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A budget of `limit` mutations, `limit` being what `parseWriteBudget` returned. */
|
|
89
|
+
export function createWriteBudget(limit: number): WriteBudget {
|
|
90
|
+
let remaining = limit;
|
|
91
|
+
const hasLeft = (): boolean => Number.isSafeInteger(remaining) && remaining > 0;
|
|
92
|
+
return {
|
|
93
|
+
hasLeft,
|
|
94
|
+
take(): boolean {
|
|
95
|
+
if (!hasLeft()) return false;
|
|
96
|
+
remaining -= 1;
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|