functionalscript 0.33.0 → 0.35.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 +27 -14
- package/fs/asserts/module.f.d.ts +1 -1
- package/fs/asserts/module.f.js +4 -1
- package/fs/base64/proof.f.d.ts +1 -0
- package/fs/base64/proof.f.js +25 -56
- package/fs/base_n/module.f.js +11 -5
- package/fs/base_n/proof.f.d.ts +1 -0
- package/fs/base_n/proof.f.js +12 -1
- package/fs/bnf/data/module.f.d.ts +0 -103
- package/fs/bnf/data/module.f.js +10 -246
- package/fs/bnf/data/proof.f.d.ts +0 -10
- package/fs/bnf/data/proof.f.js +2 -642
- package/fs/bnf/descent/module.f.d.ts +56 -0
- package/fs/bnf/descent/module.f.js +119 -0
- package/fs/bnf/descent/proof.f.d.ts +5 -0
- package/fs/bnf/descent/proof.f.js +296 -0
- package/fs/bnf/ll1/module.f.d.ts +72 -0
- package/fs/bnf/ll1/module.f.js +153 -0
- package/fs/bnf/ll1/proof.f.d.ts +9 -0
- package/fs/bnf/ll1/proof.f.js +358 -0
- package/fs/cas/cli/module.f.d.ts +6 -0
- package/fs/cas/cli/module.f.js +56 -0
- package/fs/cas/cli/proof.f.d.ts +14 -0
- package/fs/cas/cli/proof.f.js +149 -0
- package/fs/cas/mcp/module.f.d.ts +9 -17
- package/fs/cas/mcp/module.f.js +123 -96
- package/fs/cas/mcp/proof.f.d.ts +12 -1
- package/fs/cas/mcp/proof.f.js +199 -32
- package/fs/cas/module.f.d.ts +35 -15
- package/fs/cas/module.f.js +168 -131
- package/fs/cas/proof.f.d.ts +16 -13
- package/fs/cas/proof.f.js +255 -121
- package/fs/ci/config/module.f.d.ts +14 -14
- package/fs/ci/config/module.f.js +14 -14
- package/fs/djs/tokenizer-new/module.f.js +1 -1
- package/fs/djs/tokenizer-new/proof.f.d.ts +2 -0
- package/fs/djs/tokenizer-new/proof.f.js +357 -229
- package/fs/effects/list/module.f.d.ts +31 -0
- package/fs/effects/list/module.f.js +16 -0
- package/fs/effects/module.f.d.ts +11 -5
- package/fs/effects/module.f.js +16 -6
- package/fs/effects/node/module.f.d.ts +29 -3
- package/fs/effects/node/module.f.js +35 -1
- package/fs/effects/node/module.js +32 -18
- package/fs/effects/node/virtual/module.f.js +57 -0
- package/fs/effects/proof.f.d.ts +4 -0
- package/fs/effects/proof.f.js +18 -1
- package/fs/fjs/module.f.js +2 -8
- package/fs/html/module.f.js +6 -8
- package/fs/mcp/module.f.d.ts +1 -1
- package/fs/mcp/stdio/module.f.d.ts +8 -0
- package/fs/mcp/stdio/module.f.js +33 -6
- package/fs/mcp/stdio/proof.f.d.ts +3 -0
- package/fs/mcp/stdio/proof.f.js +52 -1
- package/fs/mime/module.f.d.ts +101 -5
- package/fs/mime/module.f.js +159 -6
- package/fs/mime/proof.f.d.ts +25 -0
- package/fs/mime/proof.f.js +193 -3
- package/fs/text/code_point/module.f.d.ts +39 -2
- package/fs/text/code_point/module.f.js +74 -2
- package/fs/text/code_point/proof.f.d.ts +8 -0
- package/fs/text/code_point/proof.f.js +72 -0
- package/fs/text/module.f.d.ts +11 -1
- package/fs/text/module.f.js +13 -3
- package/fs/text/proof.f.d.ts +6 -0
- package/fs/text/proof.f.js +25 -2
- package/fs/text/utf16/module.f.js +4 -26
- package/fs/text/utf8/module.f.d.ts +27 -0
- package/fs/text/utf8/module.f.js +8 -14
- package/fs/types/bit_vec/module.f.d.ts +20 -2
- package/fs/types/bit_vec/module.f.js +77 -27
- package/fs/types/bit_vec/proof.f.d.ts +8 -0
- package/fs/types/bit_vec/proof.f.js +24 -6
- package/fs/types/function/operator/module.f.d.ts +26 -0
- package/fs/types/list/module.f.d.ts +3 -0
- package/fs/types/nullable/module.f.d.ts +9 -5
- package/fs/types/nullable/module.f.js +19 -0
- package/fs/types/result/module.d.ts +2 -4
- package/fs/types/result/module.js +9 -3
- package/package.json +3 -3
- package/issues/demo/data/data.f.js +0 -12
- package/issues/demo/data/shared.f.js +0 -3
- package/issues/demo/fs/app.js +0 -4
- package/issues/demo/fs/math.f.js +0 -4
- package/issues/demo/sample/proof.f.js +0 -13
- /package/{issues → todo}/031-json.f.d.ts +0 -0
- /package/{issues → todo}/031-json.f.js +0 -0
- /package/{issues → todo}/proof.f.d.ts +0 -0
- /package/{issues → todo}/proof.f.js +0 -0
package/fs/cas/mcp/proof.f.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { assert, assertEq } from "../../asserts/module.f.js";
|
|
2
2
|
import { pure } from "../../effects/module.f.js";
|
|
3
3
|
import { run } from "../../effects/mock/module.f.js";
|
|
4
|
-
import { asBase, asNominal, create
|
|
5
|
-
import { msb, u8ListToVec, vec8 } from "../../types/bit_vec/module.f.js";
|
|
4
|
+
import { asBase, asNominal, create } from "../../effects/memory/module.f.js";
|
|
5
|
+
import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from "../../types/bit_vec/module.f.js";
|
|
6
6
|
import { vecToCBase32 } from "../../cbase32/module.f.js";
|
|
7
7
|
import { encode as base64Encode } from "../../base64/module.f.js";
|
|
8
|
-
import { computeSync, sha256 } from "../../crypto/sha2/module.f.js";
|
|
9
8
|
import { utf8 } from "../../text/module.f.js";
|
|
10
|
-
import {
|
|
9
|
+
import {} from "../module.f.js";
|
|
11
10
|
import { mcpStep, uninitializedState, } from "../../mcp/module.f.js";
|
|
12
|
-
import {} from "../../effects/node/module.f.js";
|
|
13
11
|
import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
|
|
14
12
|
import { casConfig, casMcpHandlers } from "./module.f.js";
|
|
15
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";
|
|
16
16
|
const initialTestState = { memory: { next: 0, values: {} } };
|
|
17
17
|
const mock = {
|
|
18
18
|
memCreate: value => state => {
|
|
@@ -32,18 +32,15 @@ const mock = {
|
|
|
32
32
|
rename: (_src, _dst) => _ => { throw new Error('rename not supported in memory mock'); },
|
|
33
33
|
readBytes: (_path, _offset, _size) => _ => { throw new Error('readBytes not supported in memory mock'); },
|
|
34
34
|
randomInt: () => _ => { throw new Error('randomInt not supported in memory mock'); },
|
|
35
|
+
now: () => state => [state, 0],
|
|
36
|
+
access: (_path) => _ => { throw new Error('access not supported in memory mock'); },
|
|
37
|
+
createExclusive: (_path) => _ => { throw new Error('createExclusive not supported in memory mock'); },
|
|
38
|
+
readdir: (_path, _opts) => _ => { throw new Error('readdir not supported in memory mock'); },
|
|
39
|
+
rm: (_path) => _ => { throw new Error('rm not supported in memory mock'); },
|
|
40
|
+
stat: (_path) => _ => { throw new Error('stat not supported in memory mock'); },
|
|
41
|
+
writeBytes: (_path, _offset, _data) => _ => { throw new Error('writeBytes not supported in memory mock'); },
|
|
35
42
|
};
|
|
36
43
|
const runMem = (effect) => run(mock)(initialTestState)(effect)[1];
|
|
37
|
-
const memCas = (mapKey) => ({
|
|
38
|
-
read: (key) => read(mapKey).step(m => pure(m[vecToCBase32(key)]?.[1])),
|
|
39
|
-
write: (value) => {
|
|
40
|
-
const key = computeSync(sha256)([value]);
|
|
41
|
-
return read(mapKey)
|
|
42
|
-
.step(m => write(mapKey, { ...m, [vecToCBase32(key)]: [key, value] }))
|
|
43
|
-
.step(() => pure(key));
|
|
44
|
-
},
|
|
45
|
-
list: () => read(mapKey).step(m => pure(Object.values(m).map(([k]) => k))),
|
|
46
|
-
});
|
|
47
44
|
// ── Session driver ──────────────────────────────────────────────────────────────
|
|
48
45
|
// Feeds each message to `step` in order, collecting every response.
|
|
49
46
|
const feed = (step) => (msgs) => {
|
|
@@ -52,19 +49,12 @@ const feed = (step) => (msgs) => {
|
|
|
52
49
|
: step(msgs[i]).step(r => go(i + 1, [...acc, r]));
|
|
53
50
|
return go(0, []);
|
|
54
51
|
};
|
|
55
|
-
// Runs a full session over a fresh in-memory CAS, returning all responses.
|
|
56
|
-
const runSession = (msgs, home = '/home/user') => runMem(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
57
|
-
const c = memCas(mapKey);
|
|
58
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
59
|
-
return feed(step)(msgs);
|
|
60
|
-
})));
|
|
61
52
|
// Runs a session backed by the virtual node runner (for cas_upload which uses
|
|
62
53
|
// Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
|
|
63
54
|
// the same filesystem-backed CAS.
|
|
64
55
|
const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
|
|
65
56
|
const effect = create(uninitializedState).step(sessionKey => {
|
|
66
|
-
const
|
|
67
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
57
|
+
const step = mcpStep(casConfig)(casMcpHandlers(home))(sessionKey);
|
|
68
58
|
return feed(step)(msgs);
|
|
69
59
|
});
|
|
70
60
|
return virtual({ ...emptyState, root })(effect)[1];
|
|
@@ -76,7 +66,24 @@ const initialized = { jsonrpc: '2.0', method: 'notifications/initialized' };
|
|
|
76
66
|
const call = (id, name, args) => ({ jsonrpc: '2.0', method: 'tools/call', id, params: { name, arguments: args } });
|
|
77
67
|
const list = (id) => ({ jsonrpc: '2.0', method: 'tools/list', id });
|
|
78
68
|
// Runs `init`, `notifications/initialized`, then `msgs`; returns the tool responses.
|
|
79
|
-
const session = (...msgs) =>
|
|
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
|
+
};
|
|
80
87
|
const resultOf = (resp) => resp.result;
|
|
81
88
|
const item0 = (resp) => resultOf(resp).content[0];
|
|
82
89
|
const textOf = (resp) => item0(resp).text;
|
|
@@ -87,10 +94,123 @@ const binarySample = base64Encode(vec8(0x2an));
|
|
|
87
94
|
// A base64 blob whose leading bytes are the PNG magic-byte signature, so
|
|
88
95
|
// `cas_get` detects its type and returns base64 with mime_type image/png.
|
|
89
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
|
+
};
|
|
90
113
|
// ── Tests ───────────────────────────────────────────────────────────────────────
|
|
114
|
+
// A blob larger than one read chunk (> 128 KiB) used to fail metadata-only
|
|
115
|
+
// cas_get because `collectRead` overflowed `maxLength`. With `detectStream`,
|
|
116
|
+
// content:false returns correct metadata regardless of size. Each chunk is
|
|
117
|
+
// exactly `maxLengthBytes` (the largest single `Vec` the runtime allows), so the
|
|
118
|
+
// two-chunk blob spans two read chunks without any one `Vec` exceeding the cap.
|
|
119
|
+
const largeMultiChunkBlobMeta = (chunk0, chunk1, expectedType, expectedMime) => () => {
|
|
120
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'big': [chunk0, chunk1] } } } };
|
|
121
|
+
const [addResp] = runSessionVirtual(root)([
|
|
122
|
+
init, initialized,
|
|
123
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
|
|
124
|
+
]).slice(2);
|
|
125
|
+
assert(!resultOf(addResp).isError);
|
|
126
|
+
const hash = textOf(addResp);
|
|
127
|
+
const [, metaResp] = runSessionVirtual(root)([
|
|
128
|
+
init, initialized,
|
|
129
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
|
|
130
|
+
call(3, 'cas_get', { hash }),
|
|
131
|
+
]).slice(2);
|
|
132
|
+
assert(!resultOf(metaResp).isError);
|
|
133
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
134
|
+
assertEq(meta.type, expectedType);
|
|
135
|
+
assertEq(meta.mime_type, expectedMime);
|
|
136
|
+
assertEq(meta.length, Number((length(chunk0) + length(chunk1)) / 8n));
|
|
137
|
+
assertEq(meta.content, undefined);
|
|
138
|
+
};
|
|
139
|
+
// A full `maxLengthBytes`-long chunk of repeated ASCII 'a' — valid UTF-8.
|
|
140
|
+
const asciiChunk = repeat(maxLengthBytes)(vec8(0x61n));
|
|
141
|
+
// The same length, but repeated 2-byte "é" (C3 A9) — valid UTF-8 of multi-byte symbols.
|
|
142
|
+
const symbolChunk = repeat(maxLengthBytes / 2n)(u8ListToVec(msb)([0xc3, 0xa9]));
|
|
143
|
+
// A full chunk of 0xFF — an invalid UTF-8 lead byte, so binary (no magic match).
|
|
144
|
+
const binaryChunk = repeat(maxLengthBytes)(vec8(0xffn));
|
|
145
|
+
// 33,000 `"` bytes (0x22): valid UTF-8, so `cas_get` classifies it as text and
|
|
146
|
+
// takes the double-JSON-escaping path (`toJson` builds the inner CAS JSON, then
|
|
147
|
+
// the outer `stringifyJson` embeds that as the `text` field). Each quote costs
|
|
148
|
+
// 4 final bytes (2 on each escaping pass), so 33,000 of them alone push the
|
|
149
|
+
// encoded line past `maxLength` (131,072 bytes) — well above the strict 32,769
|
|
150
|
+
// minimum needed, without approaching any other size-related edge case.
|
|
151
|
+
const quotesChunk = repeat(33000n)(vec8(0x22n));
|
|
91
152
|
export const proof = {
|
|
153
|
+
// Both chunks repeated ASCII → text/plain, no error on a > 128 KiB blob.
|
|
154
|
+
getMetaLargeMultiChunkBlobNoError: largeMultiChunkBlobMeta(asciiChunk, asciiChunk, 'text', 'text/plain'),
|
|
155
|
+
// Both chunks valid UTF-8 sequences of multi-byte symbols → text/plain.
|
|
156
|
+
getMetaLargeMultiChunkUtf8Symbols: largeMultiChunkBlobMeta(symbolChunk, symbolChunk, 'text', 'text/plain'),
|
|
157
|
+
// First chunk valid UTF-8, second chunk binary → base64/octet-stream. The
|
|
158
|
+
// streaming validator must see the trailing binary chunk to reject text.
|
|
159
|
+
getMetaLargeMultiChunkTextThenBinary: largeMultiChunkBlobMeta(asciiChunk, binaryChunk, 'base64', 'application/octet-stream'),
|
|
160
|
+
// content:true on a present blob larger than `maxLength` reports a distinct
|
|
161
|
+
// "too large" error, not "no such hash" — the blob exists, it just can't be
|
|
162
|
+
// buffered inline. The metadata-only path (above) still returns its size/type.
|
|
163
|
+
getContentLargeBlobTooLargeError: () => {
|
|
164
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'big': [asciiChunk, asciiChunk] } } } };
|
|
165
|
+
const [addResp] = runSessionVirtual(root)([
|
|
166
|
+
init, initialized,
|
|
167
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
|
|
168
|
+
]).slice(2);
|
|
169
|
+
const hash = textOf(addResp);
|
|
170
|
+
const [, getResp] = runSessionVirtual(root)([
|
|
171
|
+
init, initialized,
|
|
172
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
|
|
173
|
+
call(3, 'cas_get', { hash, content: true }),
|
|
174
|
+
]).slice(2);
|
|
175
|
+
assertEq(resultOf(getResp).isError, true);
|
|
176
|
+
const text = textOf(getResp);
|
|
177
|
+
assert(text.includes('too large'));
|
|
178
|
+
assert(!text.includes('no such hash'));
|
|
179
|
+
},
|
|
180
|
+
// content:true on a genuinely absent hash still reports "no such hash" — the
|
|
181
|
+
// oversized branch above must not absorb the absent case.
|
|
182
|
+
getContentMissingHashIsError: () => {
|
|
183
|
+
const absent = vecToCBase32(vec8(0x55n));
|
|
184
|
+
const [resp] = session(call(2, 'cas_get', { hash: absent, content: true }));
|
|
185
|
+
assertEq(resultOf(resp).isError, true);
|
|
186
|
+
assert(textOf(resp).includes('no such hash'));
|
|
187
|
+
},
|
|
188
|
+
// A text blob made entirely of `"` characters keeps the *raw* content
|
|
189
|
+
// (33,000 bytes) well under `maxLengthBytes`, but each quote costs 2 bytes on
|
|
190
|
+
// the inner CAS-JSON escape and 4 bytes once the outer JSON-RPC envelope
|
|
191
|
+
// re-escapes that backslash-quote pair — no `cas_get`-level size guard could
|
|
192
|
+
// see this coming, only the transport's real encode attempt can. Uploaded via
|
|
193
|
+
// `type: 'url'` (a file, not an inlined JSON string) so the test exercises
|
|
194
|
+
// `cas_get`'s response-side escaping without also paying for parsing a huge
|
|
195
|
+
// inline `content` argument on the request side. Confirms `writeResponse`'s
|
|
196
|
+
// `tryUtf8` fallback (not a crash) handles double-escaping overflow, again
|
|
197
|
+
// preserving the request's `id`.
|
|
198
|
+
getContentDoubleEscapedOverflowWritesInternalError: () => {
|
|
199
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'q': [quotesChunk] } } } };
|
|
200
|
+
const [addResp] = runStdio(root)([
|
|
201
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/q', type: 'url' }),
|
|
202
|
+
]);
|
|
203
|
+
const hash = textOf(addResp);
|
|
204
|
+
const [, getResp] = runStdio(root)([
|
|
205
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/q', type: 'url' }),
|
|
206
|
+
call(3, 'cas_get', { hash, content: true }),
|
|
207
|
+
]);
|
|
208
|
+
const err = getResp;
|
|
209
|
+
assertEq(err.error?.code, -32603);
|
|
210
|
+
assertEq(err.id, 3);
|
|
211
|
+
},
|
|
92
212
|
toolsListAdvertisesThreeTools: () => {
|
|
93
|
-
const [resp] =
|
|
213
|
+
const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
|
|
94
214
|
const tools = resp.result.tools;
|
|
95
215
|
assertEq(tools.length, 3);
|
|
96
216
|
assertEq(tools.map(t => t.name).join(','), 'cas_add,cas_get,cas_list');
|
|
@@ -190,6 +310,37 @@ export const proof = {
|
|
|
190
310
|
const [resp] = session(call(2, 'cas_add', { content: 'A', type: 'base64' }));
|
|
191
311
|
assertEq(resultOf(resp).isError, true);
|
|
192
312
|
},
|
|
313
|
+
// 174_764 'A' chars × 6 bits = 1_048_584 bits, 8 over maxLength — base64Decode
|
|
314
|
+
// returns null and cas_add surfaces an error.
|
|
315
|
+
addBase64OverLimitIsError: () => {
|
|
316
|
+
const [resp] = session(call(2, 'cas_add', { content: 'A'.repeat(174_764), type: 'base64' }));
|
|
317
|
+
assertEq(resultOf(resp).isError, true);
|
|
318
|
+
assert(textOf(resp).includes('too large or malformed'));
|
|
319
|
+
},
|
|
320
|
+
// One ASCII byte past maxLengthBytes — tryUtf8 returns null and cas_add
|
|
321
|
+
// surfaces an error.
|
|
322
|
+
addTextOverLimitIsError: () => {
|
|
323
|
+
const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes) + 1) }));
|
|
324
|
+
assertEq(resultOf(resp).isError, true);
|
|
325
|
+
assert(textOf(resp).includes('too large or malformed'));
|
|
326
|
+
},
|
|
327
|
+
// Exactly maxLengthBytes (131,072) ASCII bytes — tryUtf8 returns a non-null
|
|
328
|
+
// Vec (len === maxLength, not over), so cas_add stores cleanly.
|
|
329
|
+
addTextAtLimitSucceeds: () => {
|
|
330
|
+
const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes)) }));
|
|
331
|
+
assert(!resultOf(resp).isError);
|
|
332
|
+
assert(textOf(resp).length > 0);
|
|
333
|
+
},
|
|
334
|
+
// base64 of exactly maxLengthBytes — currently returns isError because
|
|
335
|
+
// base64Decode measures the raw pre-trim body (1_048_578 bits) against
|
|
336
|
+
// maxLength before stripping the 2 padding bits that would land it exactly at
|
|
337
|
+
// maxLength. Assert the current failing behavior; flip to a success assertion
|
|
338
|
+
// once `fs/base64/todo/decode-rejects-max-size-input.md` is fixed.
|
|
339
|
+
addBase64AtLimitIsError: () => {
|
|
340
|
+
const [resp] = session(call(2, 'cas_add', { content: base64OfA(maxLengthBytes), type: 'base64' }));
|
|
341
|
+
assertEq(resultOf(resp).isError, true);
|
|
342
|
+
assert(textOf(resp).includes('too large or malformed'));
|
|
343
|
+
},
|
|
193
344
|
getUnterminatedHashIsError: () => {
|
|
194
345
|
const [resp] = session(call(2, 'cas_get', { hash: '0' }));
|
|
195
346
|
assertEq(resultOf(resp).isError, true);
|
|
@@ -278,7 +429,7 @@ export const proof = {
|
|
|
278
429
|
const meta = JSON.parse(textOf(metaResp));
|
|
279
430
|
assertEq(meta.mime_type, 'text/plain');
|
|
280
431
|
assertEq(meta.type, 'text');
|
|
281
|
-
assertEq(meta.length,
|
|
432
|
+
assertEq(meta.length, 12);
|
|
282
433
|
assertEq(meta.content, undefined);
|
|
283
434
|
},
|
|
284
435
|
getMetaBinaryBlob: () => {
|
|
@@ -304,15 +455,18 @@ export const proof = {
|
|
|
304
455
|
assertEq(meta.type, 'base64');
|
|
305
456
|
assertEq(meta.content, undefined);
|
|
306
457
|
},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
458
|
+
// A NUL-bearing blob is valid UTF-8 yet binary: cas_get must report
|
|
459
|
+
// base64/octet-stream, not text/plain.
|
|
460
|
+
getMetaOctetStreamForNulBlob: () => {
|
|
461
|
+
const nulContent = u8ListToVec(msb)([0x00, 0x00, 0x00]);
|
|
462
|
+
const nulB64 = base64Encode(nulContent);
|
|
463
|
+
const [addResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }));
|
|
311
464
|
const hash = textOf(addResp);
|
|
312
|
-
const [, metaResp] = session(call(2, 'cas_add', { content:
|
|
465
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
313
466
|
assert(!resultOf(metaResp).isError);
|
|
314
467
|
const meta = JSON.parse(textOf(metaResp));
|
|
315
|
-
assertEq(meta.
|
|
468
|
+
assertEq(meta.mime_type, 'application/octet-stream');
|
|
469
|
+
assertEq(meta.type, 'base64');
|
|
316
470
|
},
|
|
317
471
|
getMetaMissingHashIsError: () => {
|
|
318
472
|
const absent = vecToCBase32(vec8(0x77n));
|
|
@@ -357,4 +511,17 @@ export const proof = {
|
|
|
357
511
|
assert(resultOf(resp).isError === true);
|
|
358
512
|
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
359
513
|
},
|
|
514
|
+
// cas_get with content:true on octet-stream (no magic bytes, not UTF-8) returns inline base64.
|
|
515
|
+
getOctetStreamWithContentIncludesBase64: () => {
|
|
516
|
+
const binaryContent = u8ListToVec(msb)([0xFF, 0xFE, 0x00, 0x01]);
|
|
517
|
+
const binaryB64 = base64Encode(binaryContent);
|
|
518
|
+
const [addResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }));
|
|
519
|
+
const hash = textOf(addResp);
|
|
520
|
+
const [, getResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
521
|
+
assert(!resultOf(getResp).isError);
|
|
522
|
+
const result = JSON.parse(textOf(getResp));
|
|
523
|
+
assertEq(result.mime_type, 'application/octet-stream');
|
|
524
|
+
assertEq(result.type, 'base64');
|
|
525
|
+
assertEq(result.content, binaryB64);
|
|
526
|
+
},
|
|
360
527
|
};
|
package/fs/cas/module.f.d.ts
CHANGED
|
@@ -6,29 +6,49 @@
|
|
|
6
6
|
import { type Sha2 } from '../crypto/sha2/module.f.ts';
|
|
7
7
|
import { type Vec } from '../types/bit_vec/module.f.ts';
|
|
8
8
|
import { type Effect, type Operation } from '../effects/module.f.ts';
|
|
9
|
-
import { type Access, type
|
|
10
|
-
import { type
|
|
11
|
-
import type { MemOp } from '../effects/memory/module.f.ts';
|
|
9
|
+
import { type Access, type CreateExclusive, type IoResult, type Mkdir, type Now, type RandomInt, type ReadBytes, type Readdir, type Rename, type Rm, type Stat, type WriteBytes } from '../effects/node/module.f.ts';
|
|
10
|
+
import { type List } from '../effects/list/module.f.ts';
|
|
12
11
|
/** Converts a content key to its sharded relative CAS file path. */
|
|
13
12
|
export declare const toPath: (key: Vec) => string;
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The filesystem effects the streaming CAS performs: `read` pulls shards
|
|
15
|
+
* (`ReadBytes`); `list` walks the store (`Access`/`Readdir`); `write` runs the
|
|
16
|
+
* lock-free staging upload (`Mkdir`/`CreateExclusive`/`WriteBytes`/`Rename`/`Rm`/
|
|
17
|
+
* `Stat`, lease deadlines from `Now`, staging names from `RandomInt`) and GC's
|
|
18
|
+
* expired staging files (`Readdir`/`Rm`).
|
|
19
|
+
*/
|
|
20
|
+
export type FileCasOperation = ReadBytes | Mkdir | Readdir | Access | Rename | Rm | RandomInt | Now | CreateExclusive | WriteBytes | Stat | Now | Readdir | Rm;
|
|
15
21
|
export type Cas<O extends Operation> = {
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Streams the content for `hash` out in `<=128 KiB` chunks. Every pull yields an
|
|
24
|
+
* explicit `ok(chunk)` or `error` item, so a missing shard or I/O error is a distinct
|
|
25
|
+
* error *item* in the stream, never collapsed into end-of-stream (`undefined`).
|
|
26
|
+
*/
|
|
27
|
+
readonly read: (hash: Vec) => List<O, IoResult<Vec>>;
|
|
28
|
+
/**
|
|
29
|
+
* Consumes a chunk stream — each item `ok(chunk)` or `error` — hashing incrementally,
|
|
30
|
+
* and returns the content address. An error item aborts the upload.
|
|
31
|
+
*/
|
|
32
|
+
readonly write: <O1 extends Operation>(payload: List<O1, IoResult<Vec>>) => Effect<O | O1, IoResult<Vec>>;
|
|
20
33
|
/** Lists all stored content hashes. */
|
|
21
34
|
readonly list: () => Effect<O, readonly Vec[]>;
|
|
22
35
|
};
|
|
36
|
+
export type FileCas = Cas<FileCasOperation> & {
|
|
37
|
+
url: (v: Vec) => string;
|
|
38
|
+
};
|
|
23
39
|
/**
|
|
24
40
|
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
25
41
|
*/
|
|
26
|
-
export declare const fileCas: (sha2: Sha2) => (path: string) =>
|
|
42
|
+
export declare const fileCas: (sha2: Sha2) => (path: string) => FileCas;
|
|
43
|
+
/**
|
|
44
|
+
* Streams the file at `path` through `cas.write`, returning the content hash.
|
|
45
|
+
* Both the CLI `cas add` and the MCP `add` delegate to this; the MCP layer
|
|
46
|
+
* additionally deletes the source file on success.
|
|
47
|
+
*/
|
|
48
|
+
export declare const casAddFile: <O extends Operation>(cas: Cas<O>) => (path: string) => Effect<O | ReadBytes, IoResult<Vec>>;
|
|
27
49
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
50
|
+
* Upload pipeline: streams `fileName` from `~/cas_upload/` through `casAddFile`,
|
|
51
|
+
* then deletes the source on success. On failure the source is left in place so
|
|
52
|
+
* the upload can be retried; `write` already cleans up its own partial staging file.
|
|
31
53
|
*/
|
|
32
|
-
export declare const casUpload: (home: string) => (fileName: string) => Effect<
|
|
33
|
-
export declare const commands: Commands<FileCasOperation | Write | All | MemOp | Read>;
|
|
34
|
-
export declare const main: (options: NodeProgramOptions) => Effect<All | Read | Write | FileCasOperation | MemOp, number>;
|
|
54
|
+
export declare const casUpload: (home: string) => (fileName: string) => Effect<FileCasOperation, IoResult<Vec>>;
|