functionalscript 0.34.0 → 0.35.1

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.
@@ -11,6 +11,8 @@ import { mcpStep, uninitializedState, } from "../../mcp/module.f.js";
11
11
  import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
12
12
  import { casConfig, casMcpHandlers } from "./module.f.js";
13
13
  import { ok as resultOk } from "../../types/result/module.f.js";
14
+ import { stdioTransport } from "../../mcp/stdio/module.f.js";
15
+ import { fromVec } from "../../types/uint8array/module.f.js";
14
16
  const initialTestState = { memory: { next: 0, values: {} } };
15
17
  const mock = {
16
18
  memCreate: value => state => {
@@ -65,6 +67,23 @@ const call = (id, name, args) => ({ jsonrpc: '2.0', method: 'tools/call', id, pa
65
67
  const list = (id) => ({ jsonrpc: '2.0', method: 'tools/list', id });
66
68
  // Runs `init`, `notifications/initialized`, then `msgs`; returns the tool responses.
67
69
  const session = (...msgs) => runSessionVirtual({}, '/home/user')([init, initialized, ...msgs]).slice(2);
70
+ // UTF-8 bytes of `s` as a plain array — the virtual stdin byte stream.
71
+ const toBytes = (s) => [...fromVec(utf8(s))];
72
+ // Runs `init`, `notifications/initialized`, then `msgs` through the *real*
73
+ // stdio pipeline — `mcpStep` wrapped in `stdioTransport`, driving
74
+ // `writeResponse`'s `tryUtf8` encode over actual stdin/stdout bytes. Unlike
75
+ // `session`/`runSessionVirtual`, which collect `mcpStep`'s `Response` values
76
+ // as plain JS objects and never serialize/encode them, this is the only
77
+ // helper that can observe the transport's oversized-response fallback (see
78
+ // `fs/mcp/stdio/module.f.ts` `writeResponse`).
79
+ const runStdio = (root, home = '/home/user') => (msgs) => {
80
+ const input = [init, initialized, ...msgs].map(m => JSON.stringify(m)).join('\n') + '\n';
81
+ const effect = create(uninitializedState).step(sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home))(sessionKey)));
82
+ const stdout = virtual({ ...emptyState, root, stdin: toBytes(input) })(effect)[0].stdout;
83
+ // Only requests get a written line (notifications, like `initialized`,
84
+ // write nothing) — drop the `init` response, keep one line per `msgs` entry.
85
+ return stdout.split('\n').filter(line => line.length > 0).slice(1).map(line => JSON.parse(line));
86
+ };
68
87
  const resultOf = (resp) => resp.result;
69
88
  const item0 = (resp) => resultOf(resp).content[0];
70
89
  const textOf = (resp) => item0(resp).text;
@@ -75,6 +94,22 @@ const binarySample = base64Encode(vec8(0x2an));
75
94
  // A base64 blob whose leading bytes are the PNG magic-byte signature, so
76
95
  // `cas_get` detects its type and returns base64 with mime_type image/png.
77
96
  const pngSample = base64Encode(u8ListToVec(msb)([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]));
97
+ // Returns the RFC 4648 base64 encoding of `n` zero bytes, computed directly
98
+ // without bigint arithmetic — safe near maxLengthBytes where base64Encode on a
99
+ // padded Vec would trigger the encode-padding-overflow bug. Handles every n%3
100
+ // residue: rem=0 → no padding, rem=1 → 'AA==', rem=2 → 'AAA='.
101
+ const base64OfA = (n) => {
102
+ const groups = Number(n / 3n);
103
+ const rem = Number(n % 3n);
104
+ const body = 'AAAA'.repeat(groups);
105
+ if (rem === 0) {
106
+ return body;
107
+ }
108
+ if (rem === 1) {
109
+ return body + 'AA==';
110
+ }
111
+ return body + 'AAA=';
112
+ };
78
113
  // ── Tests ───────────────────────────────────────────────────────────────────────
79
114
  // A blob larger than one read chunk (> 128 KiB) used to fail metadata-only
80
115
  // cas_get because `collectRead` overflowed `maxLength`. With `detectStream`,
@@ -107,6 +142,22 @@ const asciiChunk = repeat(maxLengthBytes)(vec8(0x61n));
107
142
  const symbolChunk = repeat(maxLengthBytes / 2n)(u8ListToVec(msb)([0xc3, 0xa9]));
108
143
  // A full chunk of 0xFF — an invalid UTF-8 lead byte, so binary (no magic match).
109
144
  const binaryChunk = repeat(maxLengthBytes)(vec8(0xffn));
145
+ // 100,000 raw bytes of 0xFF: comfortably under `maxLengthBytes` (so `cas_get`'s
146
+ // own `length > maxLengthBytes` guard does not fire), but base64 inflates it to
147
+ // ceil(100_000/3)*4 = 133,336 chars — already past `maxLength` (131,072 bytes)
148
+ // before the JSON-RPC envelope adds anything. Small enough to stay clear of the
149
+ // separate `base64Encode`-near-`maxLength` padding bug (see `base64OfA` above).
150
+ const oversizedBase64Chunk = repeat(100000n)(vec8(0xffn));
151
+ // 90,000 raw bytes: base64 inflates to 120,000 chars, leaving ~11 KiB of margin
152
+ // under `maxLength` once the envelope is added — the paired "still succeeds" case.
153
+ const boundaryBase64Chunk = repeat(90000n)(vec8(0xffn));
154
+ // 33,000 `"` bytes (0x22): valid UTF-8, so `cas_get` classifies it as text and
155
+ // takes the double-JSON-escaping path (`toJson` builds the inner CAS JSON, then
156
+ // the outer `stringifyJson` embeds that as the `text` field). Each quote costs
157
+ // 4 final bytes (2 on each escaping pass), so 33,000 of them alone push the
158
+ // encoded line past `maxLength` (131,072 bytes) — well above the strict 32,769
159
+ // minimum needed, without approaching any other size-related edge case.
160
+ const quotesChunk = repeat(33000n)(vec8(0x22n));
110
161
  export const proof = {
111
162
  // Both chunks repeated ASCII → text/plain, no error on a > 128 KiB blob.
112
163
  getMetaLargeMultiChunkBlobNoError: largeMultiChunkBlobMeta(asciiChunk, asciiChunk, 'text', 'text/plain'),
@@ -115,6 +166,103 @@ export const proof = {
115
166
  // First chunk valid UTF-8, second chunk binary → base64/octet-stream. The
116
167
  // streaming validator must see the trailing binary chunk to reject text.
117
168
  getMetaLargeMultiChunkTextThenBinary: largeMultiChunkBlobMeta(asciiChunk, binaryChunk, 'base64', 'application/octet-stream'),
169
+ // content:true on a present blob larger than `maxLength` reports a distinct
170
+ // "too large" error, not "no such hash" — the blob exists, it just can't be
171
+ // buffered inline. The metadata-only path (above) still returns its size/type.
172
+ getContentLargeBlobTooLargeError: () => {
173
+ const root = { 'home': { 'user': { 'cas_upload': { 'big': [asciiChunk, asciiChunk] } } } };
174
+ const [addResp] = runSessionVirtual(root)([
175
+ init, initialized,
176
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
177
+ ]).slice(2);
178
+ const hash = textOf(addResp);
179
+ const [, getResp] = runSessionVirtual(root)([
180
+ init, initialized,
181
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
182
+ call(3, 'cas_get', { hash, content: true }),
183
+ ]).slice(2);
184
+ assertEq(resultOf(getResp).isError, true);
185
+ const text = textOf(getResp);
186
+ assert(text.includes('too large'));
187
+ assert(!text.includes('no such hash'));
188
+ },
189
+ // content:true on a genuinely absent hash still reports "no such hash" — the
190
+ // oversized branch above must not absorb the absent case.
191
+ getContentMissingHashIsError: () => {
192
+ const absent = vecToCBase32(vec8(0x55n));
193
+ const [resp] = session(call(2, 'cas_get', { hash: absent, content: true }));
194
+ assertEq(resultOf(resp).isError, true);
195
+ assert(textOf(resp).includes('no such hash'));
196
+ },
197
+ // A blob at exactly `maxLengthBytes` passes cas_get's own raw-length guard
198
+ // (it checks the *stored* size, not the encoded response), but base64
199
+ // inflation of a 100,000-byte blob alone already exceeds `maxLength` before
200
+ // the JSON-RPC envelope is even added. The transport's `writeResponse`
201
+ // (`tryUtf8`) must catch this and write a JSON-RPC internal-error response —
202
+ // carrying the *original request's* `id` — instead of crashing the process.
203
+ // See `fs/mcp/stdio/module.f.ts` `writeResponse`.
204
+ //
205
+ // This test originally timed out under `bun test`'s native 5s per-test
206
+ // limit (12-14s observed in CI on PR #1201) — the cost was in
207
+ // `base64Encode`, quadratic before the `baseN.vecToString` fix (see
208
+ // `fs/base64/proof.f.ts` `encodeLargeVecIsSlow`). Now well under budget on
209
+ // both engines.
210
+ getContentBase64InflationOverflowWritesInternalError: () => {
211
+ const root = { 'home': { 'user': { 'cas_upload': { 'big': [oversizedBase64Chunk] } } } };
212
+ const [addResp] = runStdio(root)([
213
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
214
+ ]);
215
+ const hash = textOf(addResp);
216
+ const [, getResp] = runStdio(root)([
217
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
218
+ call(3, 'cas_get', { hash, content: true }),
219
+ ]);
220
+ const err = getResp;
221
+ assertEq(err.error?.code, -32603);
222
+ assertEq(err.id, 3);
223
+ },
224
+ // The paired boundary case: a blob whose base64 inflation leaves enough
225
+ // margin under `maxLength` for the envelope — confirms the fallback above
226
+ // only triggers on genuine overflow, not on every `content:true` binary read.
227
+ getContentBase64NearBoundarySucceeds: () => {
228
+ const root = { 'home': { 'user': { 'cas_upload': { 'big': [boundaryBase64Chunk] } } } };
229
+ const [addResp] = runStdio(root)([
230
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
231
+ ]);
232
+ const hash = textOf(addResp);
233
+ const [, getResp] = runStdio(root)([
234
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
235
+ call(3, 'cas_get', { hash, content: true }),
236
+ ]);
237
+ assert(!resultOf(getResp).isError);
238
+ const result = JSON.parse(textOf(getResp));
239
+ assertEq(result.type, 'base64');
240
+ assertEq(result.length, 90_000);
241
+ },
242
+ // A text blob made entirely of `"` characters keeps the *raw* content
243
+ // (33,000 bytes) well under `maxLengthBytes`, but each quote costs 2 bytes on
244
+ // the inner CAS-JSON escape and 4 bytes once the outer JSON-RPC envelope
245
+ // re-escapes that backslash-quote pair — no `cas_get`-level size guard could
246
+ // see this coming, only the transport's real encode attempt can. Uploaded via
247
+ // `type: 'url'` (a file, not an inlined JSON string) so the test exercises
248
+ // `cas_get`'s response-side escaping without also paying for parsing a huge
249
+ // inline `content` argument on the request side. Confirms `writeResponse`'s
250
+ // `tryUtf8` fallback (not a crash) handles double-escaping overflow, again
251
+ // preserving the request's `id`.
252
+ getContentDoubleEscapedOverflowWritesInternalError: () => {
253
+ const root = { 'home': { 'user': { 'cas_upload': { 'q': [quotesChunk] } } } };
254
+ const [addResp] = runStdio(root)([
255
+ call(2, 'cas_add', { content: '/home/user/cas_upload/q', type: 'url' }),
256
+ ]);
257
+ const hash = textOf(addResp);
258
+ const [, getResp] = runStdio(root)([
259
+ call(2, 'cas_add', { content: '/home/user/cas_upload/q', type: 'url' }),
260
+ call(3, 'cas_get', { hash, content: true }),
261
+ ]);
262
+ const err = getResp;
263
+ assertEq(err.error?.code, -32603);
264
+ assertEq(err.id, 3);
265
+ },
118
266
  toolsListAdvertisesThreeTools: () => {
119
267
  const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
120
268
  const tools = resp.result.tools;
@@ -216,6 +364,37 @@ export const proof = {
216
364
  const [resp] = session(call(2, 'cas_add', { content: 'A', type: 'base64' }));
217
365
  assertEq(resultOf(resp).isError, true);
218
366
  },
367
+ // 174_764 'A' chars × 6 bits = 1_048_584 bits, 8 over maxLength — base64Decode
368
+ // returns null and cas_add surfaces an error.
369
+ addBase64OverLimitIsError: () => {
370
+ const [resp] = session(call(2, 'cas_add', { content: 'A'.repeat(174_764), type: 'base64' }));
371
+ assertEq(resultOf(resp).isError, true);
372
+ assert(textOf(resp).includes('too large or malformed'));
373
+ },
374
+ // One ASCII byte past maxLengthBytes — tryUtf8 returns null and cas_add
375
+ // surfaces an error.
376
+ addTextOverLimitIsError: () => {
377
+ const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes) + 1) }));
378
+ assertEq(resultOf(resp).isError, true);
379
+ assert(textOf(resp).includes('too large or malformed'));
380
+ },
381
+ // Exactly maxLengthBytes (131,072) ASCII bytes — tryUtf8 returns a non-null
382
+ // Vec (len === maxLength, not over), so cas_add stores cleanly.
383
+ addTextAtLimitSucceeds: () => {
384
+ const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes)) }));
385
+ assert(!resultOf(resp).isError);
386
+ assert(textOf(resp).length > 0);
387
+ },
388
+ // base64 of exactly maxLengthBytes — currently returns isError because
389
+ // base64Decode measures the raw pre-trim body (1_048_578 bits) against
390
+ // maxLength before stripping the 2 padding bits that would land it exactly at
391
+ // maxLength. Assert the current failing behavior; flip to a success assertion
392
+ // once `fs/base64/todo/decode-rejects-max-size-input.md` is fixed.
393
+ addBase64AtLimitIsError: () => {
394
+ const [resp] = session(call(2, 'cas_add', { content: base64OfA(maxLengthBytes), type: 'base64' }));
395
+ assertEq(resultOf(resp).isError, true);
396
+ assert(textOf(resp).includes('too large or malformed'));
397
+ },
219
398
  getUnterminatedHashIsError: () => {
220
399
  const [resp] = session(call(2, 'cas_get', { hash: '0' }));
221
400
  assertEq(resultOf(resp).isError, true);
@@ -18,6 +18,14 @@
18
18
  * `null`) is written rather than silently discarded, per JSON-RPC 2.0 §5.
19
19
  * - the step yields `null` (a notification needing no reply) → nothing is
20
20
  * written and the loop continues.
21
+ * - a response that doesn't fit in one encoded line (`tryUtf8` overflow —
22
+ * `maxLength`, 128 KiB) never throws. `writeResponse` retries with a fixed,
23
+ * small `-32603` internal-error body carrying the original `id`; if even
24
+ * that overflows (a pathological caller-controlled `id`, e.g. a very large
25
+ * string), it retries once more with `id: null` — a fully constant response
26
+ * shape that is always small enough to encode. Every request that reaches
27
+ * `step` therefore gets *some* response line, never silence and never a
28
+ * crashed process.
21
29
  *
22
30
  * @module
23
31
  */
@@ -18,25 +18,39 @@
18
18
  * `null`) is written rather than silently discarded, per JSON-RPC 2.0 §5.
19
19
  * - the step yields `null` (a notification needing no reply) → nothing is
20
20
  * written and the loop continues.
21
+ * - a response that doesn't fit in one encoded line (`tryUtf8` overflow —
22
+ * `maxLength`, 128 KiB) never throws. `writeResponse` retries with a fixed,
23
+ * small `-32603` internal-error body carrying the original `id`; if even
24
+ * that overflows (a pathological caller-controlled `id`, e.g. a very large
25
+ * string), it retries once more with `id: null` — a fully constant response
26
+ * shape that is always small enough to encode. Every request that reaches
27
+ * `step` therefore gets *some* response line, never silence and never a
28
+ * crashed process.
21
29
  *
22
30
  * @module
23
31
  */
24
32
  import { pure } from "../../effects/module.f.js";
25
33
  import { readLine, write } from "../../effects/node/module.f.js";
26
- import { utf8 } from "../../text/module.f.js";
34
+ import { tryUtf8 } from "../../text/module.f.js";
27
35
  import { stringToList } from "../../text/utf16/module.f.js";
28
36
  import { stringify } from "../../json/module.f.js";
29
37
  import { tokenize } from "../../json/tokenizer/module.f.js";
30
38
  import { parse } from "../../json/parser/module.f.js";
31
39
  import { sort } from "../../types/object/module.f.js";
32
- import { jsonrpc, parseError } from "../../json/rpc/module.f.js";
40
+ import { internalError, jsonrpc, parseError } from "../../json/rpc/module.f.js";
41
+ import { error, ok } from "../../types/result/module.f.js";
33
42
  const stringifyJson = stringify(sort);
34
43
  /** The parse-error response (`-32700`, `id: null`) for a malformed input line. */
35
44
  const parseErrorResponse = { jsonrpc, error: parseError, id: null };
45
+ /** An internal-error response (`-32603`) carrying `id`. */
46
+ const internalErrorResponse = (id) => ({ jsonrpc, error: internalError, id });
36
47
  /** Encodes a response as a newline-terminated UTF-8 line and writes it to `stdout`. */
37
- const writeResponse = (resp) => write('stdout', utf8(stringifyJson(resp) + '\n'));
38
- /** Writes `resp` when present, otherwise does nothing (notification). */
39
- const writeMaybe = (resp) => resp === null ? pure(undefined) : writeResponse(resp);
48
+ const writeResponse = (resp) => {
49
+ const v = tryUtf8(stringifyJson(resp) + '\n');
50
+ return v === null
51
+ ? pure(error(undefined))
52
+ : write('stdout', v).step(() => pure(ok(undefined)));
53
+ };
40
54
  /**
41
55
  * Drives the read-parse-dispatch-write loop for `step` over stdin/stdout.
42
56
  *
@@ -50,5 +64,18 @@ const handleLine = (step) => (line) => {
50
64
  const [t, value] = parse(tokenize(stringToList(line)));
51
65
  return (t === 'error'
52
66
  ? writeResponse(parseErrorResponse)
53
- : step(value).step(writeMaybe)).step(() => stdioTransport(step));
67
+ : step(value).step(resp => resp === null
68
+ ? pure(undefined)
69
+ : writeResponse(resp).step(([t2]) => t2 === 'error'
70
+ // The real response didn't fit. Retry with a fixed, small
71
+ // internal-error body carrying `resp.id` — but a
72
+ // caller-controlled `id` (e.g. a very large string) can
73
+ // itself push even this fallback over `maxLength`, so
74
+ // that retry is bounded by one more: an `id: null`
75
+ // internal-error, whose fully-constant shape is the only
76
+ // line in this transport guaranteed to always encode.
77
+ ? writeResponse(internalErrorResponse(resp.id)).step(([t3]) => t3 === 'error'
78
+ ? writeResponse(internalErrorResponse(null)).step(() => pure(undefined))
79
+ : pure(undefined))
80
+ : pure(undefined)))).step(() => stdioTransport(step));
54
81
  };
@@ -6,5 +6,8 @@ export declare const proof: {
6
6
  malformedJsonWritesParseError: () => void;
7
7
  trailingCommaWritesParseError: () => void;
8
8
  undefinedFieldOmitted: () => void;
9
+ oversizedResponseWritesInternalError: () => void;
10
+ loopContinuesAfterOversizedResponse: () => void;
11
+ oversizedIdFallsBackToNullId: () => void;
9
12
  multipleLines: () => void;
10
13
  };
@@ -4,8 +4,9 @@ import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
4
4
  import { stringify } from "../../json/module.f.js";
5
5
  import { utf8 } from "../../text/module.f.js";
6
6
  import { fromVec } from "../../types/uint8array/module.f.js";
7
+ import { maxLengthBytes } from "../../types/bit_vec/module.f.js";
7
8
  import { sort } from "../../types/object/module.f.js";
8
- import { jsonrpc, parseError } from "../../json/rpc/module.f.js";
9
+ import { internalError, jsonrpc, parseError } from "../../json/rpc/module.f.js";
9
10
  import { stdioTransport } from "./module.f.js";
10
11
  const stringifyJson = stringify(sort);
11
12
  // Extracts the request `id` (a request has one; a notification does not).
@@ -29,7 +30,11 @@ const runStep = (step) => (input) => virtual({ ...emptyState, stdin: toBytes(inp
29
30
  const run = runStep(echoStep);
30
31
  const okResponse = (id) => stringifyJson({ jsonrpc, result: { ok: true }, id }) + '\n';
31
32
  const parseErrorLine = stringifyJson({ jsonrpc, error: parseError, id: null }) + '\n';
33
+ const internalErrorLine = (id) => stringifyJson({ jsonrpc, error: internalError, id }) + '\n';
32
34
  const ping = (id) => `{"jsonrpc":"2.0","method":"ping","id":${id}}`;
35
+ // One byte past `maxLengthBytes` on its own; embedded in a response envelope
36
+ // it stays comfortably over the limit despite the surrounding JSON overhead.
37
+ const oversizedString = 'a'.repeat(Number(maxLengthBytes) + 1);
33
38
  const notification = '{"jsonrpc":"2.0","method":"notifications/initialized"}';
34
39
  export const proof = {
35
40
  // EOF on the very first read: clean shutdown, nothing written, no further reads.
@@ -81,6 +86,52 @@ export const proof = {
81
86
  const state = runStep(step)(ping(1) + '\n');
82
87
  assertEq(state.stdout, okResponse(1));
83
88
  },
89
+ // A response that would exceed `maxLengthBytes` once UTF-8 encoded cannot
90
+ // be written as a single bit vector (`tryUtf8` reports overflow); the loop
91
+ // writes a JSON-RPC internal-error response — carrying the original
92
+ // request's `id`, not `null` — instead of throwing or silently dropping
93
+ // the reply.
94
+ oversizedResponseWritesInternalError: () => {
95
+ const step = (value) => {
96
+ const id = idOf(value);
97
+ return pure(id === undefined
98
+ ? null
99
+ : { jsonrpc, result: { big: oversizedString }, id });
100
+ };
101
+ const state = runStep(step)(ping(1) + '\n');
102
+ assertEq(state.stdout, internalErrorLine(1));
103
+ },
104
+ // The loop recovers from the oversized-response error and keeps draining
105
+ // stdin: a well-behaved request on the next line still gets its normal
106
+ // reply.
107
+ loopContinuesAfterOversizedResponse: () => {
108
+ const step = (value) => {
109
+ const id = idOf(value);
110
+ return pure(id === undefined
111
+ ? null
112
+ : id === 1
113
+ ? { jsonrpc, result: { big: oversizedString }, id }
114
+ : { jsonrpc, result: { ok: true }, id });
115
+ };
116
+ const state = runStep(step)([ping(1), ping(2)].join('\n'));
117
+ assertEq(state.stdout, internalErrorLine(1) + okResponse(2));
118
+ assertEq(state.stdin.length, 0);
119
+ },
120
+ // When even the `id`-preserving internal-error fallback would overflow —
121
+ // because the `id` itself is the oversized part, not just `result` — the
122
+ // loop falls back once more to a fixed `id: null` internal-error, the only
123
+ // shape in this transport guaranteed to always fit. Without this second
124
+ // fallback tier the request would get no response line at all.
125
+ oversizedIdFallsBackToNullId: () => {
126
+ const step = (value) => {
127
+ const id = idOf(value);
128
+ return pure(id === undefined
129
+ ? null
130
+ : { jsonrpc, result: { ok: true }, id: oversizedString });
131
+ };
132
+ const state = runStep(step)(ping(1) + '\n');
133
+ assertEq(state.stdout, internalErrorLine(null));
134
+ },
84
135
  // A multi-line session interleaving all cases: request, notification, and
85
136
  // malformed line, ending with an unterminated request. Order is preserved
86
137
  // and the notification contributes nothing.
@@ -7,19 +7,29 @@
7
7
  */
8
8
  import { type Vec } from '../types/bit_vec/module.f.ts';
9
9
  import { type List } from '../types/list/module.f.ts';
10
+ import { type Nullable } from '../types/nullable/module.f.ts';
10
11
  export type Block = ItemThunk | ItemArray;
11
12
  type ItemArray = readonly Item[];
12
13
  type ItemThunk = () => List<Item>;
13
14
  export type Item = string | ItemArray | ItemThunk;
14
15
  export declare const flat: (indent: string) => (text: Block) => List<string>;
15
16
  export type Utf8 = Vec;
17
+ /**
18
+ * Converts a string to an UTF-8, represented as an MSB first bit vector,
19
+ * returning `null` instead of throwing if the result would exceed
20
+ * `maxLength`.
21
+ *
22
+ * @param s The input string to be converted.
23
+ * @returns The resulting UTF-8 bit vector, MSB first, or `null` on overflow.
24
+ */
25
+ export declare const tryUtf8: (s: string) => Nullable<Utf8>;
16
26
  /**
17
27
  * Converts a string to an UTF-8, represented as an MSB first bit vector.
18
28
  *
19
29
  * @param s The input string to be converted.
20
30
  * @returns The resulting UTF-8 bit vector, MSB first.
21
31
  */
22
- export declare const utf8: (s: string) => Utf8;
32
+ export declare const utf8: import("../types/function/module.f.ts").Func<string, Vec>;
23
33
  /**
24
34
  * Converts a UTF-8 bit vector with MSB first encoding to a string.
25
35
  *
@@ -5,10 +5,11 @@
5
5
  *
6
6
  * @module
7
7
  */
8
- import { msb, u8List, u8ListToVec } from "../types/bit_vec/module.f.js";
8
+ import { msb, tryU8ListToVec, u8List } from "../types/bit_vec/module.f.js";
9
9
  import { flatMap } from "../types/list/module.f.js";
10
10
  import { fromCodePointList, toCodePointList } from "./utf8/module.f.js";
11
11
  import { stringToCodePointList, codePointListToString } from "./utf16/module.f.js";
12
+ import { mapUnwrap } from "../types/nullable/module.f.js";
12
13
  export const flat = (indent) => {
13
14
  const f = (prefix) => {
14
15
  const g = (item) => typeof (item) === 'string' ? [`${prefix}${item}`] : f(`${prefix}${indent}`)(item);
@@ -16,14 +17,23 @@ export const flat = (indent) => {
16
17
  };
17
18
  return f('');
18
19
  };
19
- const u8ListToVecMsb = u8ListToVec(msb);
20
+ const tryU8ListToVecMsb = tryU8ListToVec(msb);
21
+ /**
22
+ * Converts a string to an UTF-8, represented as an MSB first bit vector,
23
+ * returning `null` instead of throwing if the result would exceed
24
+ * `maxLength`.
25
+ *
26
+ * @param s The input string to be converted.
27
+ * @returns The resulting UTF-8 bit vector, MSB first, or `null` on overflow.
28
+ */
29
+ export const tryUtf8 = (s) => tryU8ListToVecMsb(fromCodePointList(stringToCodePointList(s)));
20
30
  /**
21
31
  * Converts a string to an UTF-8, represented as an MSB first bit vector.
22
32
  *
23
33
  * @param s The input string to be converted.
24
34
  * @returns The resulting UTF-8 bit vector, MSB first.
25
35
  */
26
- export const utf8 = (s) => u8ListToVecMsb(fromCodePointList(stringToCodePointList(s)));
36
+ export const utf8 = mapUnwrap(tryUtf8);
27
37
  /**
28
38
  * Converts a UTF-8 bit vector with MSB first encoding to a string.
29
39
  *
@@ -1,4 +1,10 @@
1
1
  export declare const proof: {
2
2
  block: () => void;
3
3
  encoding: () => void;
4
+ tryUtf8RoundTrip: () => void;
5
+ tryUtf8Empty: () => void;
6
+ tryUtf8Overflow: {
7
+ try: () => void;
8
+ throw: () => void;
9
+ };
4
10
  };
@@ -1,5 +1,8 @@
1
- import { flat, utf8, utf8ToString } from "./module.f.js";
1
+ import { assert, assertEq } from "../asserts/module.f.js";
2
+ import { flat, utf8, utf8ToString, tryUtf8 } from "./module.f.js";
2
3
  import { join } from "../types/string/module.f.js";
4
+ import { empty, maxLengthBytes } from "../types/bit_vec/module.f.js";
5
+ const overflowStr = 'a'.repeat(Number(maxLengthBytes) + 1);
3
6
  export const proof = {
4
7
  block: () => {
5
8
  const text = [
@@ -22,5 +25,25 @@ export const proof = {
22
25
  if (r !== 'Hello world!') {
23
26
  throw r;
24
27
  }
25
- }
28
+ },
29
+ tryUtf8RoundTrip: () => {
30
+ const v = tryUtf8('Hello world!');
31
+ assert(v !== null);
32
+ assertEq(v, utf8('Hello world!'));
33
+ assertEq(utf8ToString(v), 'Hello world!');
34
+ },
35
+ tryUtf8Empty: () => {
36
+ assertEq(tryUtf8(''), empty);
37
+ },
38
+ tryUtf8Overflow: {
39
+ // One byte past `maxLengthBytes`; `tryUtf8` should report `null`
40
+ // instead of building an oversized `bigint`, and the throwing `utf8`
41
+ // wrapper should raise on the same input.
42
+ try: () => {
43
+ assertEq(tryUtf8(overflowStr), null);
44
+ },
45
+ throw: () => {
46
+ utf8(overflowStr);
47
+ },
48
+ },
26
49
  };
@@ -26,6 +26,7 @@ import type { Binary, Fold, Reduce as OpReduce } from '../function/operator/modu
26
26
  import { type List, type Thunk } from '../list/module.f.ts';
27
27
  import { type Nominal } from '../nominal/module.f.ts';
28
28
  import { type Sign } from '../function/compare/module.f.ts';
29
+ import { type Nullable } from '../nullable/module.f.ts';
29
30
  /**
30
31
  * A vector of bits represented as a signed `bigint`.
31
32
  */
@@ -195,6 +196,12 @@ export type BitOrder = {
195
196
  * ```
196
197
  */
197
198
  readonly concat: Reduce;
199
+ /**
200
+ * Folds a list of vectors into a single vector in this bit order, like
201
+ * `listToVec`, but returns `null` instead of throwing when the combined
202
+ * length would exceed `maxLength`.
203
+ */
204
+ readonly tryListToVec: (list: List<Vec>) => Nullable<Vec>;
198
205
  /**
199
206
  * Folds a list of vectors into a single vector in this bit order.
200
207
  *
@@ -221,9 +228,10 @@ export type BitOrder = {
221
228
  */
222
229
  readonly cmp: (a: Vec) => (b: Vec) => Sign;
223
230
  readonly unpackSplit: (len: bigint) => (u: Unpacked) => readonly [bigint, bigint];
224
- readonly unpackConcat: (a: Unpacked) => (b: Unpacked) => Unpacked;
231
+ readonly unpackConcat: UnpackConcat;
225
232
  readonly startsWith: (prefix: Vec) => (v: Vec) => boolean;
226
233
  };
234
+ type UnpackConcat = (a: Unpacked) => (b: Unpacked) => Unpacked;
227
235
  /**
228
236
  * Implements operations for handling vectors in a least-significant-bit (LSb) first order.
229
237
  *
@@ -240,6 +248,16 @@ export declare const lsb: BitOrder;
240
248
  * Usually associated with Big-Endian (BE) byte order.
241
249
  */
242
250
  export declare const msb: BitOrder;
251
+ /**
252
+ * Converts a list of unsigned 8-bit integers to a bit vector using the provided
253
+ * bit order, like `u8ListToVec`, but returns `null` instead of throwing when the
254
+ * result would exceed `maxLength`.
255
+ *
256
+ * @param bo The bit order for the conversion
257
+ * @param list The list of unsigned 8-bit integers to be converted.
258
+ * @returns The resulting vector, or `null` if it would exceed `maxLength`.
259
+ */
260
+ export declare const tryU8ListToVec: ({ unpackConcat }: BitOrder) => (list: List<number>) => Nullable<Vec>;
243
261
  /**
244
262
  * Converts a list of unsigned 8-bit integers to a bit vector using the provided bit order.
245
263
  *
@@ -247,7 +265,7 @@ export declare const msb: BitOrder;
247
265
  * @param list The list of unsigned 8-bit integers to be converted.
248
266
  * @returns The resulting vector based on the provided bit order.
249
267
  */
250
- export declare const u8ListToVec: ({ unpackConcat }: BitOrder) => (list: List<number>) => Vec;
268
+ export declare const u8ListToVec: (bo: BitOrder) => import("../function/module.f.ts").Func<List<number>, Vec>;
251
269
  /**
252
270
  * Chunks an unpacked vector into fixed-size pieces of `n` bits using the provided bit order,
253
271
  * returning each chunk as an unsigned integer.