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.
- package/fs/base64/proof.f.d.ts +1 -0
- package/fs/base64/proof.f.js +25 -1
- package/fs/base_n/module.f.d.ts +4 -3
- package/fs/base_n/module.f.js +25 -13
- package/fs/cas/mcp/proof.f.d.ts +2 -0
- package/fs/cas/mcp/proof.f.js +54 -0
- package/fs/crypto/sha2/module.f.js +20 -19
- package/fs/crypto/sha2/proof.f.d.ts +1 -0
- package/fs/crypto/sha2/proof.f.js +18 -0
- package/package.json +1 -1
package/fs/base64/proof.f.d.ts
CHANGED
package/fs/base64/proof.f.js
CHANGED
|
@@ -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
|
};
|
package/fs/base_n/module.f.d.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
/**
|
package/fs/base_n/module.f.js
CHANGED
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @module
|
|
13
13
|
*/
|
|
14
|
-
import { msb, lsb,
|
|
15
|
-
import {} from "../types/list/module.f.js";
|
|
16
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
package/fs/cas/mcp/proof.f.d.ts
CHANGED
|
@@ -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;
|
package/fs/cas/mcp/proof.f.js
CHANGED
|
@@ -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,
|
|
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);
|
|
@@ -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
|
};
|