functionalscript 0.32.3 → 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.
@@ -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 ReadFile, type Write } from '../../effects/node/module.f.ts';
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 { Cas } from '../module.f.ts';
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<ReadFile | O>;
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 | ReadFile | O, void>;
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>;
@@ -9,9 +9,9 @@
9
9
  *
10
10
  * ## Tools
11
11
  *
12
- * | Tool | args | CAS call | result |
12
+ * | Tool | args | action | result |
13
13
  * |------------|-------------------------------|------------------|-------------------------------------|
14
- * | `cas_add` | `{ content, type? }` | `c.write(value)` | hash (cBase32) |
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
- * reads the file at that path and stores its raw bytes. Paths outside
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 { readFile } from "../../effects/node/module.f.js";
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 read from a filesystem path within $HOME/cas_upload/; omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
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);
@@ -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;
@@ -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
- const initialTestState = { memory: { next: 0, values: {} }, files: {} };
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
- readFile: path => state => {
30
- const v = state.files[path];
31
- return v !== undefined
32
- ? [state, ['ok', v]]
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 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));
60
- const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
61
- return feed(step)(msgs);
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' reads a file from /home/user/cas_upload/ and stores it.
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 [addUrlResp] = runSessionWithFiles({ '/home/user/cas_upload/hello.txt': fileContent })([
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 msgs = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
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(msgs[0]);
231
- const msgs2 = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
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(msgs2[1]).isError);
237
- const result = JSON.parse(textOf(msgs2[1]));
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] = runSessionWithFiles({})([
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 [addResp] = runSessionWithFiles({ '/home/user/cas_upload/f': fileContent })([
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
- const [, metaResp2] = runSessionWithFiles({ '/home/user/cas_upload/f': fileContent })([
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(metaResp2).isError);
262
- const meta = JSON.parse(textOf(metaResp2));
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 [resp] = runSessionWithFiles({ '/home/user/cas_upload/test.txt': fileContent })([
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 fileContent = utf8('forbidden file');
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 fileContent = utf8('secret content');
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
  },
@@ -4,9 +4,9 @@
4
4
  * @module
5
5
  */
6
6
  import { type Sha2 } from '../crypto/sha2/module.f.ts';
7
- import type { Vec } from '../types/bit_vec/module.f.ts';
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>;
@@ -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);
@@ -75,6 +76,63 @@ export const cas = (sha2) => {
75
76
  list,
76
77
  });
77
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
+ };
78
136
  export const commands = [
79
137
  {
80
138
  names: ['add'],
@@ -121,6 +179,6 @@ export const commands = [
121
179
  .step(forEachStep(j => log(vecToCBase32(j))))
122
180
  .step(() => pure(0));
123
181
  },
124
- }
182
+ },
125
183
  ];
126
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",
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": "25.9.3",
47
+ "@types/node": "26.0.0",
48
48
  "typescript": "6.0.3"
49
49
  }
50
50
  }