functionalscript 0.32.2 → 0.32.4
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/cas/mcp/module.f.d.ts +4 -4
- package/fs/cas/mcp/module.f.js +18 -16
- package/fs/cas/mcp/proof.f.d.ts +1 -0
- package/fs/cas/mcp/proof.f.js +53 -38
- package/fs/cas/module.f.d.ts +8 -2
- package/fs/cas/module.f.js +64 -14
- 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 +112 -1
- package/fs/effects/node/virtual/module.f.d.ts +2 -0
- package/fs/effects/node/virtual/module.f.js +124 -1
- package/fs/effects/node/virtual/proof.f.d.ts +10 -0
- package/fs/effects/node/virtual/proof.f.js +61 -1
- package/package.json +2 -2
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);
|
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
|
@@ -7,11 +7,13 @@ import { vecToCBase32 } from "../../cbase32/module.f.js";
|
|
|
7
7
|
import { encode as base64Encode } from "../../base64/module.f.js";
|
|
8
8
|
import { sha256 } from "../../crypto/sha2/module.f.js";
|
|
9
9
|
import { utf8 } from "../../text/module.f.js";
|
|
10
|
-
import { cas } from "../module.f.js";
|
|
10
|
+
import { cas, fileKvStore } 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,15 +28,12 @@ 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 runMemWithFiles = (files) => (effect) => run(mock)({ memory: { next: 0, values: {} }, files })(effect)[1];
|
|
38
37
|
const memKvStore = (mapKey) => ({
|
|
39
38
|
read: (key) => read(mapKey).step(m => pure(m[vecToCBase32(key)]?.[1])),
|
|
40
39
|
write: (key, value) => read(mapKey).step(m => write(mapKey, { ...m, [vecToCBase32(key)]: [key, value] })),
|
|
@@ -54,12 +53,17 @@ const runSession = (msgs, home = '/home/user') => runMem(create({}).step(mapKey
|
|
|
54
53
|
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
55
54
|
return feed(step)(msgs);
|
|
56
55
|
})));
|
|
57
|
-
// Runs a session
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
// Runs a session backed by the virtual node runner (for cas_upload which uses
|
|
57
|
+
// Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
|
|
58
|
+
// the same filesystem-backed CAS.
|
|
59
|
+
const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
|
|
60
|
+
const effect = create(uninitializedState).step(sessionKey => {
|
|
61
|
+
const c = cas(sha256)(fileKvStore(home));
|
|
62
|
+
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
63
|
+
return feed(step)(msgs);
|
|
64
|
+
});
|
|
65
|
+
return virtual({ ...emptyState, root })(effect)[1];
|
|
66
|
+
};
|
|
63
67
|
// ── Messages ────────────────────────────────────────────────────────────────────
|
|
64
68
|
const init = { jsonrpc: '2.0', method: 'initialize', id: 1,
|
|
65
69
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'c', version: '0' } } };
|
|
@@ -211,10 +215,11 @@ export const proof = {
|
|
|
211
215
|
assert(!('error' in resp));
|
|
212
216
|
assert('result' in resp);
|
|
213
217
|
},
|
|
214
|
-
// cas_add with type:'url'
|
|
218
|
+
// cas_add with type:'url' streams a file from /home/user/cas_upload/ into CAS.
|
|
215
219
|
addUrlStoresFileAndReturnsHash: () => {
|
|
216
220
|
const fileContent = utf8('hello from file');
|
|
217
|
-
const
|
|
221
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'hello.txt': fileContent } } } };
|
|
222
|
+
const [addUrlResp] = runSessionVirtual(root)([
|
|
218
223
|
init, initialized,
|
|
219
224
|
call(2, 'cas_add', { content: '/home/user/cas_upload/hello.txt', type: 'url' }),
|
|
220
225
|
]).slice(2);
|
|
@@ -223,23 +228,26 @@ export const proof = {
|
|
|
223
228
|
},
|
|
224
229
|
addUrlRoundTrips: () => {
|
|
225
230
|
const fileContent = utf8('round-trip content');
|
|
226
|
-
const
|
|
231
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'rt.txt': fileContent } } } };
|
|
232
|
+
// First pass: add to get the hash (deterministic for same content).
|
|
233
|
+
const [addResp] = runSessionVirtual(root)([
|
|
227
234
|
init, initialized,
|
|
228
235
|
call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
|
|
229
236
|
]).slice(2);
|
|
230
|
-
const hash = textOf(
|
|
231
|
-
|
|
237
|
+
const hash = textOf(addResp);
|
|
238
|
+
// Second pass: add again + get in one session (file re-present in fresh virtual state).
|
|
239
|
+
const [, getResp] = runSessionVirtual(root)([
|
|
232
240
|
init, initialized,
|
|
233
241
|
call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
|
|
234
242
|
call(3, 'cas_get', { hash, content: true }),
|
|
235
243
|
]).slice(2);
|
|
236
|
-
assert(!resultOf(
|
|
237
|
-
const result = JSON.parse(textOf(
|
|
244
|
+
assert(!resultOf(getResp).isError);
|
|
245
|
+
const result = JSON.parse(textOf(getResp));
|
|
238
246
|
assertEq(result.type, 'text');
|
|
239
247
|
assertEq(result.content, 'round-trip content');
|
|
240
248
|
},
|
|
241
249
|
addUrlMissingFileIsError: () => {
|
|
242
|
-
const [resp] =
|
|
250
|
+
const [resp] = runSessionVirtual({})([
|
|
243
251
|
init, initialized,
|
|
244
252
|
call(2, 'cas_add', { content: '/home/user/cas_upload/nonexistent.txt', type: 'url' }),
|
|
245
253
|
]).slice(2);
|
|
@@ -248,18 +256,21 @@ export const proof = {
|
|
|
248
256
|
// cas_get without content:true returns only metadata.
|
|
249
257
|
getMetaReturnsLengthAndMimeType: () => {
|
|
250
258
|
const fileContent = utf8('text content');
|
|
251
|
-
const
|
|
259
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'f': fileContent } } } };
|
|
260
|
+
// First pass: add to get the hash.
|
|
261
|
+
const [addResp] = runSessionVirtual(root)([
|
|
252
262
|
init, initialized,
|
|
253
263
|
call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
|
|
254
264
|
]).slice(2);
|
|
255
265
|
const hash = textOf(addResp);
|
|
256
|
-
|
|
266
|
+
// Second pass: add + get metadata.
|
|
267
|
+
const [, metaResp] = runSessionVirtual(root)([
|
|
257
268
|
init, initialized,
|
|
258
269
|
call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
|
|
259
270
|
call(3, 'cas_get', { hash }),
|
|
260
271
|
]).slice(2);
|
|
261
|
-
assert(!resultOf(
|
|
262
|
-
const meta = JSON.parse(textOf(
|
|
272
|
+
assert(!resultOf(metaResp).isError);
|
|
273
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
263
274
|
assertEq(meta.mime_type, 'text/plain');
|
|
264
275
|
assertEq(meta.type, 'text');
|
|
265
276
|
assertEq(meta.length, Number(BigInt(/* 'text content'.length */ 12)));
|
|
@@ -307,10 +318,22 @@ export const proof = {
|
|
|
307
318
|
const [resp] = session(call(2, 'cas_get', { hash: 'bad!' }));
|
|
308
319
|
assertEq(resultOf(resp).isError, true);
|
|
309
320
|
},
|
|
321
|
+
// cas_add type:'url' with a subdirectory path flattens slashes to '-' in staging.
|
|
322
|
+
addUrlFromSubdirectorySucceeds: () => {
|
|
323
|
+
const fileContent = utf8('nested file content');
|
|
324
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'subdir': { 'file.txt': fileContent } } } } };
|
|
325
|
+
const [resp] = runSessionVirtual(root)([
|
|
326
|
+
init, initialized,
|
|
327
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/subdir/file.txt', type: 'url' }),
|
|
328
|
+
]).slice(2);
|
|
329
|
+
assert(!resultOf(resp).isError);
|
|
330
|
+
assert(textOf(resp).length > 0);
|
|
331
|
+
},
|
|
310
332
|
// cas_add with type:'url' accepts paths within /home/user/cas_upload/
|
|
311
333
|
addUrlFromApprovedDirectorySucceeds: () => {
|
|
312
334
|
const fileContent = utf8('approved file');
|
|
313
|
-
const
|
|
335
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'test.txt': fileContent } } } };
|
|
336
|
+
const [resp] = runSessionVirtual(root)([
|
|
314
337
|
init, initialized,
|
|
315
338
|
call(2, 'cas_add', { content: '/home/user/cas_upload/test.txt', type: 'url' }),
|
|
316
339
|
]).slice(2);
|
|
@@ -319,21 +342,13 @@ export const proof = {
|
|
|
319
342
|
},
|
|
320
343
|
// cas_add with type:'url' rejects paths outside /home/user/cas_upload/
|
|
321
344
|
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);
|
|
345
|
+
const [resp] = session(call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }));
|
|
327
346
|
assert(resultOf(resp).isError === true);
|
|
328
347
|
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
329
348
|
},
|
|
330
349
|
// cas_add with type:'url' rejects path traversal attempts with ..
|
|
331
350
|
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);
|
|
351
|
+
const [resp] = session(call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }));
|
|
337
352
|
assert(resultOf(resp).isError === true);
|
|
338
353
|
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
339
354
|
},
|
package/fs/cas/module.f.d.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
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
12
|
export type KvStore<O extends Operation> = {
|
|
@@ -38,5 +38,11 @@ export type Cas<O extends Operation> = {
|
|
|
38
38
|
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
39
39
|
*/
|
|
40
40
|
export declare const cas: (sha2: Sha2) => <O extends Operation>(_: KvStore<O>) => Cas<O>;
|
|
41
|
+
/**
|
|
42
|
+
* Move-hash-move upload pipeline: moves `fileName` from `~/cas_upload/` to a
|
|
43
|
+
* random staging path, stream-hashes it, then renames it to its final CAS shard
|
|
44
|
+
* path. Returns `ok(hash)` on success or `error(reason)` on any I/O failure.
|
|
45
|
+
*/
|
|
46
|
+
export declare const casUpload: (home: string) => (fileName: string) => Effect<Mkdir | Rename | RandomInt | ReadBytes, IoResult<Vec>>;
|
|
41
47
|
export declare const commands: Commands<FileKvStoreOperation | Write | All | MemOp | Read>;
|
|
42
48
|
export declare const main: (options: NodeProgramOptions) => Effect<All | Read | Write | FileKvStoreOperation | MemOp, number>;
|
package/fs/cas/module.f.js
CHANGED
|
@@ -5,13 +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
13
|
import { casMcpServer } from "./mcp/module.f.js";
|
|
13
14
|
import { toOption } from "../types/nullable/module.f.js";
|
|
14
|
-
import { unwrap } from "../types/result/module.f.js";
|
|
15
|
+
import { error, ok, unwrap } from "../types/result/module.f.js";
|
|
15
16
|
import { splitAt } from "../types/string/module.f.js";
|
|
16
17
|
const o = { withFileTypes: true };
|
|
17
18
|
const split2 = splitAt(2);
|
|
@@ -30,16 +31,8 @@ export const fileKvStore = (path) => {
|
|
|
30
31
|
const storePrefix = join(path, prefix);
|
|
31
32
|
const normalizedStorePrefix = normalize(storePrefix);
|
|
32
33
|
return {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return pure(r[1]);
|
|
36
|
-
}
|
|
37
|
-
// A missing file means "no such content"; surface anything else.
|
|
38
|
-
if (isNotFound(r[1])) {
|
|
39
|
-
return pure(undefined);
|
|
40
|
-
}
|
|
41
|
-
throw r[1];
|
|
42
|
-
}),
|
|
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)),
|
|
43
36
|
write: (key, value) => {
|
|
44
37
|
const p = toPath(key);
|
|
45
38
|
const parts = parse(p);
|
|
@@ -83,6 +76,63 @@ export const cas = (sha2) => {
|
|
|
83
76
|
list,
|
|
84
77
|
});
|
|
85
78
|
};
|
|
79
|
+
/** Maximum chunk size for streaming reads: the largest `Vec` the runtime allows. */
|
|
80
|
+
const CHUNK_BYTES = Number(maxLengthBytes);
|
|
81
|
+
/** 256-bit random `Vec` built from 8 sequential `randomInt` (32-bit) calls. */
|
|
82
|
+
const random256 = foldStep((_) => (acc) => randomInt().step(r => pure(msb.concat(acc)(vec(32n)(BigInt(r))))))(empty)([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
83
|
+
/**
|
|
84
|
+
* Streams a file in `CHUNK_BYTES` chunks, feeding each into the SHA-2 state,
|
|
85
|
+
* and returns the final hash without loading the whole file into memory.
|
|
86
|
+
*/
|
|
87
|
+
const streamHash = (sha2) => (path) => {
|
|
88
|
+
const chunkBits = BigInt(CHUNK_BYTES) * 8n;
|
|
89
|
+
const loop = (state, offset) => readBytes(path, offset, CHUNK_BYTES).step(result => {
|
|
90
|
+
if (result[0] === 'error') {
|
|
91
|
+
return pure(error(result[1]));
|
|
92
|
+
}
|
|
93
|
+
const chunk = result[1];
|
|
94
|
+
const newState = sha2.append(chunk)(state);
|
|
95
|
+
if (length(chunk) < chunkBits) {
|
|
96
|
+
return pure(ok(sha2.end(newState)));
|
|
97
|
+
}
|
|
98
|
+
return loop(newState, offset + CHUNK_BYTES);
|
|
99
|
+
});
|
|
100
|
+
return loop(sha2.init, 0);
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Move-hash-move upload pipeline: moves `fileName` from `~/cas_upload/` to a
|
|
104
|
+
* random staging path, stream-hashes it, then renames it to its final CAS shard
|
|
105
|
+
* path. Returns `ok(hash)` on success or `error(reason)` on any I/O failure.
|
|
106
|
+
*/
|
|
107
|
+
export const casUpload = (home) => (fileName) => {
|
|
108
|
+
const src = join(home, 'cas_upload', fileName);
|
|
109
|
+
const stageDir = join(home, prefix, '.stage');
|
|
110
|
+
return random256.step(rnd => {
|
|
111
|
+
const rndStr = vecToCBase32(rnd);
|
|
112
|
+
const stagePath = join(stageDir, `${rndStr}-${fileName.replaceAll('/', '-')}`);
|
|
113
|
+
return mkdir(stageDir, { recursive: true })
|
|
114
|
+
.step(() => rename(src, stagePath))
|
|
115
|
+
.step(r => {
|
|
116
|
+
if (r[0] === 'error') {
|
|
117
|
+
return pure(error(r[1]));
|
|
118
|
+
}
|
|
119
|
+
return streamHash(sha256)(stagePath)
|
|
120
|
+
.step(hashResult => {
|
|
121
|
+
if (hashResult[0] === 'error') {
|
|
122
|
+
return pure(hashResult);
|
|
123
|
+
}
|
|
124
|
+
const hash = hashResult[1];
|
|
125
|
+
const p = toPath(hash);
|
|
126
|
+
const parts = parse(p);
|
|
127
|
+
const finalDir = join(home, ...parts.slice(0, -1));
|
|
128
|
+
const finalPath = join(home, p);
|
|
129
|
+
return mkdir(finalDir, { recursive: true })
|
|
130
|
+
.step(() => rename(stagePath, finalPath))
|
|
131
|
+
.step(r2 => pure(r2[0] === 'error' ? error(r2[1]) : ok(hash)));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
};
|
|
86
136
|
export const commands = [
|
|
87
137
|
{
|
|
88
138
|
names: ['add'],
|
|
@@ -129,6 +179,6 @@ export const commands = [
|
|
|
129
179
|
.step(forEachStep(j => log(vecToCBase32(j))))
|
|
130
180
|
.step(() => pure(0));
|
|
131
181
|
},
|
|
132
|
-
}
|
|
182
|
+
},
|
|
133
183
|
];
|
|
134
184
|
export const main = dispatch(commands);
|
|
@@ -67,6 +67,12 @@ export declare const writeFile: Func<WriteFile>;
|
|
|
67
67
|
export declare const writeUtf8File: (path: string, content: string) => Effect<WriteFile, IoResult<void>>;
|
|
68
68
|
export type Rm = readonly ['rm', (path: string) => IoResult<void>];
|
|
69
69
|
export declare const rm: Func<Rm>;
|
|
70
|
+
export type Rename = readonly ['rename', (src: string, dst: string) => IoResult<void>];
|
|
71
|
+
export declare const rename: Func<Rename>;
|
|
72
|
+
export type ReadBytes = readonly ['readBytes', (path: string, offset: number, size: number) => IoResult<Vec>];
|
|
73
|
+
export declare const readBytes: Func<ReadBytes>;
|
|
74
|
+
export type RandomInt = readonly ['randomInt', () => number];
|
|
75
|
+
export declare const randomInt: Func<RandomInt>;
|
|
70
76
|
export type ExecResult = {
|
|
71
77
|
readonly stdout: string;
|
|
72
78
|
readonly stderr: string;
|
|
@@ -75,7 +81,7 @@ export type Exec = readonly ['exec', (command: string, stdin?: string) => IoResu
|
|
|
75
81
|
export declare const exec: Func<Exec>;
|
|
76
82
|
export type Access = readonly ['access', (path: string) => IoResult<void>];
|
|
77
83
|
export declare const access: Func<Access>;
|
|
78
|
-
export type Fs = Mkdir | ReadFile | Readdir | WriteFile | Rm | Exec | Access;
|
|
84
|
+
export type Fs = Mkdir | ReadFile | ReadBytes | Readdir | WriteFile | Rm | Rename | Exec | Access;
|
|
79
85
|
export type Server = Nominal<'server', `160855c4f69310fece3273c1853ac32de43dee1eb41bf59d821917f8eebe9272`, unknown>;
|
|
80
86
|
export type Headers = StringMap<string, string>;
|
|
81
87
|
export type IncomingMessage = {
|
|
@@ -210,7 +216,7 @@ export type TestContext = {
|
|
|
210
216
|
/** Effect operation that registers a named test with the active `TestContext`. */
|
|
211
217
|
export type Test = readonly ['test', (ctx: TestContext, name: string, expectFailure: boolean, test: (t: TestContext) => Effect<Test | All | Await, void>) => void];
|
|
212
218
|
export declare const test: Func<Test>;
|
|
213
|
-
export type NodeOp = All | Await | Fetch | Fs | Http | Forever | Import | MemOp | Now | Read | Sandbox | Write | Test;
|
|
219
|
+
export type NodeOp = All | Await | Fetch | Fs | Http | Forever | Import | MemOp | Now | RandomInt | Read | Sandbox | Write | Test;
|
|
214
220
|
export type NodeEffect<T> = Effect<NodeOp, T>;
|
|
215
221
|
/**
|
|
216
222
|
* Writes an error line to `stderr` and yields exit code `1`. The canonical
|
|
@@ -48,6 +48,9 @@ export const writeFile = do_('writeFile');
|
|
|
48
48
|
/** Writes a string to `path` as UTF-8 bytes. */
|
|
49
49
|
export const writeUtf8File = (path, content) => writeFile(path, utf8(content));
|
|
50
50
|
export const rm = do_('rm');
|
|
51
|
+
export const rename = do_('rename');
|
|
52
|
+
export const readBytes = do_('readBytes');
|
|
53
|
+
export const randomInt = do_('randomInt');
|
|
51
54
|
export const exec = do_('exec');
|
|
52
55
|
export const access = do_('access');
|
|
53
56
|
export const createServer = do_('createServer');
|
|
@@ -22,6 +22,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
22
22
|
*/
|
|
23
23
|
import http from 'node:http';
|
|
24
24
|
import childProcess from 'node:child_process';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
25
26
|
import fs from 'node:fs';
|
|
26
27
|
import os from 'node:os';
|
|
27
28
|
import process from 'node:process';
|
|
@@ -58,7 +59,7 @@ const collect = async (v) => {
|
|
|
58
59
|
}
|
|
59
60
|
return result;
|
|
60
61
|
};
|
|
61
|
-
const { mkdir, readFile, readdir, writeFile, rm, access, stat } = fs.promises;
|
|
62
|
+
const { mkdir, open, readFile, readdir, rename, writeFile, rm, access, stat } = fs.promises;
|
|
62
63
|
const { exec } = childProcess;
|
|
63
64
|
const maxFileSizeBytes = Number(maxLengthBytes);
|
|
64
65
|
const prefix = 'file:///';
|
|
@@ -179,6 +180,25 @@ const runNodeEffect = asyncRun({
|
|
|
179
180
|
}))),
|
|
180
181
|
writeFile: (path, data) => tc(() => writeFile(path, fromVec(data))),
|
|
181
182
|
rm: path => tc(() => rm(path)),
|
|
183
|
+
rename: (src, dst) => tc(() => rename(src, dst)),
|
|
184
|
+
readBytes: (path, offset, size) => tc(async () => {
|
|
185
|
+
if (offset < 0) {
|
|
186
|
+
throw new Error(`Offset ${offset} is negative`);
|
|
187
|
+
}
|
|
188
|
+
if (size > maxFileSizeBytes) {
|
|
189
|
+
throw new Error(`Chunk size ${size} exceeds maximum allowed size of ${maxFileSizeBytes} bytes`);
|
|
190
|
+
}
|
|
191
|
+
const fh = await open(path, 'r');
|
|
192
|
+
try {
|
|
193
|
+
const buffer = Buffer.alloc(size);
|
|
194
|
+
const { bytesRead } = await fh.read(buffer, 0, size, offset);
|
|
195
|
+
return toVec(buffer.subarray(0, bytesRead));
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await fh.close();
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
randomInt: async () => crypto.randomInt(2 ** 32),
|
|
182
202
|
access: path => tc(() => access(path)),
|
|
183
203
|
import: path => tc(() => asyncImport(path)),
|
|
184
204
|
exec: (command, stdin) => new Promise(resolve => {
|
|
@@ -46,4 +46,19 @@ export declare const proof: {
|
|
|
46
46
|
createAndRead: () => void;
|
|
47
47
|
createAndWrite: () => void;
|
|
48
48
|
};
|
|
49
|
+
rename: {
|
|
50
|
+
fileOverFile: () => void;
|
|
51
|
+
nestedRename: () => void;
|
|
52
|
+
dirOverFile: () => void;
|
|
53
|
+
missingSource: () => void;
|
|
54
|
+
};
|
|
55
|
+
readBytes: {
|
|
56
|
+
simple: () => void;
|
|
57
|
+
withOffset: () => void;
|
|
58
|
+
oversizeChunk: () => void;
|
|
59
|
+
missingFile: () => void;
|
|
60
|
+
};
|
|
61
|
+
randomInt: {
|
|
62
|
+
increments: () => void;
|
|
63
|
+
};
|
|
49
64
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { empty, isVec, uint, vec8 } from "../../types/bit_vec/module.f.js";
|
|
2
2
|
import { utf8, utf8ToString } from "../../text/module.f.js";
|
|
3
3
|
import { decode, pure } from "../module.f.js";
|
|
4
|
-
import { both, fetch, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File } from "./module.f.js";
|
|
4
|
+
import { both, fetch, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt } from "./module.f.js";
|
|
5
5
|
import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.js";
|
|
6
6
|
import { emptyState, virtual } from "./virtual/module.f.js";
|
|
7
7
|
export const proof = {
|
|
@@ -442,4 +442,115 @@ export const proof = {
|
|
|
442
442
|
}
|
|
443
443
|
},
|
|
444
444
|
},
|
|
445
|
+
rename: {
|
|
446
|
+
fileOverFile: () => {
|
|
447
|
+
const [state, [t, result]] = virtual({
|
|
448
|
+
...emptyState,
|
|
449
|
+
root: { src: vec8(0x2an), dst: vec8(0x15n) },
|
|
450
|
+
})(rename('src', 'dst'));
|
|
451
|
+
if (t !== 'ok') {
|
|
452
|
+
throw result;
|
|
453
|
+
}
|
|
454
|
+
if (state.root.src !== undefined) {
|
|
455
|
+
throw state.root;
|
|
456
|
+
}
|
|
457
|
+
if (!isVec(state.root.dst)) {
|
|
458
|
+
throw state.root;
|
|
459
|
+
}
|
|
460
|
+
if (uint(state.root.dst) !== 0x2an) {
|
|
461
|
+
throw state.root;
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
nestedRename: () => {
|
|
465
|
+
const [state, [t, result]] = virtual({
|
|
466
|
+
...emptyState,
|
|
467
|
+
root: { tmp: { src: vec8(0x2an) } },
|
|
468
|
+
})(rename('tmp/src', 'tmp/dst'));
|
|
469
|
+
if (t !== 'ok') {
|
|
470
|
+
throw result;
|
|
471
|
+
}
|
|
472
|
+
const tmp = state.root.tmp;
|
|
473
|
+
if (typeof tmp !== 'object') {
|
|
474
|
+
throw state.root;
|
|
475
|
+
}
|
|
476
|
+
if (tmp.src !== undefined) {
|
|
477
|
+
throw tmp;
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
dirOverFile: () => {
|
|
481
|
+
const [state, [t, result]] = virtual({
|
|
482
|
+
...emptyState,
|
|
483
|
+
root: { src: {}, dst: vec8(0x15n) },
|
|
484
|
+
})(rename('src', 'dst'));
|
|
485
|
+
if (t !== 'error') {
|
|
486
|
+
throw result;
|
|
487
|
+
}
|
|
488
|
+
if (!isVec(state.root.dst)) {
|
|
489
|
+
throw state.root;
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
missingSource: () => {
|
|
493
|
+
const [_, [t, result]] = virtual(emptyState)(rename('missing', 'dst'));
|
|
494
|
+
if (t !== 'error') {
|
|
495
|
+
throw result;
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
readBytes: {
|
|
500
|
+
simple: () => {
|
|
501
|
+
const [_, [t, result]] = virtual({
|
|
502
|
+
...emptyState,
|
|
503
|
+
root: { file: vec8(0xabcdn) },
|
|
504
|
+
})(readBytes('file', 0, 2));
|
|
505
|
+
if (t !== 'ok') {
|
|
506
|
+
throw result;
|
|
507
|
+
}
|
|
508
|
+
if (!isVec(result)) {
|
|
509
|
+
throw result;
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
withOffset: () => {
|
|
513
|
+
const [_, [t, result]] = virtual({
|
|
514
|
+
...emptyState,
|
|
515
|
+
root: { file: vec8(0xabcdn) },
|
|
516
|
+
})(readBytes('file', 1, 1));
|
|
517
|
+
if (t !== 'ok') {
|
|
518
|
+
throw result;
|
|
519
|
+
}
|
|
520
|
+
if (!isVec(result)) {
|
|
521
|
+
throw result;
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
oversizeChunk: () => {
|
|
525
|
+
const [_, [t, result]] = virtual({
|
|
526
|
+
...emptyState,
|
|
527
|
+
root: { file: vec8(0x2an) },
|
|
528
|
+
})(readBytes('file', 0, Number(2n ** 32n)));
|
|
529
|
+
if (t !== 'error') {
|
|
530
|
+
throw result;
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
missingFile: () => {
|
|
534
|
+
const [_, [t, result]] = virtual(emptyState)(readBytes('missing', 0, 4));
|
|
535
|
+
if (t !== 'error') {
|
|
536
|
+
throw result;
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
randomInt: {
|
|
541
|
+
increments: () => {
|
|
542
|
+
const [state1, r1] = virtual(emptyState)(randomInt());
|
|
543
|
+
if (r1 !== 0) {
|
|
544
|
+
throw r1;
|
|
545
|
+
}
|
|
546
|
+
const [state2, r2] = virtual(state1)(randomInt());
|
|
547
|
+
if (r2 !== 1) {
|
|
548
|
+
throw r2;
|
|
549
|
+
}
|
|
550
|
+
const [_, r3] = virtual(state2)(randomInt());
|
|
551
|
+
if (r3 !== 2) {
|
|
552
|
+
throw r3;
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
},
|
|
445
556
|
};
|
|
@@ -28,6 +28,8 @@ export type State = {
|
|
|
28
28
|
memoryValues: {
|
|
29
29
|
readonly [key: string]: unknown;
|
|
30
30
|
};
|
|
31
|
+
/** Monotonically increasing counter returned by `randomInt`; starts at 0. */
|
|
32
|
+
randomNext: number;
|
|
31
33
|
};
|
|
32
34
|
export declare const emptyState: State;
|
|
33
35
|
export declare const virtual: RunInstance<NodeOp, State>;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { todo } from "../../../asserts/module.f.js";
|
|
7
7
|
import { join, parse } from "../../../path/module.f.js";
|
|
8
8
|
import { utf8ToString } from "../../../text/module.f.js";
|
|
9
|
-
import { isVec } from "../../../types/bit_vec/module.f.js";
|
|
9
|
+
import { isVec, length, maxLengthBytes, msb, vec } from "../../../types/bit_vec/module.f.js";
|
|
10
10
|
import { error, ok } from "../../../types/result/module.f.js";
|
|
11
11
|
import { run } from "../../mock/module.f.js";
|
|
12
12
|
import { asBase, asNominal } from "../../memory/module.f.js";
|
|
@@ -19,6 +19,7 @@ export const emptyState = {
|
|
|
19
19
|
epochNs: 0,
|
|
20
20
|
memoryNext: 0,
|
|
21
21
|
memoryValues: {},
|
|
22
|
+
randomNext: 0,
|
|
22
23
|
};
|
|
23
24
|
const operation = (op) => {
|
|
24
25
|
const f = (dir, path) => {
|
|
@@ -142,6 +143,125 @@ const rm = operation((dir, path) => {
|
|
|
142
143
|
const { [name]: _, ...rest } = dir;
|
|
143
144
|
return [rest, okVoid];
|
|
144
145
|
});
|
|
146
|
+
const extractEntity = (dir, path) => {
|
|
147
|
+
if (path.length === 0) {
|
|
148
|
+
return [dir, error('cannot extract root')];
|
|
149
|
+
}
|
|
150
|
+
if (path.length === 1) {
|
|
151
|
+
const [name] = path;
|
|
152
|
+
const entry = dir[name];
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
return [dir, enoent];
|
|
155
|
+
}
|
|
156
|
+
const { [name]: _, ...rest } = dir;
|
|
157
|
+
return [rest, ok(entry)];
|
|
158
|
+
}
|
|
159
|
+
const [first, ...rest] = path;
|
|
160
|
+
const sub = dir[first];
|
|
161
|
+
if (sub === undefined || isVec(sub) || typeof sub === 'function') {
|
|
162
|
+
return [dir, enoent];
|
|
163
|
+
}
|
|
164
|
+
const [newSub, result] = extractEntity(sub, rest);
|
|
165
|
+
if (result[0] === 'error') {
|
|
166
|
+
return [dir, result];
|
|
167
|
+
}
|
|
168
|
+
return [{ ...dir, [first]: newSub }, result];
|
|
169
|
+
};
|
|
170
|
+
const insertEntityAt = (dir, path, entity) => {
|
|
171
|
+
if (path.length === 0) {
|
|
172
|
+
return [dir, error('cannot insert at root')];
|
|
173
|
+
}
|
|
174
|
+
if (path.length === 1) {
|
|
175
|
+
const [name] = path;
|
|
176
|
+
const existing = dir[name];
|
|
177
|
+
if (existing !== undefined) {
|
|
178
|
+
const entityIsDir = typeof entity === 'object';
|
|
179
|
+
const existingIsDir = typeof existing === 'object';
|
|
180
|
+
if (entityIsDir && !existingIsDir) {
|
|
181
|
+
return [dir, error(`cannot overwrite file '${name}' with a directory`)];
|
|
182
|
+
}
|
|
183
|
+
if (!entityIsDir && existingIsDir) {
|
|
184
|
+
return [dir, error(`'${name}' is a directory`)];
|
|
185
|
+
}
|
|
186
|
+
if (entityIsDir && existingIsDir) {
|
|
187
|
+
const existingDir = existing;
|
|
188
|
+
const hasContent = Object.values(existingDir).some(v => v !== undefined);
|
|
189
|
+
if (hasContent) {
|
|
190
|
+
return [dir, error(`cannot overwrite non-empty directory '${name}'`)];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [{ ...dir, [name]: entity }, okVoid];
|
|
195
|
+
}
|
|
196
|
+
const [first, ...rest] = path;
|
|
197
|
+
const sub = dir[first];
|
|
198
|
+
if (sub === undefined) {
|
|
199
|
+
return [dir, enoent];
|
|
200
|
+
}
|
|
201
|
+
if (isVec(sub) || typeof sub === 'function') {
|
|
202
|
+
return [dir, error('not a directory')];
|
|
203
|
+
}
|
|
204
|
+
const [newSub, result] = insertEntityAt(sub, rest, entity);
|
|
205
|
+
if (result[0] === 'error') {
|
|
206
|
+
return [dir, result];
|
|
207
|
+
}
|
|
208
|
+
return [{ ...dir, [first]: newSub }, result];
|
|
209
|
+
};
|
|
210
|
+
const isProperPrefix = (prefix, path) => prefix.length < path.length && prefix.every((seg, i) => seg === path[i]);
|
|
211
|
+
const rename = (src, dst) => (state) => {
|
|
212
|
+
const srcParsed = parse(src);
|
|
213
|
+
const dstParsed = parse(dst);
|
|
214
|
+
// extract source first to report ENOENT if it's missing, before checking subtree guards
|
|
215
|
+
const [srcRoot, srcResult] = extractEntity(state.root, srcParsed);
|
|
216
|
+
if (srcResult[0] === 'error') {
|
|
217
|
+
return [state, srcResult];
|
|
218
|
+
}
|
|
219
|
+
// now that source exists, reject if dst is strictly inside src's subtree (rename into own descendant)
|
|
220
|
+
// or if src is strictly inside dst's subtree (rename onto own ancestor)
|
|
221
|
+
if (isProperPrefix(srcParsed, dstParsed) || isProperPrefix(dstParsed, srcParsed)) {
|
|
222
|
+
return [state, error('cannot rename a directory into its own subtree or onto an ancestor')];
|
|
223
|
+
}
|
|
224
|
+
const [dstRoot, dstResult] = insertEntityAt(srcRoot, dstParsed, srcResult[1]);
|
|
225
|
+
if (dstResult[0] === 'error') {
|
|
226
|
+
return [state, dstResult];
|
|
227
|
+
}
|
|
228
|
+
return [{ ...state, root: dstRoot }, okVoid];
|
|
229
|
+
};
|
|
230
|
+
const readBytesOp = (path, offset, size) => readOperation((dir, p) => {
|
|
231
|
+
if (p.length !== 1) {
|
|
232
|
+
return enoent;
|
|
233
|
+
}
|
|
234
|
+
const file = dir[p[0]];
|
|
235
|
+
if (typeof file === 'function') {
|
|
236
|
+
throw new Error(`'${p[0]}' is a JsModule; readBytes not supported`);
|
|
237
|
+
}
|
|
238
|
+
if (file === undefined) {
|
|
239
|
+
return enoent;
|
|
240
|
+
}
|
|
241
|
+
if (!isVec(file)) {
|
|
242
|
+
return error(`'${p[0]}' is not a file`);
|
|
243
|
+
}
|
|
244
|
+
if (!Number.isInteger(offset)) {
|
|
245
|
+
return error(`Offset ${offset} is not an integer`);
|
|
246
|
+
}
|
|
247
|
+
if (!Number.isInteger(size)) {
|
|
248
|
+
return error(`Chunk size ${size} is not an integer`);
|
|
249
|
+
}
|
|
250
|
+
if (offset < 0) {
|
|
251
|
+
return error(`Offset ${offset} is negative`);
|
|
252
|
+
}
|
|
253
|
+
if (size < 0) {
|
|
254
|
+
return error(`Chunk size ${size} is negative`);
|
|
255
|
+
}
|
|
256
|
+
if (BigInt(size) > maxLengthBytes) {
|
|
257
|
+
return error(`Chunk size ${size} exceeds maximum allowed size of ${maxLengthBytes} bytes`);
|
|
258
|
+
}
|
|
259
|
+
const offsetBits = BigInt(offset) * 8n;
|
|
260
|
+
const sizeBits = BigInt(size) * 8n;
|
|
261
|
+
const remaining = msb.removeFront(offsetBits)(file);
|
|
262
|
+
const actualSizeBits = sizeBits < length(remaining) ? sizeBits : length(remaining);
|
|
263
|
+
return ok(vec(actualSizeBits)(msb.front(actualSizeBits)(remaining)));
|
|
264
|
+
})(path);
|
|
145
265
|
const map = {
|
|
146
266
|
all: (...a) => state => {
|
|
147
267
|
let e = [];
|
|
@@ -180,6 +300,9 @@ const map = {
|
|
|
180
300
|
access,
|
|
181
301
|
import: import_,
|
|
182
302
|
rm,
|
|
303
|
+
rename,
|
|
304
|
+
readBytes: readBytesOp,
|
|
305
|
+
randomInt: () => state => [{ ...state, randomNext: state.randomNext + 1 }, state.randomNext],
|
|
183
306
|
exec: todo,
|
|
184
307
|
createServer: todo,
|
|
185
308
|
listen: todo,
|
|
@@ -14,4 +14,14 @@ export declare const proof: {
|
|
|
14
14
|
throw: {
|
|
15
15
|
readFileOnJsModule: () => void;
|
|
16
16
|
};
|
|
17
|
+
renameSamePath: () => void;
|
|
18
|
+
renameIntoOwnSubtree: () => void;
|
|
19
|
+
renameOntoOwnAncestor: () => void;
|
|
20
|
+
renameNonEmptyDirOverEmptyDir: () => void;
|
|
21
|
+
renameEmptyDirOverNonEmptyDir: () => void;
|
|
22
|
+
readBytesNegativeSize: () => void;
|
|
23
|
+
readBytesZeroSize: () => void;
|
|
24
|
+
readBytesNegativeOffset: () => void;
|
|
25
|
+
readBytesFractionalSize: () => void;
|
|
26
|
+
readBytesFractionalOffset: () => void;
|
|
17
27
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertEq } from "../../../asserts/module.f.js";
|
|
2
|
-
import { awaitIfPromise, fetch, rm, writeFile, readFile, readdir, import_ } from "../module.f.js";
|
|
2
|
+
import { awaitIfPromise, fetch, rm, writeFile, readFile, readdir, import_, rename, readBytes } from "../module.f.js";
|
|
3
3
|
import { vec8 } from "../../../types/bit_vec/module.f.js";
|
|
4
4
|
import { emptyState, virtual } from "./module.f.js";
|
|
5
5
|
export const proof = {
|
|
@@ -76,4 +76,64 @@ export const proof = {
|
|
|
76
76
|
virtual({ ...emptyState, root })(readFile('a.f.ts'));
|
|
77
77
|
},
|
|
78
78
|
},
|
|
79
|
+
renameSamePath: () => {
|
|
80
|
+
// rename('a', 'a') should succeed as a no-op, not reject
|
|
81
|
+
const root = { 'a': vec8(0x42n) };
|
|
82
|
+
const [, result] = virtual({ ...emptyState, root })(rename('a', 'a'));
|
|
83
|
+
assertEq(result[0], 'ok');
|
|
84
|
+
},
|
|
85
|
+
renameIntoOwnSubtree: () => {
|
|
86
|
+
// rename('a', 'a/b') should fail (dst inside src's subtree)
|
|
87
|
+
const root = { 'a': { 'b': vec8(0x42n) } };
|
|
88
|
+
const [, result] = virtual({ ...emptyState, root })(rename('a', 'a/b'));
|
|
89
|
+
assertEq(result[0], 'error');
|
|
90
|
+
},
|
|
91
|
+
renameOntoOwnAncestor: () => {
|
|
92
|
+
// rename('a/b', 'a') should fail (src inside dst's subtree)
|
|
93
|
+
const root = { 'a': { 'b': vec8(0x42n) } };
|
|
94
|
+
const [, result] = virtual({ ...emptyState, root })(rename('a/b', 'a'));
|
|
95
|
+
assertEq(result[0], 'error');
|
|
96
|
+
},
|
|
97
|
+
renameNonEmptyDirOverEmptyDir: () => {
|
|
98
|
+
// rename a directory onto an empty directory should succeed
|
|
99
|
+
const root = { 'src': { 'file': vec8(0x42n) }, 'dst': {} };
|
|
100
|
+
const [, result] = virtual({ ...emptyState, root })(rename('src', 'dst'));
|
|
101
|
+
assertEq(result[0], 'ok');
|
|
102
|
+
},
|
|
103
|
+
renameEmptyDirOverNonEmptyDir: () => {
|
|
104
|
+
// rename an empty directory onto a non-empty directory should fail
|
|
105
|
+
const root = { 'src': {}, 'dst': { 'file': vec8(0x42n) } };
|
|
106
|
+
const [, result] = virtual({ ...emptyState, root })(rename('src', 'dst'));
|
|
107
|
+
assertEq(result[0], 'error');
|
|
108
|
+
},
|
|
109
|
+
readBytesNegativeSize: () => {
|
|
110
|
+
// readBytes with negative size should fail
|
|
111
|
+
const root = { 'file': vec8(0x42n) };
|
|
112
|
+
const [, result] = virtual({ ...emptyState, root })(readBytes('file', 0, -1));
|
|
113
|
+
assertEq(result[0], 'error');
|
|
114
|
+
},
|
|
115
|
+
readBytesZeroSize: () => {
|
|
116
|
+
// readBytes with zero size should succeed and return empty vec
|
|
117
|
+
const root = { 'file': vec8(0x42n) };
|
|
118
|
+
const [, result] = virtual({ ...emptyState, root })(readBytes('file', 0, 0));
|
|
119
|
+
assertEq(result[0], 'ok');
|
|
120
|
+
},
|
|
121
|
+
readBytesNegativeOffset: () => {
|
|
122
|
+
// readBytes with negative offset should fail
|
|
123
|
+
const root = { 'file': vec8(0x42n) };
|
|
124
|
+
const [, result] = virtual({ ...emptyState, root })(readBytes('file', -1, 1));
|
|
125
|
+
assertEq(result[0], 'error');
|
|
126
|
+
},
|
|
127
|
+
readBytesFractionalSize: () => {
|
|
128
|
+
// readBytes with fractional size should fail rather than throw RangeError
|
|
129
|
+
const root = { 'file': vec8(0x42n) };
|
|
130
|
+
const [, result] = virtual({ ...emptyState, root })(readBytes('file', 0, 1.5));
|
|
131
|
+
assertEq(result[0], 'error');
|
|
132
|
+
},
|
|
133
|
+
readBytesFractionalOffset: () => {
|
|
134
|
+
// readBytes with fractional offset should fail rather than throw RangeError
|
|
135
|
+
const root = { 'file': vec8(0x42n) };
|
|
136
|
+
const [, result] = virtual({ ...emptyState, root })(readBytes('file', 0.5, 1));
|
|
137
|
+
assertEq(result[0], 'error');
|
|
138
|
+
},
|
|
79
139
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "functionalscript",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"**/*.js",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"homepage": "https://github.com/functionalscript/functionalscript#readme",
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@playwright/test": "1.61.0",
|
|
47
|
-
"@types/node": "
|
|
47
|
+
"@types/node": "26.0.0",
|
|
48
48
|
"typescript": "6.0.3"
|
|
49
49
|
}
|
|
50
50
|
}
|