functionalscript 0.32.3 → 0.33.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/fs/bnf/data/proof.f.d.ts +3 -0
- package/fs/bnf/data/proof.f.js +7 -0
- package/fs/cas/mcp/module.f.d.ts +4 -4
- package/fs/cas/mcp/module.f.js +21 -17
- package/fs/cas/mcp/proof.f.d.ts +1 -0
- package/fs/cas/mcp/proof.f.js +62 -42
- package/fs/cas/module.f.d.ts +13 -21
- package/fs/cas/module.f.js +100 -54
- package/fs/cas/proof.f.js +15 -24
- package/fs/ci/config/module.f.d.ts +6 -6
- package/fs/ci/config/module.f.js +6 -6
- package/fs/ci/proof.f.js +8 -6
- package/fs/cli/proof.f.js +2 -12
- package/fs/djs/parser/module.f.d.ts +0 -5
- package/fs/djs/proof.f.js +5 -6
- package/fs/djs/serializer/module.f.d.ts +1 -3
- package/fs/djs/tokenizer-new/module.f.js +2 -1
- package/fs/djs/tokenizer-new/proof.f.d.ts +1 -0
- package/fs/djs/tokenizer-new/proof.f.js +101 -67
- package/fs/djs/transpiler/proof.f.js +10 -10
- package/fs/effects/node/module.f.d.ts +8 -2
- package/fs/effects/node/module.f.js +3 -0
- package/fs/effects/node/module.js +21 -1
- package/fs/effects/node/proof.f.d.ts +15 -0
- package/fs/effects/node/proof.f.js +137 -26
- package/fs/effects/node/virtual/module.f.d.ts +18 -2
- package/fs/effects/node/virtual/module.f.js +185 -10
- package/fs/effects/node/virtual/proof.f.d.ts +14 -0
- package/fs/effects/node/virtual/proof.f.js +98 -5
- package/fs/emergent_testing/proof.f.js +3 -11
- package/fs/fjs/module.f.js +3 -3
- package/fs/fjs/proof.f.js +2 -11
- package/fs/js/tokenizer/module.f.d.ts +5 -0
- package/fs/js/tokenizer/module.f.js +13 -0
- package/fs/json/serializer/module.f.js +0 -1
- package/fs/text/sgr/proof.f.js +2 -11
- package/fs/types/bigint/module.f.d.ts +8 -15
- package/fs/types/bigint/module.f.js +9 -22
- package/fs/types/bigint/proof.f.d.ts +0 -2
- package/fs/types/bigint/proof.f.js +6 -16
- package/package.json +2 -2
package/fs/bnf/data/proof.f.d.ts
CHANGED
package/fs/bnf/data/proof.f.js
CHANGED
|
@@ -763,5 +763,12 @@ export const proof = {
|
|
|
763
763
|
'digit': 'digit',
|
|
764
764
|
}
|
|
765
765
|
};
|
|
766
|
+
},
|
|
767
|
+
throw: {
|
|
768
|
+
ambiguousVariantDispatch: () => {
|
|
769
|
+
// Two alternatives covering the same code point — dispatch merge throws.
|
|
770
|
+
const conflictRule = { 'a': range('AA'), 'b': range('AA') };
|
|
771
|
+
dispatchMap(toData(conflictRule)[0]);
|
|
772
|
+
}
|
|
766
773
|
}
|
|
767
774
|
};
|
package/fs/cas/mcp/module.f.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type Effect, type Operation } from '../../effects/module.f.ts';
|
|
2
2
|
import { type MemOp } from '../../effects/memory/module.f.ts';
|
|
3
3
|
import { type Vec } from '../../types/bit_vec/module.f.ts';
|
|
4
|
-
import { type Read, type
|
|
4
|
+
import { type Mkdir, type RandomInt, type Read, type ReadBytes, type Rename, type Write } from '../../effects/node/module.f.ts';
|
|
5
5
|
import { type McpConfig, type McpHandlers } from '../../mcp/module.f.ts';
|
|
6
|
-
import type
|
|
6
|
+
import { type Cas } from '../module.f.ts';
|
|
7
7
|
/** Arguments for `cas_add`: content to store, with optional encoding type. */
|
|
8
8
|
export declare const casAddArgs: {
|
|
9
9
|
readonly content: import("../../types/rtti/module.f.ts").String;
|
|
@@ -24,7 +24,7 @@ export declare const casListArgs: {};
|
|
|
24
24
|
* the blob on the local filesystem. When absent (e.g. memory-backed tests),
|
|
25
25
|
* `url` is omitted.
|
|
26
26
|
*/
|
|
27
|
-
export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => McpHandlers<
|
|
27
|
+
export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => McpHandlers<Mkdir | Rename | RandomInt | ReadBytes | O>;
|
|
28
28
|
/**
|
|
29
29
|
* Static MCP configuration for the CAS server: advertises the `tools`
|
|
30
30
|
* capability, identifies the server, and pins the protocol version.
|
|
@@ -37,4 +37,4 @@ export declare const casConfig: McpConfig;
|
|
|
37
37
|
*
|
|
38
38
|
* When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
|
|
39
39
|
*/
|
|
40
|
-
export declare const casMcpServer: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp |
|
|
40
|
+
export declare const casMcpServer: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp | Mkdir | Rename | RandomInt | ReadBytes | O, void>;
|
package/fs/cas/mcp/module.f.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* ## Tools
|
|
11
11
|
*
|
|
12
|
-
* | Tool | args |
|
|
12
|
+
* | Tool | args | action | result |
|
|
13
13
|
* |------------|-------------------------------|------------------|-------------------------------------|
|
|
14
|
-
* | `cas_add` | `{ content, type? }` |
|
|
14
|
+
* | `cas_add` | `{ content, type? }` | write/upload | hash (cBase32) |
|
|
15
15
|
* | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mime_type,type[,content]}` |
|
|
16
16
|
* | `cas_list` | `{}` | `c.list()` | hashes, one per line |
|
|
17
17
|
*
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
* - `'base64'`: `content` is RFC 4648 base64, decoded to bytes before storage.
|
|
25
25
|
* Use this for pre-encoded binary payloads.
|
|
26
26
|
* - `'url'`: `content` is a filesystem path within `$HOME/cas_upload/`; the server
|
|
27
|
-
*
|
|
28
|
-
* `$HOME/cas_upload/` or containing `..` are rejected for security.
|
|
27
|
+
* moves the file via the streaming move-hash-move pipeline (no size limit).
|
|
28
|
+
* Paths outside `$HOME/cas_upload/` or containing `..` are rejected for security.
|
|
29
29
|
*
|
|
30
30
|
* ## `cas_get` output
|
|
31
31
|
*
|
|
@@ -71,9 +71,10 @@ import { decode as base64Decode, encode as base64Encode } from "../../base64/mod
|
|
|
71
71
|
import { utf8 } from "../../text/module.f.js";
|
|
72
72
|
import { detect } from "../../mime/module.f.js";
|
|
73
73
|
import { length as bitVecLength } from "../../types/bit_vec/module.f.js";
|
|
74
|
-
import {
|
|
74
|
+
import {} from "../../effects/node/module.f.js";
|
|
75
75
|
import { stdioTransport } from "../../mcp/stdio/module.f.js";
|
|
76
76
|
import { mcpStep, uninitializedState, toolEntry, fromRegistry, errorResult, } from "../../mcp/module.f.js";
|
|
77
|
+
import { casUpload } from "../module.f.js";
|
|
77
78
|
import { fromVec } from "../../text/utf8/module.f.js";
|
|
78
79
|
// ── Argument schemas (declared once, used for both inputSchema and validate) ─────
|
|
79
80
|
/** Arguments for `cas_add`: content to store, with optional encoding type. */
|
|
@@ -87,19 +88,20 @@ export const casListArgs = {};
|
|
|
87
88
|
const casToolRegistry = (c, home, toUrl) => {
|
|
88
89
|
const casUploadDir = `${home}/cas_upload`;
|
|
89
90
|
return [
|
|
90
|
-
toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to
|
|
91
|
+
toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to stream a file from $HOME/cas_upload/ (no size limit); omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
|
|
92
|
+
// type:'url' — streaming move-hash-move pipeline (no size limit)
|
|
93
|
+
if (type === 'url') {
|
|
94
|
+
if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
|
|
95
|
+
return pure(errorResult(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`));
|
|
96
|
+
}
|
|
97
|
+
const fileName = content.slice(`${casUploadDir}/`.length);
|
|
98
|
+
return casUpload(home)(fileName).step(result => pure(result[0] === 'error'
|
|
99
|
+
? errorResult(`upload failed: ${result[1]}`)
|
|
100
|
+
: okResult(vecToCBase32(result[1]))));
|
|
101
|
+
}
|
|
102
|
+
// type:'text' or 'base64' — resolve content to Vec, store via c.write()
|
|
91
103
|
let x;
|
|
92
104
|
switch (type) {
|
|
93
|
-
case 'url':
|
|
94
|
-
if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
|
|
95
|
-
x = pure(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`);
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
x = readFile(content).step(([t, v]) => pure(t === 'error'
|
|
99
|
-
? `cannot read file: ${content}: ${v}`
|
|
100
|
-
: v));
|
|
101
|
-
}
|
|
102
|
-
break;
|
|
103
105
|
case 'base64':
|
|
104
106
|
const value = base64Decode(content);
|
|
105
107
|
x = pure(value === null ? `invalid base64 content: ${content}` : value);
|
|
@@ -110,7 +112,9 @@ const casToolRegistry = (c, home, toUrl) => {
|
|
|
110
112
|
}
|
|
111
113
|
return x.step(value => typeof value === 'string'
|
|
112
114
|
? pure(errorResult(value))
|
|
113
|
-
: c.write(value).step(hash => pure(
|
|
115
|
+
: c.write(value).step(hash => pure(hash === undefined
|
|
116
|
+
? errorResult('write')
|
|
117
|
+
: okResult(vecToCBase32(hash)))));
|
|
114
118
|
}),
|
|
115
119
|
toolEntry('cas_get', 'Inspect a blob by hash. Always returns JSON {length,mime_type,type[,url]} where type is "text" or "base64". Pass content:true to also include the inline content string.', casGetArgs, r => {
|
|
116
120
|
const key = cBase32ToVec(r.hash);
|
package/fs/cas/mcp/proof.f.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare const proof: {
|
|
|
27
27
|
getMetaNoUrlWhenToUrlAbsent: () => void;
|
|
28
28
|
getMetaMissingHashIsError: () => void;
|
|
29
29
|
getMetaInvalidHashIsError: () => void;
|
|
30
|
+
addUrlFromSubdirectorySucceeds: () => void;
|
|
30
31
|
addUrlFromApprovedDirectorySucceeds: () => void;
|
|
31
32
|
addUrlFromRandomDirectoryIsRejected: () => void;
|
|
32
33
|
addUrlWithPathTraversalIsRejected: () => void;
|
package/fs/cas/mcp/proof.f.js
CHANGED
|
@@ -5,13 +5,15 @@ import { asBase, asNominal, create, read, write } from "../../effects/memory/mod
|
|
|
5
5
|
import { msb, u8ListToVec, vec8 } 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 { sha256 } from "../../crypto/sha2/module.f.js";
|
|
8
|
+
import { computeSync, sha256 } from "../../crypto/sha2/module.f.js";
|
|
9
9
|
import { utf8 } from "../../text/module.f.js";
|
|
10
|
-
import {
|
|
10
|
+
import { fileCas } from "../module.f.js";
|
|
11
11
|
import { mcpStep, uninitializedState, } from "../../mcp/module.f.js";
|
|
12
12
|
import {} from "../../effects/node/module.f.js";
|
|
13
|
+
import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
|
|
13
14
|
import { casConfig, casMcpHandlers } from "./module.f.js";
|
|
14
|
-
|
|
15
|
+
import { ok as resultOk } from "../../types/result/module.f.js";
|
|
16
|
+
const initialTestState = { memory: { next: 0, values: {} } };
|
|
15
17
|
const mock = {
|
|
16
18
|
memCreate: value => state => {
|
|
17
19
|
const id = `k${state.memory.next}`;
|
|
@@ -26,18 +28,20 @@ const mock = {
|
|
|
26
28
|
const id = asBase(key);
|
|
27
29
|
return [{ ...state, memory: { ...state.memory, values: { ...state.memory.values, [id]: value } } }, undefined];
|
|
28
30
|
},
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
: [state, ['error', new Error(`ENOENT: ${path}`)]];
|
|
34
|
-
},
|
|
31
|
+
mkdir: (_path, _opts) => state => [state, resultOk(undefined)],
|
|
32
|
+
rename: (_src, _dst) => _ => { throw new Error('rename not supported in memory mock'); },
|
|
33
|
+
readBytes: (_path, _offset, _size) => _ => { throw new Error('readBytes not supported in memory mock'); },
|
|
34
|
+
randomInt: () => _ => { throw new Error('randomInt not supported in memory mock'); },
|
|
35
35
|
};
|
|
36
36
|
const runMem = (effect) => run(mock)(initialTestState)(effect)[1];
|
|
37
|
-
const
|
|
38
|
-
const memKvStore = (mapKey) => ({
|
|
37
|
+
const memCas = (mapKey) => ({
|
|
39
38
|
read: (key) => read(mapKey).step(m => pure(m[vecToCBase32(key)]?.[1])),
|
|
40
|
-
write: (
|
|
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
|
+
},
|
|
41
45
|
list: () => read(mapKey).step(m => pure(Object.values(m).map(([k]) => k))),
|
|
42
46
|
});
|
|
43
47
|
// ── Session driver ──────────────────────────────────────────────────────────────
|
|
@@ -50,16 +54,21 @@ const feed = (step) => (msgs) => {
|
|
|
50
54
|
};
|
|
51
55
|
// Runs a full session over a fresh in-memory CAS, returning all responses.
|
|
52
56
|
const runSession = (msgs, home = '/home/user') => runMem(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
53
|
-
const c =
|
|
54
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
55
|
-
return feed(step)(msgs);
|
|
56
|
-
})));
|
|
57
|
-
// Runs a session with a mocked filesystem (for cas_add with type:'url' tests).
|
|
58
|
-
const runSessionWithFiles = (files, home = '/home/user') => (msgs) => runMemWithFiles(files)(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
59
|
-
const c = cas(sha256)(memKvStore(mapKey));
|
|
57
|
+
const c = memCas(mapKey);
|
|
60
58
|
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
61
59
|
return feed(step)(msgs);
|
|
62
60
|
})));
|
|
61
|
+
// Runs a session backed by the virtual node runner (for cas_upload which uses
|
|
62
|
+
// Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
|
|
63
|
+
// the same filesystem-backed CAS.
|
|
64
|
+
const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
|
|
65
|
+
const effect = create(uninitializedState).step(sessionKey => {
|
|
66
|
+
const c = fileCas(sha256)(home);
|
|
67
|
+
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
68
|
+
return feed(step)(msgs);
|
|
69
|
+
});
|
|
70
|
+
return virtual({ ...emptyState, root })(effect)[1];
|
|
71
|
+
};
|
|
63
72
|
// ── Messages ────────────────────────────────────────────────────────────────────
|
|
64
73
|
const init = { jsonrpc: '2.0', method: 'initialize', id: 1,
|
|
65
74
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'c', version: '0' } } };
|
|
@@ -211,10 +220,11 @@ export const proof = {
|
|
|
211
220
|
assert(!('error' in resp));
|
|
212
221
|
assert('result' in resp);
|
|
213
222
|
},
|
|
214
|
-
// cas_add with type:'url'
|
|
223
|
+
// cas_add with type:'url' streams a file from /home/user/cas_upload/ into CAS.
|
|
215
224
|
addUrlStoresFileAndReturnsHash: () => {
|
|
216
225
|
const fileContent = utf8('hello from file');
|
|
217
|
-
const
|
|
226
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'hello.txt': [fileContent] } } } };
|
|
227
|
+
const [addUrlResp] = runSessionVirtual(root)([
|
|
218
228
|
init, initialized,
|
|
219
229
|
call(2, 'cas_add', { content: '/home/user/cas_upload/hello.txt', type: 'url' }),
|
|
220
230
|
]).slice(2);
|
|
@@ -223,23 +233,26 @@ export const proof = {
|
|
|
223
233
|
},
|
|
224
234
|
addUrlRoundTrips: () => {
|
|
225
235
|
const fileContent = utf8('round-trip content');
|
|
226
|
-
const
|
|
236
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'rt.txt': [fileContent] } } } };
|
|
237
|
+
// First pass: add to get the hash (deterministic for same content).
|
|
238
|
+
const [addResp] = runSessionVirtual(root)([
|
|
227
239
|
init, initialized,
|
|
228
240
|
call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
|
|
229
241
|
]).slice(2);
|
|
230
|
-
const hash = textOf(
|
|
231
|
-
|
|
242
|
+
const hash = textOf(addResp);
|
|
243
|
+
// Second pass: add again + get in one session (file re-present in fresh virtual state).
|
|
244
|
+
const [, getResp] = runSessionVirtual(root)([
|
|
232
245
|
init, initialized,
|
|
233
246
|
call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
|
|
234
247
|
call(3, 'cas_get', { hash, content: true }),
|
|
235
248
|
]).slice(2);
|
|
236
|
-
assert(!resultOf(
|
|
237
|
-
const result = JSON.parse(textOf(
|
|
249
|
+
assert(!resultOf(getResp).isError);
|
|
250
|
+
const result = JSON.parse(textOf(getResp));
|
|
238
251
|
assertEq(result.type, 'text');
|
|
239
252
|
assertEq(result.content, 'round-trip content');
|
|
240
253
|
},
|
|
241
254
|
addUrlMissingFileIsError: () => {
|
|
242
|
-
const [resp] =
|
|
255
|
+
const [resp] = runSessionVirtual({})([
|
|
243
256
|
init, initialized,
|
|
244
257
|
call(2, 'cas_add', { content: '/home/user/cas_upload/nonexistent.txt', type: 'url' }),
|
|
245
258
|
]).slice(2);
|
|
@@ -248,18 +261,21 @@ export const proof = {
|
|
|
248
261
|
// cas_get without content:true returns only metadata.
|
|
249
262
|
getMetaReturnsLengthAndMimeType: () => {
|
|
250
263
|
const fileContent = utf8('text content');
|
|
251
|
-
const
|
|
264
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'f': [fileContent] } } } };
|
|
265
|
+
// First pass: add to get the hash.
|
|
266
|
+
const [addResp] = runSessionVirtual(root)([
|
|
252
267
|
init, initialized,
|
|
253
268
|
call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
|
|
254
269
|
]).slice(2);
|
|
255
270
|
const hash = textOf(addResp);
|
|
256
|
-
|
|
271
|
+
// Second pass: add + get metadata.
|
|
272
|
+
const [, metaResp] = runSessionVirtual(root)([
|
|
257
273
|
init, initialized,
|
|
258
274
|
call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
|
|
259
275
|
call(3, 'cas_get', { hash }),
|
|
260
276
|
]).slice(2);
|
|
261
|
-
assert(!resultOf(
|
|
262
|
-
const meta = JSON.parse(textOf(
|
|
277
|
+
assert(!resultOf(metaResp).isError);
|
|
278
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
263
279
|
assertEq(meta.mime_type, 'text/plain');
|
|
264
280
|
assertEq(meta.type, 'text');
|
|
265
281
|
assertEq(meta.length, Number(BigInt(/* 'text content'.length */ 12)));
|
|
@@ -307,10 +323,22 @@ export const proof = {
|
|
|
307
323
|
const [resp] = session(call(2, 'cas_get', { hash: 'bad!' }));
|
|
308
324
|
assertEq(resultOf(resp).isError, true);
|
|
309
325
|
},
|
|
326
|
+
// cas_add type:'url' with a subdirectory path flattens slashes to '-' in staging.
|
|
327
|
+
addUrlFromSubdirectorySucceeds: () => {
|
|
328
|
+
const fileContent = utf8('nested file content');
|
|
329
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'subdir': { 'file.txt': [fileContent] } } } } };
|
|
330
|
+
const [resp] = runSessionVirtual(root)([
|
|
331
|
+
init, initialized,
|
|
332
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/subdir/file.txt', type: 'url' }),
|
|
333
|
+
]).slice(2);
|
|
334
|
+
assert(!resultOf(resp).isError);
|
|
335
|
+
assert(textOf(resp).length > 0);
|
|
336
|
+
},
|
|
310
337
|
// cas_add with type:'url' accepts paths within /home/user/cas_upload/
|
|
311
338
|
addUrlFromApprovedDirectorySucceeds: () => {
|
|
312
339
|
const fileContent = utf8('approved file');
|
|
313
|
-
const
|
|
340
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'test.txt': [fileContent] } } } };
|
|
341
|
+
const [resp] = runSessionVirtual(root)([
|
|
314
342
|
init, initialized,
|
|
315
343
|
call(2, 'cas_add', { content: '/home/user/cas_upload/test.txt', type: 'url' }),
|
|
316
344
|
]).slice(2);
|
|
@@ -319,21 +347,13 @@ export const proof = {
|
|
|
319
347
|
},
|
|
320
348
|
// cas_add with type:'url' rejects paths outside /home/user/cas_upload/
|
|
321
349
|
addUrlFromRandomDirectoryIsRejected: () => {
|
|
322
|
-
const
|
|
323
|
-
const [resp] = runSessionWithFiles({ '/tmp/secret.txt': fileContent })([
|
|
324
|
-
init, initialized,
|
|
325
|
-
call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }),
|
|
326
|
-
]).slice(2);
|
|
350
|
+
const [resp] = session(call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }));
|
|
327
351
|
assert(resultOf(resp).isError === true);
|
|
328
352
|
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
329
353
|
},
|
|
330
354
|
// cas_add with type:'url' rejects path traversal attempts with ..
|
|
331
355
|
addUrlWithPathTraversalIsRejected: () => {
|
|
332
|
-
const
|
|
333
|
-
const [resp] = runSessionWithFiles({ '/home/user/cas_upload/../../etc/passwd': fileContent })([
|
|
334
|
-
init, initialized,
|
|
335
|
-
call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }),
|
|
336
|
-
]).slice(2);
|
|
356
|
+
const [resp] = session(call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }));
|
|
337
357
|
assert(resultOf(resp).isError === true);
|
|
338
358
|
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
339
359
|
},
|
package/fs/cas/module.f.d.ts
CHANGED
|
@@ -4,39 +4,31 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
import { type Sha2 } from '../crypto/sha2/module.f.ts';
|
|
7
|
-
import type
|
|
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 All, type Mkdir, type NodeProgramOptions, type Read, type Readdir, type ReadFile, type Write, type WriteFile } from '../effects/node/module.f.ts';
|
|
9
|
+
import { type Access, type All, type IoResult, type Mkdir, type NodeProgramOptions, type RandomInt, type Read, type ReadBytes, type Readdir, type ReadFile, type Rename, type Write, type WriteFile } from '../effects/node/module.f.ts';
|
|
10
10
|
import { type Commands } from '../cli/module.f.ts';
|
|
11
11
|
import type { MemOp } from '../effects/memory/module.f.ts';
|
|
12
|
-
export type KvStore<O extends Operation> = {
|
|
13
|
-
/** Reads a value by key; returns `undefined` when the key does not exist. */
|
|
14
|
-
readonly read: (key: Vec) => Effect<O, Vec | undefined>;
|
|
15
|
-
/** Writes a key/value pair to the underlying storage. */
|
|
16
|
-
readonly write: (key: Vec, value: Vec) => Effect<O, void>;
|
|
17
|
-
/** Lists all keys available in the store. */
|
|
18
|
-
readonly list: () => Effect<O, readonly Vec[]>;
|
|
19
|
-
};
|
|
20
|
-
/** A key/value tuple where index `0` is the key and index `1` is the value. */
|
|
21
|
-
export type Kv = readonly [Vec, Vec];
|
|
22
12
|
/** Converts a content key to its sharded relative CAS file path. */
|
|
23
13
|
export declare const toPath: (key: Vec) => string;
|
|
24
|
-
export type
|
|
25
|
-
/**
|
|
26
|
-
* Creates a filesystem-backed key/value store under the provided root path.
|
|
27
|
-
*/
|
|
28
|
-
export declare const fileKvStore: (path: string) => KvStore<FileKvStoreOperation>;
|
|
14
|
+
export type FileCasOperation = ReadFile | Mkdir | WriteFile | Access | Readdir;
|
|
29
15
|
export type Cas<O extends Operation> = {
|
|
30
16
|
/** Reads content by hash; returns `undefined` when not found. */
|
|
31
17
|
readonly read: (key: Vec) => Effect<O, Vec | undefined>;
|
|
32
18
|
/** Stores content and returns its computed hash. */
|
|
33
|
-
readonly write: (value: Vec) => Effect<O, Vec>;
|
|
19
|
+
readonly write: (value: Vec) => Effect<O, Vec | undefined>;
|
|
34
20
|
/** Lists all stored content hashes. */
|
|
35
21
|
readonly list: () => Effect<O, readonly Vec[]>;
|
|
36
22
|
};
|
|
37
23
|
/**
|
|
38
24
|
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
39
25
|
*/
|
|
40
|
-
export declare const
|
|
41
|
-
|
|
42
|
-
|
|
26
|
+
export declare const fileCas: (sha2: Sha2) => (path: string) => Cas<FileCasOperation>;
|
|
27
|
+
/**
|
|
28
|
+
* Move-hash-move upload pipeline: moves `fileName` from `~/cas_upload/` to a
|
|
29
|
+
* random staging path, stream-hashes it, then renames it to its final CAS shard
|
|
30
|
+
* path. Returns `ok(hash)` on success or `error(reason)` on any I/O failure.
|
|
31
|
+
*/
|
|
32
|
+
export declare const casUpload: (home: string) => (fileName: string) => Effect<Mkdir | Rename | RandomInt | ReadBytes, IoResult<Vec>>;
|
|
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>;
|
package/fs/cas/module.f.js
CHANGED
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { computeSync, sha256 } from "../crypto/sha2/module.f.js";
|
|
7
7
|
import { join, normalize, parse } from "../path/module.f.js";
|
|
8
|
+
import { empty, length, maxLengthBytes, msb, vec } from "../types/bit_vec/module.f.js";
|
|
8
9
|
import { cBase32ToVec, vecToCBase32 } from "../cbase32/module.f.js";
|
|
9
|
-
import { forEachStep, pure } from "../effects/module.f.js";
|
|
10
|
-
import { access, errorExit, isNotFound, log, mkdir, readdir, readFile, writeFile } from "../effects/node/module.f.js";
|
|
10
|
+
import { foldStep, forEachStep, pure } from "../effects/module.f.js";
|
|
11
|
+
import { access, errorExit, isNotFound, log, mkdir, randomInt, readBytes, readdir, readFile, rename, writeFile } from "../effects/node/module.f.js";
|
|
11
12
|
import { dispatch } from "../cli/module.f.js";
|
|
12
|
-
import { casMcpServer } from "./mcp/module.f.js";
|
|
13
13
|
import { toOption } from "../types/nullable/module.f.js";
|
|
14
|
-
import { unwrap } from "../types/result/module.f.js";
|
|
14
|
+
import { error, ok, unwrap } from "../types/result/module.f.js";
|
|
15
15
|
import { splitAt } from "../types/string/module.f.js";
|
|
16
|
-
const o = { withFileTypes: true };
|
|
17
16
|
const split2 = splitAt(2);
|
|
18
17
|
const prefix = '.cas';
|
|
19
18
|
/** Converts a content key to its sharded relative CAS file path. */
|
|
@@ -24,55 +23,101 @@ export const toPath = (key) => {
|
|
|
24
23
|
return join(prefix, a, b, c);
|
|
25
24
|
};
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
26
|
+
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
28
27
|
*/
|
|
29
|
-
export const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
28
|
+
export const fileCas = (sha2) => {
|
|
29
|
+
const compute = computeSync(sha2);
|
|
30
|
+
return (path) => {
|
|
31
|
+
const storePrefix = join(path, prefix);
|
|
32
|
+
const normalizedStorePrefix = normalize(storePrefix);
|
|
33
|
+
return {
|
|
34
|
+
// TODO: extend the interface with `Result<Vec, unknown>` instead of `Vec|undefined`.
|
|
35
|
+
read: (key) => readFile(join(path, toPath(key))).step(([t, v]) => pure(t === 'ok' ? v : undefined)),
|
|
36
|
+
write: (value) => {
|
|
37
|
+
const hash = compute([value]);
|
|
38
|
+
const p = toPath(hash);
|
|
39
|
+
const parts = parse(p);
|
|
40
|
+
const dir = join(path, ...parts.slice(0, -1));
|
|
41
|
+
// TODO: error handling
|
|
42
|
+
return mkdir(dir, { recursive: true })
|
|
43
|
+
.step(() => writeFile(join(path, p), value))
|
|
44
|
+
.step(([t]) => pure(t === 'ok' ? hash : undefined));
|
|
45
|
+
},
|
|
46
|
+
list: () =>
|
|
47
|
+
// A fresh store has no `.cas` directory yet. Treat *only* that case as an
|
|
48
|
+
// empty store, mirroring how `read` maps a missing file to `undefined`.
|
|
49
|
+
// A `.cas` that exists but cannot be read (permissions, corruption) is a
|
|
50
|
+
// genuine storage error and is surfaced, not masked as "no hashes".
|
|
51
|
+
access(storePrefix).step(a => {
|
|
52
|
+
if (a[0] === 'error') {
|
|
53
|
+
if (isNotFound(a[1])) {
|
|
54
|
+
return pure([]);
|
|
55
|
+
}
|
|
56
|
+
throw a[1];
|
|
53
57
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}),
|
|
58
|
+
return readdir(storePrefix, { recursive: true })
|
|
59
|
+
.step(r => pure(unwrap(r).flatMap(({ name, parentPath, isFile }) => toOption(isFile
|
|
60
|
+
? cBase32ToVec(normalize(parentPath).substring(normalizedStorePrefix.length).replaceAll('/', '') + name)
|
|
61
|
+
: null))));
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
61
64
|
};
|
|
62
65
|
};
|
|
66
|
+
/** Maximum chunk size for streaming reads: the largest `Vec` the runtime allows. */
|
|
67
|
+
const CHUNK_BYTES = Number(maxLengthBytes);
|
|
68
|
+
/** 256-bit random `Vec` built from 8 sequential `randomInt` (32-bit) calls. */
|
|
69
|
+
const random256 = foldStep((_) => (acc) => randomInt().step(r => pure(msb.concat(acc)(vec(32n)(BigInt(r))))))(empty)([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
63
70
|
/**
|
|
64
|
-
*
|
|
71
|
+
* Streams a file in `CHUNK_BYTES` chunks, feeding each into the SHA-2 state,
|
|
72
|
+
* and returns the final hash without loading the whole file into memory.
|
|
65
73
|
*/
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const streamHash = (sha2) => (path) => {
|
|
75
|
+
const chunkBits = BigInt(CHUNK_BYTES) * 8n;
|
|
76
|
+
const loop = (state, offset) => readBytes(path, offset, CHUNK_BYTES).step(result => {
|
|
77
|
+
if (result[0] === 'error') {
|
|
78
|
+
return pure(error(result[1]));
|
|
79
|
+
}
|
|
80
|
+
const chunk = result[1];
|
|
81
|
+
const newState = sha2.append(chunk)(state);
|
|
82
|
+
if (length(chunk) < chunkBits) {
|
|
83
|
+
return pure(ok(sha2.end(newState)));
|
|
84
|
+
}
|
|
85
|
+
return loop(newState, offset + CHUNK_BYTES);
|
|
86
|
+
});
|
|
87
|
+
return loop(sha2.init, 0);
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Move-hash-move upload pipeline: moves `fileName` from `~/cas_upload/` to a
|
|
91
|
+
* random staging path, stream-hashes it, then renames it to its final CAS shard
|
|
92
|
+
* path. Returns `ok(hash)` on success or `error(reason)` on any I/O failure.
|
|
93
|
+
*/
|
|
94
|
+
export const casUpload = (home) => (fileName) => {
|
|
95
|
+
const src = join(home, 'cas_upload', fileName);
|
|
96
|
+
const stageDir = join(home, prefix, '.stage');
|
|
97
|
+
return random256.step(rnd => {
|
|
98
|
+
const rndStr = vecToCBase32(rnd);
|
|
99
|
+
const stagePath = join(stageDir, `${rndStr}-${fileName.replaceAll('/', '-')}`);
|
|
100
|
+
return mkdir(stageDir, { recursive: true })
|
|
101
|
+
.step(() => rename(src, stagePath))
|
|
102
|
+
.step(r => {
|
|
103
|
+
if (r[0] === 'error') {
|
|
104
|
+
return pure(error(r[1]));
|
|
105
|
+
}
|
|
106
|
+
return streamHash(sha256)(stagePath)
|
|
107
|
+
.step(hashResult => {
|
|
108
|
+
if (hashResult[0] === 'error') {
|
|
109
|
+
return pure(hashResult);
|
|
110
|
+
}
|
|
111
|
+
const hash = hashResult[1];
|
|
112
|
+
const p = toPath(hash);
|
|
113
|
+
const parts = parse(p);
|
|
114
|
+
const finalDir = join(home, ...parts.slice(0, -1));
|
|
115
|
+
const finalPath = join(home, p);
|
|
116
|
+
return mkdir(finalDir, { recursive: true })
|
|
117
|
+
.step(() => rename(stagePath, finalPath))
|
|
118
|
+
.step(r2 => pure(r2[0] === 'error' ? error(r2[1]) : ok(hash)));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
76
121
|
});
|
|
77
122
|
};
|
|
78
123
|
export const commands = [
|
|
@@ -83,11 +128,12 @@ export const commands = [
|
|
|
83
128
|
if (path === undefined || rest.length !== 0) {
|
|
84
129
|
return errorExit("'cas add' expects one parameter");
|
|
85
130
|
}
|
|
86
|
-
const c =
|
|
131
|
+
const c = fileCas(sha256)(home);
|
|
87
132
|
return readFile(path)
|
|
88
133
|
.step(v => c.write(unwrap(v)))
|
|
89
|
-
.step(hash =>
|
|
90
|
-
|
|
134
|
+
.step(hash => hash === undefined
|
|
135
|
+
? pure(1)
|
|
136
|
+
: log(vecToCBase32(hash)).step(() => pure(0)));
|
|
91
137
|
},
|
|
92
138
|
},
|
|
93
139
|
{
|
|
@@ -101,7 +147,7 @@ export const commands = [
|
|
|
101
147
|
if (hash === null) {
|
|
102
148
|
return errorExit(`invalid hash format: ${hashCBase32}`);
|
|
103
149
|
}
|
|
104
|
-
const c =
|
|
150
|
+
const c = fileCas(sha256)(home);
|
|
105
151
|
return c.read(hash)
|
|
106
152
|
.step(v => {
|
|
107
153
|
const result = v === undefined
|
|
@@ -116,11 +162,11 @@ export const commands = [
|
|
|
116
162
|
names: ['list'],
|
|
117
163
|
description: 'List all stored content hashes',
|
|
118
164
|
handler: ({ home }) => {
|
|
119
|
-
const c =
|
|
165
|
+
const c = fileCas(sha256)(home);
|
|
120
166
|
return c.list()
|
|
121
167
|
.step(forEachStep(j => log(vecToCBase32(j))))
|
|
122
168
|
.step(() => pure(0));
|
|
123
169
|
},
|
|
124
|
-
}
|
|
170
|
+
},
|
|
125
171
|
];
|
|
126
172
|
export const main = dispatch(commands);
|