functionalscript 0.39.0 → 0.41.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.
- package/README.md +1 -1
- package/fjs/bnf/descent/module.f.d.ts +43 -2
- package/fjs/bnf/descent/module.f.js +37 -12
- package/fjs/bnf/descent/proof.f.d.ts +1 -0
- package/fjs/bnf/descent/proof.f.js +73 -32
- package/fjs/bnf/ll1/module.f.js +2 -2
- package/fjs/cas/evo/module.f.d.ts +28 -8
- package/fjs/cas/evo/module.f.js +43 -10
- package/fjs/cas/evo/proof.f.d.ts +4 -0
- package/fjs/cas/evo/proof.f.js +82 -1
- package/fjs/ci/config/module.f.d.ts +10 -7
- package/fjs/ci/config/module.f.js +23 -8
- package/fjs/ci/module.f.js +12 -5
- package/fjs/ci/nix/module.f.d.ts +61 -0
- package/fjs/ci/nix/module.f.js +92 -0
- package/fjs/ci/nix/proof.f.d.ts +23 -0
- package/fjs/ci/nix/proof.f.js +109 -0
- package/fjs/ci/node/module.f.d.ts +23 -1
- package/fjs/ci/node/module.f.js +47 -3
- package/fjs/ci/node/proof.f.d.ts +3 -0
- package/fjs/ci/node/proof.f.js +17 -0
- package/fjs/ci/proof.f.d.ts +2 -0
- package/fjs/ci/proof.f.js +47 -9
- package/fjs/dev/module.f.d.ts +1 -0
- package/fjs/dev/module.f.js +13 -2
- package/fjs/dev/update/module.f.d.ts +11 -0
- package/fjs/dev/update/module.f.js +20 -0
- package/fjs/dev/update/proof.f.d.ts +7 -0
- package/fjs/dev/update/proof.f.js +35 -0
- package/fjs/djs/ast/module.f.d.ts +47 -0
- package/fjs/djs/ast/module.f.js +9 -0
- package/fjs/djs/tokenizer/module.f.js +2 -2
- package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
- package/fjs/djs/tokenizer/proof.f.js +70 -16
- package/fjs/effects/module.f.d.ts +20 -0
- package/fjs/effects/module.f.js +25 -1
- package/fjs/effects/node/module.d.ts +3 -3
- package/fjs/effects/node/module.f.d.ts +17 -7
- package/fjs/effects/node/module.f.js +22 -0
- package/fjs/effects/node/module.js +12 -12
- package/fjs/effects/node/proof.f.d.ts +1 -0
- package/fjs/effects/node/proof.f.js +14 -1
- package/fjs/effects/node/virtual/module.f.js +1 -1
- package/fjs/effects/proof.f.d.ts +5 -0
- package/fjs/effects/proof.f.js +22 -0
- package/fjs/emergent_testing/all.test.js +2 -1
- package/fjs/emergent_testing/module.f.d.ts +3 -3
- package/fjs/emergent_testing/module.f.js +8 -10
- package/fjs/emergent_testing/proof.f.js +2 -2
- package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
- package/fjs/fsc/module.f.js +4 -4
- package/fjs/fsm/module.f.js +1 -1
- package/fjs/js/tokenizer/module.f.d.ts +1 -0
- package/fjs/js/tokenizer/module.f.js +13 -6
- package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
- package/fjs/{cas/mcp → mcp/cas}/module.f.js +18 -58
- package/fjs/mcp/evo/module.f.d.ts +32 -0
- package/fjs/{cas/evo/mcp → mcp/evo}/module.f.js +19 -14
- package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +1 -0
- package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +23 -7
- package/fjs/mcp/module.f.d.ts +54 -237
- package/fjs/mcp/module.f.js +55 -258
- package/fjs/mcp/proof.f.d.ts +43 -32
- package/fjs/mcp/proof.f.js +508 -200
- package/fjs/media/nix/module.f.d.ts +30 -0
- package/fjs/media/nix/module.f.js +166 -0
- package/fjs/media/nix/proof.f.d.ts +32 -0
- package/fjs/media/nix/proof.f.js +127 -0
- package/fjs/module.f.js +1 -1
- package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
- package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
- package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
- package/fjs/protocol/mcp/module.f.d.ts +239 -0
- package/fjs/protocol/mcp/module.f.js +272 -0
- package/fjs/protocol/mcp/proof.f.d.ts +34 -0
- package/fjs/protocol/mcp/proof.f.js +208 -0
- package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +5 -5
- package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +11 -11
- package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
- package/fjs/types/range_map/module.f.d.ts +14 -13
- package/fjs/types/range_map/module.f.js +18 -13
- package/fjs/types/range_map/proof.f.js +26 -39
- package/fjs/types/range_set/module.f.d.ts +5 -0
- package/fjs/types/range_set/module.f.js +16 -0
- package/fjs/types/range_set/proof.f.d.ts +1 -0
- package/fjs/types/range_set/proof.f.js +19 -0
- package/package.json +5 -5
- package/fjs/cas/evo/mcp/module.f.d.ts +0 -27
- package/fjs/cas/mcp/proof.f.d.ts +0 -45
- package/fjs/cas/mcp/proof.f.js +0 -545
- package/fjs/ci/playwright/module.f.d.ts +0 -2
- package/fjs/ci/playwright/module.f.js +0 -25
- package/fjs/media/json/rpc/module.f.d.ts +0 -114
- /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
- /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
package/fjs/mcp/proof.f.js
CHANGED
|
@@ -1,208 +1,516 @@
|
|
|
1
1
|
import { assert, assertEq } from '../asserts/module.f.js';
|
|
2
2
|
import { pure, step } from '../effects/module.f.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
import { create } from '../effects/memory/module.f.js';
|
|
4
|
+
import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from '../types/bit_vec/module.f.js';
|
|
5
|
+
import { vecToCBase32 } from '../basen/cbase32/module.f.js';
|
|
6
|
+
import { encode as base64Encode } from '../basen/base64/module.f.js';
|
|
7
|
+
import { utf8 } from '../text/module.f.js';
|
|
8
|
+
import { fileCas } from '../cas/module.f.js';
|
|
9
|
+
import { dialect as revisionDialect, mediaType as revisionMediaType } from '../media/revision/module.f.js';
|
|
10
|
+
import { sha256 } from '../crypto/sha2/module.f.js';
|
|
11
|
+
import { nonEmpty, empty as elEmpty } from '../effects/list/module.f.js';
|
|
12
|
+
import { mcpStep, uninitializedState, } from '../protocol/mcp/module.f.js';
|
|
13
|
+
import { emptyState, virtual } from '../effects/node/virtual/module.f.js';
|
|
14
|
+
import { casConfig, casMcpHandlers } from './module.f.js';
|
|
15
|
+
import { ok as resultOk } from '../types/result/module.f.js';
|
|
16
|
+
import { stdioTransport } from '../protocol/mcp/stdio/module.f.js';
|
|
17
|
+
import { fromVec } from '../types/uint8array/module.f.js';
|
|
18
|
+
import { initEvo } from '../cas/evo/module.f.js';
|
|
19
|
+
// ── Session driver ──────────────────────────────────────────────────────────────
|
|
20
|
+
// Feeds each message to `handler` in order, collecting every response.
|
|
21
|
+
const feed = (handler) => (msgs) => {
|
|
22
|
+
const go = (i, acc) => i === msgs.length
|
|
23
|
+
? pure(acc)
|
|
24
|
+
: step(handler(msgs[i]), r => go(i + 1, [...acc, r]));
|
|
25
|
+
return go(0, []);
|
|
26
|
+
};
|
|
27
|
+
// Runs a session backed by the virtual node runner (for cas_upload which uses
|
|
28
|
+
// Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
|
|
29
|
+
// the same filesystem-backed CAS.
|
|
30
|
+
const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
|
|
31
|
+
const effect = step(initEvo(fileCas(sha256)(home)), cacheKey => step(create(uninitializedState), sessionKey => {
|
|
32
|
+
const step = mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey);
|
|
33
|
+
return feed(step)(msgs);
|
|
34
|
+
}));
|
|
35
|
+
return virtual({ ...emptyState, root })(effect)[1];
|
|
36
|
+
};
|
|
37
|
+
// Seeds a blob directly into the virtual store via `c.write`, bypassing the
|
|
38
|
+
// MCP `cas_add` tool entirely — used by tests that need a stored blob larger
|
|
39
|
+
// than `cas_add`'s inline limit, now that the removed `type:'url'` path is no
|
|
40
|
+
// longer available to stage one through MCP. Returns the resulting root
|
|
41
|
+
// `Dir` (with the new shard written) and the blob's cBase32 hash so a
|
|
42
|
+
// subsequent `runSessionVirtual` call can read it back via `cas_get`.
|
|
43
|
+
const seedBlob = (root, home = '/home/user') => (chunks) => {
|
|
44
|
+
const c = fileCas(sha256)(home);
|
|
45
|
+
const stream = chunks.reduceRight((tail, chunk) => nonEmpty(resultOk(chunk), tail), elEmpty());
|
|
46
|
+
const [state, result] = virtual({ ...emptyState, root })(c.write(stream));
|
|
47
|
+
assert(result[0] === 'ok', result);
|
|
48
|
+
return [state.root, vecToCBase32(result[1])];
|
|
19
49
|
};
|
|
20
|
-
// ──
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
50
|
+
// ── Messages ────────────────────────────────────────────────────────────────────
|
|
51
|
+
const init = { jsonrpc: '2.0', method: 'initialize', id: 1,
|
|
52
|
+
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'c', version: '0' } } };
|
|
53
|
+
const initialized = { jsonrpc: '2.0', method: 'notifications/initialized' };
|
|
54
|
+
const call = (id, name, args) => ({ jsonrpc: '2.0', method: 'tools/call', id, params: { name, arguments: args } });
|
|
55
|
+
const list = (id) => ({ jsonrpc: '2.0', method: 'tools/list', id });
|
|
56
|
+
// Runs `init`, `notifications/initialized`, then `msgs`; returns the tool responses.
|
|
57
|
+
const session = (...msgs) => runSessionVirtual({}, '/home/user')([init, initialized, ...msgs]).slice(2);
|
|
58
|
+
// UTF-8 bytes of `s` as a plain array — the virtual stdin byte stream.
|
|
59
|
+
const toBytes = (s) => [...fromVec(utf8(s))];
|
|
60
|
+
// Runs `init`, `notifications/initialized`, then `msgs` through the *real*
|
|
61
|
+
// stdio pipeline — `mcpStep` wrapped in `stdioTransport`, driving
|
|
62
|
+
// `writeResponse`'s `tryUtf8` encode over actual stdin/stdout bytes. Unlike
|
|
63
|
+
// `session`/`runSessionVirtual`, which collect `mcpStep`'s `Response` values
|
|
64
|
+
// as plain JS objects and never serialize/encode them, this is the only
|
|
65
|
+
// helper that can observe the transport's oversized-response fallback (see
|
|
66
|
+
// `fjs/protocol/mcp/stdio/module.f.ts` `writeResponse`).
|
|
67
|
+
const runStdio = (root, home = '/home/user') => (msgs) => {
|
|
68
|
+
const input = [init, initialized, ...msgs].map(m => JSON.stringify(m)).join('\n') + '\n';
|
|
69
|
+
const effect = step(initEvo(fileCas(sha256)(home)), cacheKey => step(create(uninitializedState), sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey))));
|
|
70
|
+
const stdout = virtual({ ...emptyState, root, stdin: toBytes(input) })(effect)[0].stdout;
|
|
71
|
+
// Only requests get a written line (notifications, like `initialized`,
|
|
72
|
+
// write nothing) — drop the `init` response, keep one line per `msgs` entry.
|
|
73
|
+
return stdout.split('\n').filter(line => line.length > 0).slice(1).map(line => JSON.parse(line));
|
|
25
74
|
};
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
75
|
+
const resultOf = (resp) => resp.result;
|
|
76
|
+
const item0 = (resp) => resultOf(resp).content[0];
|
|
77
|
+
const textOf = (resp) => item0(resp).text;
|
|
78
|
+
// A plain text sample for text add→get round-trips.
|
|
79
|
+
const textSample = 'hello, world!';
|
|
80
|
+
// A valid `vnd.fjs.revision` blob: every required field present, including the
|
|
81
|
+
// explicit `snapshot` and `generation` the format now mandates.
|
|
82
|
+
const revisionSample = `{"dialect":"${revisionDialect}","subject":"8","parents":[],"snapshot":"8","generation":0}`;
|
|
83
|
+
// A base64-encoded binary payload for binary add→get round-trips.
|
|
84
|
+
const binarySample = base64Encode(vec8(0x2an));
|
|
85
|
+
// A base64 blob whose leading bytes are the PNG magic-byte signature, so
|
|
86
|
+
// `cas_get` detects its type and returns base64 with mimeType image/png.
|
|
87
|
+
const pngSample = base64Encode(u8ListToVec(msb)([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]));
|
|
88
|
+
// Returns the RFC 4648 base64 encoding of `n` zero bytes, computed directly
|
|
89
|
+
// without bigint arithmetic — independent of `base64.encode` so these tests
|
|
90
|
+
// don't rely on the correctness of the function they're indirectly exercising
|
|
91
|
+
// (`cas_add` decodes the string via `base64.decode`). Handles every n%3
|
|
92
|
+
// residue: rem=0 → no padding, rem=1 → 'AA==', rem=2 → 'AAA='.
|
|
93
|
+
const base64OfA = (n) => {
|
|
94
|
+
const groups = Number(n / 3n);
|
|
95
|
+
const rem = Number(n % 3n);
|
|
96
|
+
const body = 'AAAA'.repeat(groups);
|
|
97
|
+
if (rem === 0) {
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
if (rem === 1) {
|
|
101
|
+
return body + 'AA==';
|
|
102
|
+
}
|
|
103
|
+
return body + 'AAA=';
|
|
33
104
|
};
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
105
|
+
// ── Tests ───────────────────────────────────────────────────────────────────────
|
|
106
|
+
// A blob larger than one read chunk (> 128 KiB) used to fail metadata-only
|
|
107
|
+
// cas_get because `collectRead` overflowed `maxLength`. With `detectStream`,
|
|
108
|
+
// content:false returns correct metadata regardless of size. Each chunk is
|
|
109
|
+
// exactly `maxLengthBytes` (the largest single `Vec` the runtime allows), so the
|
|
110
|
+
// two-chunk blob spans two read chunks without any one `Vec` exceeding the cap.
|
|
111
|
+
const largeMultiChunkBlobMeta = (chunk0, chunk1, expectedType, expectedMime) => () => {
|
|
112
|
+
const [root, hash] = seedBlob({})([chunk0, chunk1]);
|
|
113
|
+
const [metaResp] = runSessionVirtual(root)([
|
|
114
|
+
init, initialized,
|
|
115
|
+
call(2, 'cas_get', { hash }),
|
|
116
|
+
]).slice(2);
|
|
117
|
+
assert(!resultOf(metaResp).isError);
|
|
118
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
119
|
+
assertEq(meta.type, expectedType);
|
|
120
|
+
assertEq(meta.mimeType, expectedMime);
|
|
121
|
+
assertEq(meta.length, Number((length(chunk0) + length(chunk1)) / 8n));
|
|
122
|
+
assertEq(meta.text, undefined);
|
|
123
|
+
assertEq(meta.blob, undefined);
|
|
124
|
+
};
|
|
125
|
+
// A full `maxLengthBytes`-long chunk of repeated ASCII 'a' — valid UTF-8.
|
|
126
|
+
const asciiChunk = repeat(maxLengthBytes)(vec8(0x61n));
|
|
127
|
+
// The same length, but repeated 2-byte "é" (C3 A9) — valid UTF-8 of multi-byte symbols.
|
|
128
|
+
const symbolChunk = repeat(maxLengthBytes / 2n)(u8ListToVec(msb)([0xc3, 0xa9]));
|
|
129
|
+
// A full chunk of 0xFF — an invalid UTF-8 lead byte, so binary (no magic match).
|
|
130
|
+
const binaryChunk = repeat(maxLengthBytes)(vec8(0xffn));
|
|
131
|
+
// 100,000 raw bytes of 0xFF: comfortably under `maxLengthBytes` (so `cas_get`'s
|
|
132
|
+
// own `length > maxLengthBytes` guard does not fire), but base64 inflates it to
|
|
133
|
+
// ceil(100_000/3)*4 = 133,336 chars — already past `maxLength` (131,072 bytes)
|
|
134
|
+
// before the JSON-RPC envelope adds anything.
|
|
135
|
+
const oversizedBase64Chunk = repeat(100000n)(vec8(0xffn));
|
|
136
|
+
// 90,000 raw bytes: base64 inflates to 120,000 chars, leaving ~11 KiB of margin
|
|
137
|
+
// under `maxLength` once the envelope is added — the paired "still succeeds" case.
|
|
138
|
+
const boundaryBase64Chunk = repeat(90000n)(vec8(0xffn));
|
|
139
|
+
// 33,000 `"` bytes (0x22): valid UTF-8, so `cas_get` classifies it as text and
|
|
140
|
+
// takes the double-JSON-escaping path (`toJson` builds the inner CAS JSON, then
|
|
141
|
+
// the outer `stringifyJson` embeds that as the `text` field). Each quote costs
|
|
142
|
+
// 4 final bytes (2 on each escaping pass), so 33,000 of them alone push the
|
|
143
|
+
// encoded line past `maxLength` (131,072 bytes) — well above the strict 32,769
|
|
144
|
+
// minimum needed, without approaching any other size-related edge case.
|
|
145
|
+
const quotesChunk = repeat(33000n)(vec8(0x22n));
|
|
50
146
|
export const proof = {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
147
|
+
// Both chunks repeated ASCII → text/plain, no error on a > 128 KiB blob.
|
|
148
|
+
getMetaLargeMultiChunkBlobNoError: largeMultiChunkBlobMeta(asciiChunk, asciiChunk, 'text', 'text/plain'),
|
|
149
|
+
// Both chunks valid UTF-8 sequences of multi-byte symbols → text/plain.
|
|
150
|
+
getMetaLargeMultiChunkUtf8Symbols: largeMultiChunkBlobMeta(symbolChunk, symbolChunk, 'text', 'text/plain'),
|
|
151
|
+
// First chunk valid UTF-8, second chunk binary → base64/octet-stream. The
|
|
152
|
+
// streaming validator must see the trailing binary chunk to reject text.
|
|
153
|
+
getMetaLargeMultiChunkTextThenBinary: largeMultiChunkBlobMeta(asciiChunk, binaryChunk, 'base64', 'application/octet-stream'),
|
|
154
|
+
// content:true on a present blob larger than `maxLength` reports a distinct
|
|
155
|
+
// "too large" error, not "no such hash" — the blob exists, it just can't be
|
|
156
|
+
// buffered inline. The metadata-only path (above) still returns its size/type.
|
|
157
|
+
getContentLargeBlobTooLargeError: () => {
|
|
158
|
+
const [root, hash] = seedBlob({})([asciiChunk, asciiChunk]);
|
|
159
|
+
const [getResp] = runSessionVirtual(root)([
|
|
160
|
+
init, initialized,
|
|
161
|
+
call(2, 'cas_get', { hash, content: true }),
|
|
162
|
+
]).slice(2);
|
|
163
|
+
assertEq(resultOf(getResp).isError, true);
|
|
164
|
+
const text = textOf(getResp);
|
|
165
|
+
assert(text.includes('too large'));
|
|
166
|
+
assert(!text.includes('no such hash'));
|
|
167
|
+
},
|
|
168
|
+
// content:true on a genuinely absent hash still reports "no such hash" — the
|
|
169
|
+
// oversized branch above must not absorb the absent case.
|
|
170
|
+
getContentMissingHashIsError: () => {
|
|
171
|
+
const absent = vecToCBase32(vec8(0x55n));
|
|
172
|
+
const [resp] = session(call(2, 'cas_get', { hash: absent, content: true }));
|
|
173
|
+
assertEq(resultOf(resp).isError, true);
|
|
174
|
+
assert(textOf(resp).includes('no such hash'));
|
|
175
|
+
},
|
|
176
|
+
// A blob at exactly `maxLengthBytes` passes cas_get's own raw-length guard
|
|
177
|
+
// (it checks the *stored* size, not the encoded response), but base64
|
|
178
|
+
// inflation of a 100,000-byte blob alone already exceeds `maxLength` before
|
|
179
|
+
// the JSON-RPC envelope is even added. The transport's `writeResponse`
|
|
180
|
+
// (`tryUtf8`) must catch this and write a JSON-RPC internal-error response —
|
|
181
|
+
// carrying the *original request's* `id` — instead of crashing the process.
|
|
182
|
+
// See `fjs/protocol/mcp/stdio/module.f.ts` `writeResponse`.
|
|
183
|
+
//
|
|
184
|
+
// This test originally timed out under `bun test`'s native 5s per-test
|
|
185
|
+
// limit (12-14s observed in CI on PR #1201) — the cost was in
|
|
186
|
+
// `base64Encode`, quadratic before the `baseN.vecToString` fix (see
|
|
187
|
+
// `fjs/basen/base64/proof.f.ts` `encodeLargeVecIsSlow`). Now well under budget on
|
|
188
|
+
// both engines.
|
|
189
|
+
getContentBase64InflationOverflowWritesInternalError: () => {
|
|
190
|
+
const [root, hash] = seedBlob({})([oversizedBase64Chunk]);
|
|
191
|
+
const [getResp] = runStdio(root)([
|
|
192
|
+
call(2, 'cas_get', { hash, content: true }),
|
|
193
|
+
]);
|
|
194
|
+
const err = getResp;
|
|
195
|
+
assertEq(err.error?.code, -32603);
|
|
196
|
+
assertEq(err.id, 2);
|
|
197
|
+
},
|
|
198
|
+
// The paired boundary case: a blob whose base64 inflation leaves enough
|
|
199
|
+
// margin under `maxLength` for the envelope — confirms the fallback above
|
|
200
|
+
// only triggers on genuine overflow, not on every `content:true` binary read.
|
|
201
|
+
getContentBase64NearBoundarySucceeds: () => {
|
|
202
|
+
const [root, hash] = seedBlob({})([boundaryBase64Chunk]);
|
|
203
|
+
const [getResp] = runStdio(root)([
|
|
204
|
+
call(2, 'cas_get', { hash, content: true }),
|
|
205
|
+
]);
|
|
206
|
+
assert(!resultOf(getResp).isError);
|
|
207
|
+
const result = JSON.parse(textOf(getResp));
|
|
208
|
+
assertEq(result.type, 'base64');
|
|
209
|
+
assertEq(result.length, 90_000);
|
|
210
|
+
},
|
|
211
|
+
// A text blob made entirely of `"` characters keeps the *raw* content
|
|
212
|
+
// (33,000 bytes) well under `maxLengthBytes`, but each quote costs 2 bytes on
|
|
213
|
+
// the inner CAS-JSON escape and 4 bytes once the outer JSON-RPC envelope
|
|
214
|
+
// re-escapes that backslash-quote pair — no `cas_get`-level size guard could
|
|
215
|
+
// see this coming, only the transport's real encode attempt can. Seeded
|
|
216
|
+
// directly into the store via `seedBlob` (not an inlined `cas_add` JSON
|
|
217
|
+
// string) so the test exercises `cas_get`'s response-side escaping without
|
|
218
|
+
// also paying for parsing a huge inline `content` argument on the request
|
|
219
|
+
// side. Confirms `writeResponse`'s
|
|
220
|
+
// `tryUtf8` fallback (not a crash) handles double-escaping overflow, again
|
|
221
|
+
// preserving the request's `id`.
|
|
222
|
+
getContentDoubleEscapedOverflowWritesInternalError: () => {
|
|
223
|
+
const [root, hash] = seedBlob({})([quotesChunk]);
|
|
224
|
+
const [getResp] = runStdio(root)([
|
|
225
|
+
call(2, 'cas_get', { hash, content: true }),
|
|
226
|
+
]);
|
|
227
|
+
const err = getResp;
|
|
228
|
+
assertEq(err.error?.code, -32603);
|
|
229
|
+
assertEq(err.id, 2);
|
|
230
|
+
},
|
|
231
|
+
toolsListAdvertisesSevenTools: () => {
|
|
232
|
+
const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
|
|
233
|
+
const tools = resp.result.tools;
|
|
234
|
+
assertEq(tools.length, 7);
|
|
235
|
+
assertEq(tools.map(t => t.name).join(','), 'cas_add,cas_get,cas_list,evo_list,evo_head,evo_revision,evo_add');
|
|
236
|
+
const add = resp.result.tools[0];
|
|
237
|
+
assertEq(add.inputSchema.type, 'object');
|
|
238
|
+
},
|
|
239
|
+
// The same server also exposes Evo (fjs/cas/evo): add a revision, then
|
|
240
|
+
// find it via evo_list/evo_head — one process, one store, one cache.
|
|
241
|
+
evoAddListHeadRoundTripsThroughCasServer: () => {
|
|
242
|
+
const [addResp, listResp, headResp] = session(call(2, 'evo_add', { parents: [], subject: 'doc', snapshot: vecToCBase32(vec8(0x1n)) }), call(3, 'evo_list', {}), call(4, 'evo_head', { subject: 'doc' }));
|
|
243
|
+
assert(!resultOf(addResp).isError);
|
|
244
|
+
const hash = textOf(addResp);
|
|
245
|
+
assert(hash.length > 0);
|
|
246
|
+
assertEq(textOf(listResp), '["doc"]');
|
|
247
|
+
assertEq(textOf(headResp), hash);
|
|
248
|
+
},
|
|
249
|
+
evoAddDomainErrorIsErrorThroughCasServer: () => {
|
|
250
|
+
// Valid arguments, but the domain rule fails: `subject` is required
|
|
251
|
+
// unless there is exactly one parent.
|
|
252
|
+
const [resp] = session(call(2, 'evo_add', { parents: [] }));
|
|
253
|
+
assertEq(resultOf(resp).isError, true);
|
|
254
|
+
assert(textOf(resp).includes('subject is required'));
|
|
255
|
+
},
|
|
256
|
+
// A plain `cas_add` of `vnd.fjs.revision` content is folded into the Evo
|
|
257
|
+
// cache too (`syncRevision`), not just `evo_add` — `cas_add`/`evo_add`
|
|
258
|
+
// are two ways to reach the same store and cache.
|
|
259
|
+
casAddSyncsEvoCacheForRevisionContent: () => {
|
|
260
|
+
const [addResp, listResp, headResp] = session(call(2, 'cas_add', { content: revisionSample }), call(3, 'evo_list', {}), call(4, 'evo_head', { subject: '8' }));
|
|
261
|
+
assert(!resultOf(addResp).isError);
|
|
262
|
+
const hash = textOf(addResp);
|
|
263
|
+
assertEq(textOf(listResp), '["8"]');
|
|
264
|
+
assertEq(textOf(headResp), hash);
|
|
265
|
+
},
|
|
266
|
+
addReturnsHash: () => {
|
|
267
|
+
const [resp] = session(call(2, 'cas_add', { content: textSample }));
|
|
268
|
+
assert(!resultOf(resp).isError);
|
|
269
|
+
assert(textOf(resp).length > 0);
|
|
270
|
+
},
|
|
271
|
+
// Text add→get round-trip: store as UTF-8 text, retrieve metadata.
|
|
272
|
+
textAddGetMetaRoundTrips: () => {
|
|
273
|
+
const [addResp] = session(call(2, 'cas_add', { content: textSample }));
|
|
274
|
+
const hash = textOf(addResp);
|
|
275
|
+
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash }));
|
|
276
|
+
assert(!resultOf(getResp).isError);
|
|
277
|
+
const result = JSON.parse(textOf(getResp));
|
|
278
|
+
assertEq(result.mimeType, 'text/plain');
|
|
279
|
+
assertEq(result.type, 'text');
|
|
280
|
+
assertEq(result.length, textSample.length);
|
|
281
|
+
assertEq(result.text, undefined);
|
|
282
|
+
},
|
|
283
|
+
// cas_get with content:true returns inline content.
|
|
284
|
+
textAddGetContentRoundTrips: () => {
|
|
285
|
+
const [addResp] = session(call(2, 'cas_add', { content: textSample }));
|
|
286
|
+
const hash = textOf(addResp);
|
|
287
|
+
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash, content: true }));
|
|
288
|
+
assert(!resultOf(getResp).isError);
|
|
289
|
+
const result = JSON.parse(textOf(getResp));
|
|
290
|
+
assertEq(result.type, 'text');
|
|
291
|
+
assertEq(result.mimeType, 'text/plain');
|
|
292
|
+
assertEq(result.text, textSample);
|
|
293
|
+
},
|
|
294
|
+
// Binary add→get round-trip: store as base64, retrieve metadata without content.
|
|
295
|
+
binaryAddGetMetaRoundTrips: () => {
|
|
296
|
+
const [addResp] = session(call(2, 'cas_add', { content: binarySample, type: 'base64' }));
|
|
297
|
+
const hash = textOf(addResp);
|
|
298
|
+
const [, getResp] = session(call(2, 'cas_add', { content: binarySample, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
299
|
+
assert(!resultOf(getResp).isError);
|
|
300
|
+
const result = JSON.parse(textOf(getResp));
|
|
301
|
+
assertEq(result.text, undefined);
|
|
302
|
+
assertEq(result.type, 'text');
|
|
303
|
+
assertEq(result.mimeType, 'text/plain');
|
|
304
|
+
},
|
|
305
|
+
// Binary add→get with content:true returns inline base64 content.
|
|
306
|
+
binaryAddGetContentRoundTrips: () => {
|
|
307
|
+
const [addResp] = session(call(2, 'cas_add', { content: binarySample, type: 'base64' }));
|
|
308
|
+
const hash = textOf(addResp);
|
|
309
|
+
const [, getResp] = session(call(2, 'cas_add', { content: binarySample, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
310
|
+
assert(!resultOf(getResp).isError);
|
|
311
|
+
const result = JSON.parse(textOf(getResp));
|
|
312
|
+
assertEq(result.type, 'text');
|
|
313
|
+
assertEq(result.text, '*');
|
|
314
|
+
},
|
|
315
|
+
addGetRoundTrips: () => {
|
|
316
|
+
const [addResp] = session(call(2, 'cas_add', { content: textSample }));
|
|
317
|
+
const hash = textOf(addResp);
|
|
318
|
+
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash, content: true }));
|
|
319
|
+
assert(!resultOf(getResp).isError);
|
|
320
|
+
const result = JSON.parse(textOf(getResp));
|
|
321
|
+
assertEq(result.text, textSample);
|
|
322
|
+
},
|
|
323
|
+
// Text content (no magic-byte match, valid UTF-8) comes back with type:'text' when content requested.
|
|
324
|
+
getUntypedReturnsText: () => {
|
|
325
|
+
const [addResp] = session(call(2, 'cas_add', { content: textSample }));
|
|
326
|
+
const hash = textOf(addResp);
|
|
327
|
+
const [, getResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_get', { hash, content: true }));
|
|
328
|
+
assertEq(item0(getResp) === null ? null : item0(getResp).type, 'text');
|
|
329
|
+
const result = JSON.parse(textOf(getResp));
|
|
330
|
+
assertEq(result.type, 'text');
|
|
331
|
+
assertEq(result.mimeType, 'text/plain');
|
|
332
|
+
},
|
|
333
|
+
// Bytes with a recognised PNG signature come back as base64 with mimeType image/png when content requested.
|
|
334
|
+
getTypedReturnsBinaryJson: () => {
|
|
335
|
+
const [addResp] = session(call(2, 'cas_add', { content: pngSample, type: 'base64' }));
|
|
336
|
+
const hash = textOf(addResp);
|
|
337
|
+
const [, getResp] = session(call(2, 'cas_add', { content: pngSample, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
338
|
+
assert(!resultOf(getResp).isError);
|
|
339
|
+
assertEq(item0(getResp).type, 'text');
|
|
340
|
+
const result = JSON.parse(textOf(getResp));
|
|
341
|
+
assertEq(result.type, 'base64');
|
|
342
|
+
assertEq(result.mimeType, 'image/png');
|
|
343
|
+
assertEq(result.blob, pngSample);
|
|
344
|
+
},
|
|
345
|
+
// A stored `vnd.fjs.revision` blob is recognized by metadata-only
|
|
346
|
+
// `cas_get` (content:false, the default) — the CAS read path routes the
|
|
347
|
+
// bounded, whole-blob-text case through the dialect-aware detector rather
|
|
348
|
+
// than only reporting the streaming text/plain verdict.
|
|
349
|
+
getMetaRecognizesRevisionDialect: () => {
|
|
350
|
+
const [addResp] = session(call(2, 'cas_add', { content: revisionSample }));
|
|
351
|
+
const hash = textOf(addResp);
|
|
352
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: revisionSample }), call(3, 'cas_get', { hash }));
|
|
353
|
+
assert(!resultOf(metaResp).isError);
|
|
354
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
355
|
+
assertEq(meta.mimeType, revisionMediaType);
|
|
356
|
+
assertEq(meta.type, 'text');
|
|
357
|
+
},
|
|
358
|
+
// Same dialect recognition on the `content: true` path, alongside the
|
|
359
|
+
// inline text payload.
|
|
360
|
+
getContentRecognizesRevisionDialect: () => {
|
|
361
|
+
const [addResp] = session(call(2, 'cas_add', { content: revisionSample }));
|
|
362
|
+
const hash = textOf(addResp);
|
|
363
|
+
const [, getResp] = session(call(2, 'cas_add', { content: revisionSample }), call(3, 'cas_get', { hash, content: true }));
|
|
364
|
+
assert(!resultOf(getResp).isError);
|
|
365
|
+
const result = JSON.parse(textOf(getResp));
|
|
366
|
+
assertEq(result.mimeType, revisionMediaType);
|
|
367
|
+
assertEq(result.type, 'text');
|
|
368
|
+
assertEq(result.text, revisionSample);
|
|
369
|
+
},
|
|
370
|
+
listEnumeratesStoredHashes: () => {
|
|
371
|
+
const [addResp, listResp] = session(call(2, 'cas_add', { content: textSample }), call(3, 'cas_list', {}));
|
|
372
|
+
const hash = textOf(addResp);
|
|
373
|
+
assert(!resultOf(listResp).isError);
|
|
374
|
+
assertEq(textOf(listResp), hash);
|
|
375
|
+
},
|
|
376
|
+
addInvalidContentIsError: () => {
|
|
377
|
+
const [resp] = session(call(2, 'cas_add', { content: 'not valid!', type: 'base64' }));
|
|
378
|
+
assertEq(resultOf(resp).isError, true);
|
|
379
|
+
},
|
|
380
|
+
addBadLengthContentIsError: () => {
|
|
381
|
+
const [resp] = session(call(2, 'cas_add', { content: 'A', type: 'base64' }));
|
|
382
|
+
assertEq(resultOf(resp).isError, true);
|
|
383
|
+
},
|
|
384
|
+
// 174_764 'A' chars × 6 bits = 1_048_584 bits, 8 over maxLength — base64Decode
|
|
385
|
+
// returns null and cas_add surfaces an error.
|
|
386
|
+
addBase64OverLimitIsError: () => {
|
|
387
|
+
const [resp] = session(call(2, 'cas_add', { content: 'A'.repeat(174_764), type: 'base64' }));
|
|
388
|
+
assertEq(resultOf(resp).isError, true);
|
|
389
|
+
assert(textOf(resp).includes('too large or malformed'));
|
|
390
|
+
assert(textOf(resp).includes('cas add'));
|
|
391
|
+
},
|
|
392
|
+
// One ASCII byte past maxLengthBytes — tryUtf8 returns null and cas_add
|
|
393
|
+
// surfaces an error.
|
|
394
|
+
addTextOverLimitIsError: () => {
|
|
395
|
+
const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes) + 1) }));
|
|
396
|
+
assertEq(resultOf(resp).isError, true);
|
|
397
|
+
assert(textOf(resp).includes('too large or malformed'));
|
|
398
|
+
assert(textOf(resp).includes('cas add'));
|
|
399
|
+
},
|
|
400
|
+
// Exactly maxLengthBytes (131,072) ASCII bytes — tryUtf8 returns a non-null
|
|
401
|
+
// Vec (len === maxLength, not over), so cas_add stores cleanly.
|
|
402
|
+
addTextAtLimitSucceeds: () => {
|
|
403
|
+
const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes)) }));
|
|
404
|
+
assert(!resultOf(resp).isError);
|
|
405
|
+
assert(textOf(resp).length > 0);
|
|
406
|
+
},
|
|
407
|
+
// base64 of exactly maxLengthBytes — base64Decode trims the encoding's
|
|
408
|
+
// padding bits from the last character before measuring the result
|
|
409
|
+
// against maxLength, so a payload landing exactly at the limit decodes
|
|
410
|
+
// and stores cleanly.
|
|
411
|
+
addBase64AtLimitSucceeds: () => {
|
|
412
|
+
const [resp] = session(call(2, 'cas_add', { content: base64OfA(maxLengthBytes), type: 'base64' }));
|
|
413
|
+
assert(!resultOf(resp).isError);
|
|
414
|
+
assert(textOf(resp).length > 0);
|
|
415
|
+
},
|
|
416
|
+
getUnterminatedHashIsError: () => {
|
|
417
|
+
const [resp] = session(call(2, 'cas_get', { hash: '0' }));
|
|
418
|
+
assertEq(resultOf(resp).isError, true);
|
|
419
|
+
},
|
|
420
|
+
addMissingContentIsError: () => {
|
|
421
|
+
const [resp] = session(call(2, 'cas_add', {}));
|
|
422
|
+
assertEq(resultOf(resp).isError, true);
|
|
423
|
+
},
|
|
424
|
+
getMissingHashArgumentIsError: () => {
|
|
425
|
+
const [resp] = session(call(2, 'cas_get', {}));
|
|
426
|
+
assertEq(resultOf(resp).isError, true);
|
|
427
|
+
},
|
|
428
|
+
getInvalidHashIsError: () => {
|
|
429
|
+
const [resp] = session(call(2, 'cas_get', { hash: 'not valid!' }));
|
|
430
|
+
assertEq(resultOf(resp).isError, true);
|
|
431
|
+
},
|
|
432
|
+
getMissingHashIsError: () => {
|
|
433
|
+
const absent = vecToCBase32(vec8(0x99n));
|
|
434
|
+
const [resp] = session(call(2, 'cas_get', { hash: absent }));
|
|
435
|
+
assertEq(resultOf(resp).isError, true);
|
|
436
|
+
},
|
|
437
|
+
unknownToolIsError: () => {
|
|
438
|
+
const [resp] = session(call(2, 'cas_remove', { hash: binarySample }));
|
|
439
|
+
assertEq(resultOf(resp).isError, true);
|
|
440
|
+
},
|
|
441
|
+
toolErrorIsNotJsonRpcError: () => {
|
|
442
|
+
const [resp] = session(call(2, 'cas_add', { content: 'not valid!', type: 'base64' }));
|
|
443
|
+
assert(!('error' in resp));
|
|
444
|
+
assert('result' in resp);
|
|
445
|
+
},
|
|
446
|
+
// cas_get without content:true returns only metadata.
|
|
447
|
+
getMetaReturnsLengthAndMimeType: () => {
|
|
448
|
+
const [addResp] = session(call(2, 'cas_add', { content: 'text content' }));
|
|
449
|
+
const hash = textOf(addResp);
|
|
450
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: 'text content' }), call(3, 'cas_get', { hash }));
|
|
451
|
+
assert(!resultOf(metaResp).isError);
|
|
452
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
453
|
+
assertEq(meta.mimeType, 'text/plain');
|
|
454
|
+
assertEq(meta.type, 'text');
|
|
455
|
+
assertEq(meta.length, 12);
|
|
456
|
+
assertEq(meta.text, undefined);
|
|
457
|
+
},
|
|
458
|
+
getMetaBinaryBlob: () => {
|
|
459
|
+
const [addResp] = session(call(2, 'cas_add', { content: pngSample, type: 'base64' }));
|
|
460
|
+
const hash = textOf(addResp);
|
|
461
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: pngSample, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
462
|
+
assert(!resultOf(metaResp).isError);
|
|
463
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
464
|
+
assertEq(meta.mimeType, 'image/png');
|
|
465
|
+
assertEq(meta.type, 'base64');
|
|
466
|
+
assertEq(meta.length, 10);
|
|
467
|
+
assertEq(meta.blob, undefined);
|
|
468
|
+
},
|
|
469
|
+
getMetaOctetStreamForUnknownBinary: () => {
|
|
470
|
+
const binaryContent = u8ListToVec(msb)([0xFF, 0xFE, 0x00, 0x01]);
|
|
471
|
+
const binaryB64 = base64Encode(binaryContent);
|
|
472
|
+
const [addResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }));
|
|
473
|
+
const hash = textOf(addResp);
|
|
474
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
475
|
+
assert(!resultOf(metaResp).isError);
|
|
476
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
477
|
+
assertEq(meta.mimeType, 'application/octet-stream');
|
|
478
|
+
assertEq(meta.type, 'base64');
|
|
479
|
+
assertEq(meta.blob, undefined);
|
|
480
|
+
},
|
|
481
|
+
// A NUL-bearing blob is valid UTF-8 yet binary: cas_get must report
|
|
482
|
+
// base64/octet-stream, not text/plain.
|
|
483
|
+
getMetaOctetStreamForNulBlob: () => {
|
|
484
|
+
const nulContent = u8ListToVec(msb)([0x00, 0x00, 0x00]);
|
|
485
|
+
const nulB64 = base64Encode(nulContent);
|
|
486
|
+
const [addResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }));
|
|
487
|
+
const hash = textOf(addResp);
|
|
488
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
489
|
+
assert(!resultOf(metaResp).isError);
|
|
490
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
491
|
+
assertEq(meta.mimeType, 'application/octet-stream');
|
|
492
|
+
assertEq(meta.type, 'base64');
|
|
493
|
+
},
|
|
494
|
+
getMetaMissingHashIsError: () => {
|
|
495
|
+
const absent = vecToCBase32(vec8(0x77n));
|
|
496
|
+
const [resp] = session(call(2, 'cas_get', { hash: absent }));
|
|
497
|
+
assertEq(resultOf(resp).isError, true);
|
|
498
|
+
},
|
|
499
|
+
getMetaInvalidHashIsError: () => {
|
|
500
|
+
const [resp] = session(call(2, 'cas_get', { hash: 'bad!' }));
|
|
501
|
+
assertEq(resultOf(resp).isError, true);
|
|
502
|
+
},
|
|
503
|
+
// cas_get with content:true on octet-stream (no magic bytes, not UTF-8) returns inline base64.
|
|
504
|
+
getOctetStreamWithContentIncludesBase64: () => {
|
|
505
|
+
const binaryContent = u8ListToVec(msb)([0xFF, 0xFE, 0x00, 0x01]);
|
|
506
|
+
const binaryB64 = base64Encode(binaryContent);
|
|
507
|
+
const [addResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }));
|
|
508
|
+
const hash = textOf(addResp);
|
|
509
|
+
const [, getResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
510
|
+
assert(!resultOf(getResp).isError);
|
|
511
|
+
const result = JSON.parse(textOf(getResp));
|
|
512
|
+
assertEq(result.mimeType, 'application/octet-stream');
|
|
513
|
+
assertEq(result.type, 'base64');
|
|
514
|
+
assertEq(result.blob, binaryB64);
|
|
207
515
|
},
|
|
208
516
|
};
|