functionalscript 0.35.0 → 0.35.2

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.
@@ -9,4 +9,5 @@ export declare const proof: {
9
9
  validPadding: () => void;
10
10
  knownVectors: () => void;
11
11
  decodeOverflow: () => void;
12
+ encodeLargeVecIsSlow: () => void;
12
13
  };
@@ -1,5 +1,5 @@
1
1
  import { assertEq } from "../asserts/module.f.js";
2
- import { empty, vec } from "../types/bit_vec/module.f.js";
2
+ import { empty, vec, repeat, vec8 } from "../types/bit_vec/module.f.js";
3
3
  import { encode, decode } from "./module.f.js";
4
4
  const check = (s, v) => {
5
5
  assertEq(encode(v), s);
@@ -83,4 +83,28 @@ export const proof = {
83
83
  const oversized = 'A'.repeat(174_764);
84
84
  assertEq(decode(oversized), null);
85
85
  },
86
+ // Regression guard: `encode` used to be quadratic in the input size, not
87
+ // linear. `baseN`'s `vecToString` (`fs/base_n/module.f.ts`) popped one
88
+ // 6-bit chunk off the front at a time via `popFront`, and each `popFront`
89
+ // re-masked the entire remaining bigint through `vec()`'s `m & ui` —
90
+ // O(remaining length) — so encoding a vector of `n` bits cost O(n²). A
91
+ // 90,000-byte input took ~5.6s on Node and ~18.4s on Bun (Bun's `bigint`
92
+ // has a much higher per-operation constant, see PR #1190), reliably
93
+ // blowing `bun test`'s 5s per-test timeout — the same class of failure
94
+ // that hit `cas_get content:true` on a comparably-sized binary blob in CI
95
+ // (PR #1201).
96
+ //
97
+ // Fixed by rewriting `vecToString` as a balanced recursive split
98
+ // (`unpackToString`), mirroring the `chunkList`/`unpackChunkList`
99
+ // divide-and-conquer already used by `u8List` and by concatenation
100
+ // (`msb.concat` / `unpackListToVec`, PR #1192) — true O(n log n) now,
101
+ // since each half is masked to its own length before recursing. The same
102
+ // call now takes ~50ms on Node and ~225ms on Bun. No timing assertion
103
+ // (duration varies by engine/machine) — this just exercises the call and
104
+ // relies on the test runner's own per-test timing to catch a regression.
105
+ encodeLargeVecIsSlow: () => {
106
+ const big = repeat(90000n)(vec8(0xffn));
107
+ const result = encode(big);
108
+ assertEq(result?.length, 120_000);
109
+ },
86
110
  };
@@ -18,9 +18,10 @@ import type { Nullable } from '../types/nullable/module.f.ts';
18
18
  */
19
19
  export type BaseN = {
20
20
  /**
21
- * Encodes a bit vector by repeatedly popping `bits`-wide MSB chunks and
22
- * indexing them into the alphabet. A trailing partial chunk shorter than
23
- * `bits` is left-padded with zeros (the underlying `popFront` semantics).
21
+ * Encodes a bit vector by splitting it into `bits`-wide MSB chunks
22
+ * (`chunkList`, a balanced divide-and-conquer split, not a linear scan)
23
+ * and indexing each into the alphabet. A trailing partial chunk shorter
24
+ * than `bits` is left-padded with zeros.
24
25
  */
25
26
  readonly vecToString: (v: Vec) => string;
26
27
  /**
@@ -11,10 +11,14 @@
11
11
  *
12
12
  * @module
13
13
  */
14
- import { msb, lsb, length, vec } from "../types/bit_vec/module.f.js";
15
- import {} from "../types/list/module.f.js";
16
- const { popFront } = msb;
14
+ import { msb, lsb, vec, chunkList, unpack } from "../types/bit_vec/module.f.js";
15
+ import { fold } from "../types/list/module.f.js";
16
+ import { compose } from "../types/function/module.f.js";
17
+ const { unpackSplit } = msb;
17
18
  const { tryListToVec: reversedListToVec } = lsb;
19
+ // `chunkList(msb)` doesn't depend on `bits` or `v` — shared across every
20
+ // `baseN(...)` codec (base64, cbase32, ...).
21
+ const chunkListMsb = chunkList(msb);
18
22
  /**
19
23
  * Builds a {@link BaseN} codec for a fixed chunk width and alphabet.
20
24
  *
@@ -26,21 +30,29 @@ const { tryListToVec: reversedListToVec } = lsb;
26
30
  * `i`/`l`→`1`, `o`→`0`.
27
31
  */
28
32
  export const baseN = (bits, alphabet, normalize) => {
29
- const popFrontN = popFront(bits);
30
33
  const vecN = vec(bits);
31
34
  const toIndex = normalize === undefined
32
35
  ? (c) => alphabet.indexOf(c)
33
36
  : (c) => alphabet.indexOf(normalize(c));
37
+ const unpackSplitBits = unpackSplit(bits);
38
+ // Converts one `<= bits`-wide chunk (as yielded by `chunkList`, already
39
+ // masked to its own length) to its alphabet index. A trailing partial
40
+ // chunk shorter than `bits` is left-padded with zeros: `unpackSplit`'s
41
+ // shift amount goes negative, which per spec becomes a left shift.
42
+ const chunkToIndex = (chunk) => {
43
+ const u = unpack(chunk);
44
+ return Number(u.length < bits ? unpackSplitBits(u)[0] : u.uint);
45
+ };
46
+ // Folds directly over `chunkList`'s lazy list in one pass — faster than
47
+ // `map` into a second lazy list before joining, since there's no second
48
+ // list to allocate/traverse.
49
+ const chunkToString = (chunk) => (acc) => acc + alphabet[chunkToIndex(chunk)];
34
50
  return {
35
- vecToString: v => {
36
- let result = '';
37
- while (length(v) > 0n) {
38
- const [r, rest] = popFrontN(v);
39
- result += alphabet[Number(r)];
40
- v = rest;
41
- }
42
- return result;
43
- },
51
+ // `chunkListMsb(bits)` then `fold(chunkToString)('')` — neither half
52
+ // depends on `v`, so `compose` builds (and this closure captures)
53
+ // the composed function once per `baseN(...)` codec; no need to name
54
+ // the halves separately just to get that one-time build.
55
+ vecToString: compose(chunkListMsb(bits))(fold(chunkToString)('')),
44
56
  stringToVec: s => {
45
57
  // Build a reversed chunk list, bailing out at the first invalid
46
58
  // character so malformed input is rejected in O(prefix) time and
@@ -4,6 +4,8 @@ export declare const proof: {
4
4
  getMetaLargeMultiChunkTextThenBinary: () => void;
5
5
  getContentLargeBlobTooLargeError: () => void;
6
6
  getContentMissingHashIsError: () => void;
7
+ getContentBase64InflationOverflowWritesInternalError: () => void;
8
+ getContentBase64NearBoundarySucceeds: () => void;
7
9
  getContentDoubleEscapedOverflowWritesInternalError: () => void;
8
10
  toolsListAdvertisesThreeTools: () => void;
9
11
  addReturnsHash: () => void;
@@ -142,6 +142,15 @@ const asciiChunk = repeat(maxLengthBytes)(vec8(0x61n));
142
142
  const symbolChunk = repeat(maxLengthBytes / 2n)(u8ListToVec(msb)([0xc3, 0xa9]));
143
143
  // A full chunk of 0xFF — an invalid UTF-8 lead byte, so binary (no magic match).
144
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));
145
154
  // 33,000 `"` bytes (0x22): valid UTF-8, so `cas_get` classifies it as text and
146
155
  // takes the double-JSON-escaping path (`toJson` builds the inner CAS JSON, then
147
156
  // the outer `stringifyJson` embeds that as the `text` field). Each quote costs
@@ -185,6 +194,51 @@ export const proof = {
185
194
  assertEq(resultOf(resp).isError, true);
186
195
  assert(textOf(resp).includes('no such hash'));
187
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
+ },
188
242
  // A text blob made entirely of `"` characters keeps the *raw* content
189
243
  // (33,000 bytes) well under `maxLengthBytes`, but each quote costs 2 bytes on
190
244
  // the inner CAS-JSON escape and 4 bytes once the outer JSON-RPC envelope
@@ -1,7 +1,10 @@
1
1
  import { mask } from "../../types/bigint/module.f.js";
2
- import { vec, length, empty, msb } from "../../types/bit_vec/module.f.js";
2
+ import { vec, length, empty, msb, chunkList, uint } from "../../types/bit_vec/module.f.js";
3
3
  import { fold } from "../../types/list/module.f.js";
4
- const { concat, popFront, front } = msb;
4
+ const { concat, front } = msb;
5
+ // `chunkList(msb)` depends on neither `chunkLength` nor `v`/`state` — shared
6
+ // across every `base(...)` config (32-bit and 64-bit SHA-2 variants).
7
+ const chunkListMsb = chunkList(msb);
5
8
  const lastOne = vec(1n)(1n);
6
9
  const base = ({ logBitLen, k, bs0, bs1, ss0, ss1 }) => {
7
10
  const bitLength = 1n << logBitLen;
@@ -109,6 +112,20 @@ const base = ({ logBitLen, k, bs0, bs1, ss0, ss1 }) => {
109
112
  ]);
110
113
  };
111
114
  const chunkLength = bitLength << 4n; // * 16
115
+ // `chunkListMsb(chunkLength)` depends on `chunkLength` but not `v`/`state`
116
+ // — computed once per `base(...)` config, not once per `append` call.
117
+ const chunkListChunkLength = chunkListMsb(chunkLength);
118
+ // Folds one block (or the final, shorter-than-`chunkLength` leftover) into
119
+ // `State`. `chunkList` yields chunks of exactly `chunkLength` bits except
120
+ // possibly the last one, which is why `remainder` only ever holds that
121
+ // last chunk (`empty` otherwise) — same shape as `State` itself, so no
122
+ // separate accumulator type is needed.
123
+ const appendChunk = (chunk) => (state) => length(chunk) === chunkLength
124
+ ? { hash: compress(state.hash)(uint(chunk)), len: state.len + chunkLength, remainder: empty }
125
+ : { ...state, remainder: chunk };
126
+ // `fold(appendChunk)` depends on neither `v` nor `state` — only the
127
+ // starting accumulator passed to it per `append` call does.
128
+ const foldChunks = fold(appendChunk);
112
129
  const fromV8 = (a) => a.reduce((p, v) => (p << bitLength) | v);
113
130
  // See https://www.rfc-editor.org/rfc/rfc6234#section-4
114
131
  const lastChunkLength = chunkLength - 1n - (bitLength << 1n);
@@ -117,23 +134,7 @@ const base = ({ logBitLen, k, bs0, bs1, ss0, ss1 }) => {
117
134
  chunkLength,
118
135
  compress,
119
136
  fromV8,
120
- append: (v) => (state) => {
121
- let { remainder, hash, len } = state;
122
- remainder = concat(remainder)(v);
123
- let remainderLen = length(remainder);
124
- while (remainderLen >= chunkLength) {
125
- const [u, nr] = popFront(chunkLength)(remainder);
126
- hash = compress(hash)(u);
127
- remainder = nr;
128
- remainderLen -= chunkLength;
129
- len += chunkLength;
130
- }
131
- return {
132
- hash,
133
- len,
134
- remainder
135
- };
136
- },
137
+ append: (v) => (state) => foldChunks({ ...state, remainder: empty })(chunkListChunkLength(concat(state.remainder)(v))),
137
138
  end: (hashLength) => {
138
139
  const offset = (bitLength << 3n) - hashLength;
139
140
  const result = vec(hashLength);
@@ -27,4 +27,5 @@ export declare const proof: {
27
27
  padding: {
28
28
  overflow: () => void;
29
29
  };
30
+ appendLargeVecIsFast: () => void;
30
31
  };
@@ -149,4 +149,22 @@ export const proof = {
149
149
  }
150
150
  },
151
151
  },
152
+ // Regression guard (sha2-append-quadratic): `append` used to be
153
+ // quadratic in the input size, not linear — a single call on a
154
+ // 100,000-byte `Vec` used to cost real seconds and scale worse than
155
+ // O(n²); now near-linear (~40-60ms on Node, well under `bun test`'s 5s
156
+ // per-test limit on Bun). No timing assertion (duration varies by
157
+ // engine/machine); relies on the test runner's own per-test timing to
158
+ // catch a regression, same convention as `fs/base64/proof.f.ts`
159
+ // `encodeLargeVecIsSlow`.
160
+ appendLargeVecIsFast: () => {
161
+ const big = repeat(100000n)(vec(8n)(0xffn));
162
+ let state = sha256.init;
163
+ state = sha256.append(big)(state);
164
+ const h = sha256.end(state);
165
+ const x = 0xbe87f6dbe42cdf682276fbecab3636fbfcaa008cf454d635dd77872b50d940aan;
166
+ if (uint(h) !== x) {
167
+ throw h;
168
+ }
169
+ },
152
170
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "functionalscript",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "**/*.js",