@relayflows/sdk 2.0.29 → 2.0.30

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.
Files changed (110) hide show
  1. package/dist/authored-flow-executor.d.ts.map +1 -1
  2. package/dist/authored-flow-executor.js +10 -1
  3. package/dist/authored-flow-executor.js.map +1 -1
  4. package/dist/bundle-extensions.d.ts.map +1 -1
  5. package/dist/bundle-extensions.js +4 -7
  6. package/dist/bundle-extensions.js.map +1 -1
  7. package/dist/bundle.d.ts.map +1 -1
  8. package/dist/bundle.js +41 -7
  9. package/dist/bundle.js.map +1 -1
  10. package/dist/canonical.d.ts.map +1 -1
  11. package/dist/canonical.js +18 -6
  12. package/dist/canonical.js.map +1 -1
  13. package/dist/flow-extension-compat.d.ts.map +1 -1
  14. package/dist/flow-extension-compat.js +26 -8
  15. package/dist/flow-extension-compat.js.map +1 -1
  16. package/dist/flow-extension-loader.d.ts +7 -0
  17. package/dist/flow-extension-loader.d.ts.map +1 -1
  18. package/dist/flow-extension-loader.js +48 -22
  19. package/dist/flow-extension-loader.js.map +1 -1
  20. package/dist/fs-descriptor.d.ts +17 -0
  21. package/dist/fs-descriptor.d.ts.map +1 -0
  22. package/dist/fs-descriptor.js +96 -0
  23. package/dist/fs-descriptor.js.map +1 -0
  24. package/dist/hosted-base-snapshot.d.ts +25 -0
  25. package/dist/hosted-base-snapshot.d.ts.map +1 -0
  26. package/dist/hosted-base-snapshot.js +278 -0
  27. package/dist/hosted-base-snapshot.js.map +1 -0
  28. package/dist/hosted-extension-declarations.d.ts +10 -0
  29. package/dist/hosted-extension-declarations.d.ts.map +1 -0
  30. package/dist/hosted-extension-declarations.js +272 -0
  31. package/dist/hosted-extension-declarations.js.map +1 -0
  32. package/dist/hosted-extension-isolation.d.ts +82 -0
  33. package/dist/hosted-extension-isolation.d.ts.map +1 -0
  34. package/dist/hosted-extension-isolation.js +322 -0
  35. package/dist/hosted-extension-isolation.js.map +1 -0
  36. package/dist/hosted-extension-manifest.d.ts +9 -0
  37. package/dist/hosted-extension-manifest.d.ts.map +1 -0
  38. package/dist/hosted-extension-manifest.js +235 -0
  39. package/dist/hosted-extension-manifest.js.map +1 -0
  40. package/dist/hosted-extension-protocol.d.ts +16 -0
  41. package/dist/hosted-extension-protocol.d.ts.map +1 -0
  42. package/dist/hosted-extension-protocol.js +224 -0
  43. package/dist/hosted-extension-protocol.js.map +1 -0
  44. package/dist/hosted-extension-runtime.d.ts +30 -0
  45. package/dist/hosted-extension-runtime.d.ts.map +1 -0
  46. package/dist/hosted-extension-runtime.js +167 -0
  47. package/dist/hosted-extension-runtime.js.map +1 -0
  48. package/dist/hosted-extension-sandbox-source.d.ts +9 -0
  49. package/dist/hosted-extension-sandbox-source.d.ts.map +1 -0
  50. package/dist/hosted-extension-sandbox-source.js +95 -0
  51. package/dist/hosted-extension-sandbox-source.js.map +1 -0
  52. package/dist/hosted-extension-sandbox.d.ts +39 -0
  53. package/dist/hosted-extension-sandbox.d.ts.map +1 -0
  54. package/dist/hosted-extension-sandbox.js +418 -0
  55. package/dist/hosted-extension-sandbox.js.map +1 -0
  56. package/dist/hosted-project.d.ts +3 -0
  57. package/dist/hosted-project.d.ts.map +1 -0
  58. package/dist/hosted-project.js +19 -0
  59. package/dist/hosted-project.js.map +1 -0
  60. package/dist/hosted-promise-safety.d.ts +12 -0
  61. package/dist/hosted-promise-safety.d.ts.map +1 -0
  62. package/dist/hosted-promise-safety.js +63 -0
  63. package/dist/hosted-promise-safety.js.map +1 -0
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/intrinsic-array.d.ts +3 -0
  69. package/dist/intrinsic-array.d.ts.map +1 -0
  70. package/dist/intrinsic-array.js +14 -0
  71. package/dist/intrinsic-array.js.map +1 -0
  72. package/dist/json-value.d.ts +6 -1
  73. package/dist/json-value.d.ts.map +1 -1
  74. package/dist/json-value.js +155 -36
  75. package/dist/json-value.js.map +1 -1
  76. package/dist/llm-worker.d.ts +9 -0
  77. package/dist/llm-worker.d.ts.map +1 -1
  78. package/dist/llm-worker.js +15 -1
  79. package/dist/llm-worker.js.map +1 -1
  80. package/dist/plugin-store.d.ts +8 -2
  81. package/dist/plugin-store.d.ts.map +1 -1
  82. package/dist/plugin-store.js +233 -56
  83. package/dist/plugin-store.js.map +1 -1
  84. package/dist/semver-range.d.ts.map +1 -1
  85. package/dist/semver-range.js +27 -15
  86. package/dist/semver-range.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/authored-flow-executor.ts +10 -1
  89. package/src/bundle-extensions.ts +6 -5
  90. package/src/bundle.ts +52 -7
  91. package/src/canonical.ts +20 -6
  92. package/src/flow-extension-compat.ts +32 -7
  93. package/src/flow-extension-loader.ts +56 -21
  94. package/src/fs-descriptor.ts +127 -0
  95. package/src/hosted-base-snapshot.ts +351 -0
  96. package/src/hosted-extension-declarations.ts +387 -0
  97. package/src/hosted-extension-isolation.ts +457 -0
  98. package/src/hosted-extension-manifest.ts +249 -0
  99. package/src/hosted-extension-protocol.ts +262 -0
  100. package/src/hosted-extension-runtime.ts +266 -0
  101. package/src/hosted-extension-sandbox-source.ts +94 -0
  102. package/src/hosted-extension-sandbox.ts +489 -0
  103. package/src/hosted-project.ts +18 -0
  104. package/src/hosted-promise-safety.ts +68 -0
  105. package/src/index.ts +18 -0
  106. package/src/intrinsic-array.ts +14 -0
  107. package/src/json-value.ts +184 -32
  108. package/src/llm-worker.ts +15 -1
  109. package/src/plugin-store.ts +323 -56
  110. package/src/semver-range.ts +39 -14
@@ -1,6 +1,25 @@
1
- import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
1
+ import { constants } from 'node:fs';
2
+ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
2
3
  import { dirname, join, resolve } from 'node:path';
3
4
  import { payloadManifest, safePath, sha256 } from './bundle.js';
5
+ import {
6
+ closeDescriptor,
7
+ closeDirectory,
8
+ descriptorIsDirectory,
9
+ descriptorIsFile,
10
+ directoryEntryIsDirectory,
11
+ directoryEntryIsFile,
12
+ lstatPath,
13
+ openDirectory,
14
+ openDescriptor,
15
+ readDescriptor,
16
+ readDirectoryEntry,
17
+ statDescriptor,
18
+ } from './fs-descriptor.js';
19
+ import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js';
20
+ import { appendIntrinsicArray } from './intrinsic-array.js';
21
+ import { snapshotJsonValue, type JsonValue } from './json-value.js';
22
+ import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js';
4
23
  import { PluginError } from './plugin-manifest.js';
5
24
 
6
25
  /**
@@ -13,93 +32,341 @@ import { PluginError } from './plugin-manifest.js';
13
32
  * refusal, not a surprise.
14
33
  */
15
34
  export const PLUGIN_STORE = '.flows/plugins';
35
+ const MKDIR = mkdir;
36
+ const MKDTEMP = mkdtemp;
37
+ const RENAME = rename;
38
+ const RM = rm;
39
+ const WRITE_FILE = writeFile;
40
+ const PATH_DIRNAME = dirname;
41
+ const PATH_JOIN = join;
42
+ const PATH_RESOLVE = resolve;
43
+ const PROCESS_PLATFORM = process.platform;
44
+ const ERROR = Error;
45
+ const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000;
46
+ const MAX_PLUGIN_STORE_ENTRIES = 10_000;
47
+ const ARRAY_IS_ARRAY = Array.isArray;
48
+ const BIG_INT = BigInt;
49
+ const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe;
50
+ const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as (
51
+ value: Buffer,
52
+ encoding: BufferEncoding,
53
+ ) => string;
54
+ const JSON_PARSE = JSON.parse;
55
+ const NUMBER = Number;
56
+ const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger;
57
+ const OBJECT_CREATE = Object.create;
58
+ const OBJECT_FREEZE = Object.freeze;
59
+ const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor;
60
+ const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (pattern: RegExp, value: string) => boolean;
61
+ const SET = Set;
62
+ const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as <T>(set: Set<T>, value: T) => Set<T>;
63
+ const SET_FOR_EACH = Function.prototype.call.bind(Set.prototype.forEach) as <T>(
64
+ set: Set<T>,
65
+ callback: (value: T) => void,
66
+ ) => void;
67
+ const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as <T>(set: Set<T>, value: T) => boolean;
68
+ const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as (
69
+ value: string,
70
+ separator: string,
71
+ ) => string[];
72
+ const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as (
73
+ value: string,
74
+ search: string,
75
+ ) => boolean;
76
+ const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
16
77
 
17
78
  export function pluginStoreDirectory(root: string, name: string, digest: string): string {
18
- return join(resolve(root), PLUGIN_STORE, `${name}@sha256:${digest}`);
79
+ return PATH_JOIN(PATH_RESOLVE(root), PLUGIN_STORE, `${name}@sha256:${digest}`);
19
80
  }
20
81
 
21
- export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array }
82
+ export interface StoredPluginFile {
83
+ readonly path: string;
84
+ readonly data: Uint8Array;
85
+ }
86
+
87
+ /** @internal Deterministic race seams for the bounded store reader. */
88
+ export interface StoredPluginReadTestHooks {
89
+ readonly beforeOpen?: (path: string) => Promise<void>;
90
+ readonly afterStat?: (path: string) => Promise<void>;
91
+ readonly beforeDirectoryStat?: () => Promise<void>;
92
+ }
93
+
94
+ const EMPTY_STORED_PLUGIN_HOOKS = OBJECT_FREEZE(
95
+ OBJECT_CREATE(null),
96
+ ) as StoredPluginReadTestHooks;
22
97
 
23
98
  /** Write the files atomically; an existing directory is verified instead of overwritten. */
24
- export async function materializePlugin(root: string, name: string, files: readonly StoredPluginFile[]): Promise<{ directory: string; digest: string }> {
99
+ export async function materializePlugin(
100
+ root: string,
101
+ name: string,
102
+ files: readonly StoredPluginFile[],
103
+ ): Promise<{ directory: string; digest: string }> {
25
104
  const manifest = payloadManifest(files);
26
105
  const digest = sha256(manifest);
27
106
  const directory = pluginStoreDirectory(root, name, digest);
28
107
  let exists = false;
29
- try { await lstat(directory); exists = true; }
30
- catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
31
- if (exists) { await verifyStoredPlugin(directory, digest); return { directory, digest }; }
32
- const parent = dirname(directory);
33
- await mkdir(parent, { recursive: true });
34
- const staging = await mkdtemp(join(parent, '.install-'));
35
108
  try {
36
- for (const file of files) {
37
- if (!safePath(file.path) || file.path === 'manifest.json') throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`);
38
- await mkdir(dirname(join(staging, file.path)), { recursive: true });
39
- await writeFile(join(staging, file.path), file.data, { mode: 0o644 });
109
+ await lstatPath(directory);
110
+ exists = true;
111
+ } catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
113
+ }
114
+ if (exists) {
115
+ await verifyStoredPlugin(directory, digest);
116
+ return frozenHostedPromiseValue({ directory, digest });
117
+ }
118
+ const parent = PATH_DIRNAME(directory);
119
+ await MKDIR(parent, { recursive: true });
120
+ const staging = await MKDTEMP(PATH_JOIN(parent, '.install-'));
121
+ try {
122
+ for (let index = 0; index < files.length; index += 1) {
123
+ const file = files[index]!;
124
+ if (!safePath(file.path) || file.path === 'manifest.json')
125
+ throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`);
126
+ await MKDIR(PATH_DIRNAME(PATH_JOIN(staging, file.path)), {
127
+ recursive: true,
128
+ });
129
+ await WRITE_FILE(PATH_JOIN(staging, file.path), file.data, {
130
+ mode: 0o644,
131
+ });
40
132
  }
41
- await writeFile(join(staging, 'manifest.json'), manifest);
42
- try { await rename(staging, directory); }
43
- catch (error) {
44
- if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
133
+ await WRITE_FILE(PATH_JOIN(staging, 'manifest.json'), manifest);
134
+ try {
135
+ await RENAME(staging, directory);
136
+ } catch (error) {
137
+ const code = (error as NodeJS.ErrnoException).code;
138
+ if (code !== 'EEXIST' && code !== 'ENOTEMPTY') throw error;
45
139
  await verifyStoredPlugin(directory, digest);
46
140
  }
47
- } finally { await rm(staging, { recursive: true, force: true }); }
48
- return { directory, digest };
141
+ } finally {
142
+ await RM(staging, { recursive: true, force: true });
143
+ }
144
+ return frozenHostedPromiseValue({ directory, digest });
49
145
  }
50
146
 
51
- async function regularFile(root: string, path: string): Promise<Buffer> {
52
- const parts = path.split('/');
53
- for (let i = 1; i <= parts.length; i++) {
54
- const stat = await lstat(join(root, ...parts.slice(0, i)));
55
- if (i === parts.length ? !stat.isFile() : !stat.isDirectory()) throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`);
147
+ async function regularFile(
148
+ root: string,
149
+ path: string,
150
+ maxBytes: number,
151
+ expectedBytes?: number,
152
+ hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS,
153
+ ): Promise<Buffer> {
154
+ const absolute = PATH_JOIN(root, path);
155
+ const handle = await openStoredFile(root, path, hooks);
156
+ try {
157
+ const before = await statDescriptor(handle, { bigint: true });
158
+ if (
159
+ !descriptorIsFile(before) ||
160
+ before.size < 0n ||
161
+ before.size > BIG_INT(maxBytes) ||
162
+ (expectedBytes !== undefined && before.size !== BIG_INT(expectedBytes))
163
+ ) {
164
+ throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`);
165
+ }
166
+ await ownHook(hooks, 'afterStat')?.(absolute);
167
+ const size = NUMBER(before.size);
168
+ const bytes = BUFFER_ALLOC_UNSAFE(size);
169
+ let offset = 0;
170
+ while (offset < size) {
171
+ const bytesRead = await readDescriptor(handle, bytes, offset, size - offset, offset);
172
+ if (bytesRead === 0) {
173
+ throw new PluginError('plugin_source_drift', `${path}: changed while reading.`);
174
+ }
175
+ offset += bytesRead;
176
+ }
177
+ const extra = BUFFER_ALLOC_UNSAFE(1);
178
+ if ((await readDescriptor(handle, extra, 0, 1, size)) !== 0) {
179
+ throw new PluginError('plugin_source_drift', `${path}: changed while reading.`);
180
+ }
181
+ const after = await statDescriptor(handle, { bigint: true });
182
+ if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
183
+ throw new PluginError('plugin_source_drift', `${path}: changed while reading.`);
184
+ }
185
+ return hostedPromiseValue(bytes);
186
+ } finally {
187
+ await closeDescriptor(handle);
188
+ }
189
+ }
190
+
191
+ async function openStoredFile(root: string, path: string, hooks: StoredPluginReadTestHooks): Promise<number> {
192
+ const parts = STRING_SPLIT(path, '/');
193
+ if (PROCESS_PLATFORM !== 'linux') {
194
+ for (let i = 1; i < parts.length; i++) {
195
+ let parent = root;
196
+ for (let index = 0; index < i; index += 1) parent = PATH_JOIN(parent, parts[index]!);
197
+ if (!descriptorIsDirectory(await lstatPath(parent))) {
198
+ throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`);
199
+ }
200
+ }
201
+ await ownHook(hooks, 'beforeOpen')?.(PATH_JOIN(root, path));
202
+ return await openDescriptor(PATH_JOIN(root, path), READ_FLAGS);
203
+ }
204
+ let directory = await openDescriptor(PATH_RESOLVE(root), READ_FLAGS);
205
+ try {
206
+ if (!descriptorIsDirectory(await statDescriptor(directory, { bigint: true }))) {
207
+ throw new PluginError('plugin_source_drift', `${path}: plugin store root is not a directory.`);
208
+ }
209
+ for (let index = 0; index < parts.length - 1; index += 1) {
210
+ const part = parts[index]!;
211
+ const child = await openDescriptor(`/proc/self/fd/${directory}/${part}`, READ_FLAGS);
212
+ let adopted = false;
213
+ try {
214
+ await ownHook(hooks, 'beforeDirectoryStat')?.();
215
+ if (!descriptorIsDirectory(await statDescriptor(child, { bigint: true }))) {
216
+ throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`);
217
+ }
218
+ await closeDescriptor(directory);
219
+ directory = child;
220
+ adopted = true;
221
+ } finally {
222
+ if (!adopted) await closeDescriptor(child);
223
+ }
224
+ }
225
+ await ownHook(hooks, 'beforeOpen')?.(PATH_JOIN(root, path));
226
+ return await openDescriptor(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, READ_FLAGS);
227
+ } finally {
228
+ await closeDescriptor(directory);
56
229
  }
57
- return readFile(join(root, path));
58
230
  }
59
231
 
60
232
  /** Re-hash a materialized plugin and compare with the digest the lockfile recorded. */
61
- export async function verifyStoredPlugin(directory: string, expectedDigest: string): Promise<void> {
62
- const drift = (message: string): never => { throw new PluginError('plugin_source_drift', `${directory}: ${message}`); };
63
- let raw: string;
233
+ export async function verifyStoredPlugin(
234
+ directory: string,
235
+ expectedDigest: string,
236
+ hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS,
237
+ ): Promise<void> {
238
+ await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks);
239
+ }
240
+
241
+ async function readVerifiedStoredPluginFiles(
242
+ directory: string,
243
+ expectedDigest: string,
244
+ hooks: StoredPluginReadTestHooks,
245
+ ): Promise<readonly { path: string; data: Buffer }[]> {
246
+ const drift = (message: string): never => {
247
+ throw new PluginError('plugin_source_drift', `${directory}: ${message}`);
248
+ };
249
+ let manifest: Buffer;
64
250
  try {
65
- if (!(await lstat(directory)).isDirectory()) return drift('not a directory');
66
- raw = (await regularFile(directory, 'manifest.json')).toString('utf8');
67
- } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); }
251
+ if (!descriptorIsDirectory(await lstatPath(directory))) return drift('not a directory');
252
+ manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks);
253
+ } catch (error) {
254
+ return drift(error instanceof PluginError ? error.message : 'manifest.json is missing');
255
+ }
256
+ const raw = BUFFER_TO_STRING(manifest, 'utf8');
68
257
  if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile');
69
- let entries: { path: string; sha256: string; bytes: number }[];
70
- try { entries = JSON.parse(raw); if (!Array.isArray(entries)) throw new Error(); }
71
- catch { return drift('manifest.json is not a manifest'); }
72
- const paths = new Set<string>();
73
- for (const entry of entries) {
74
- if (typeof entry?.path !== 'string' || !safePath(entry.path)) return drift('manifest.json lists an invalid path');
258
+ let entries: JsonValue[];
259
+ try {
260
+ const value = snapshotJsonValue(JSON_PARSE(raw), 'stored plugin manifest', {
261
+ maxBytes: MAX_PLUGIN_MANIFEST_BYTES,
262
+ maxDepth: 3,
263
+ maxNodes: 1 + MAX_PLUGIN_FILES * 4,
264
+ });
265
+ if (!ARRAY_IS_ARRAY(value)) throw new ERROR();
266
+ entries = value;
267
+ } catch {
268
+ return drift('manifest.json is not a manifest');
269
+ }
270
+ if (entries.length > MAX_PLUGIN_FILES) return drift(`manifest.json lists more than ${MAX_PLUGIN_FILES} files`);
271
+ const paths = new SET<string>();
272
+ const files = [{ path: 'manifest.json', data: manifest }];
273
+ let totalBytes = 0;
274
+ for (let index = 0; index < entries.length; index += 1) {
275
+ const entry = entries[index]!;
276
+ if (
277
+ typeof entry !== 'object' ||
278
+ entry === null ||
279
+ ARRAY_IS_ARRAY(entry)
280
+ )
281
+ return drift('manifest.json lists an invalid file');
282
+ const path = entry.path;
283
+ const digest = entry.sha256;
284
+ const bytes = entry.bytes;
285
+ if (
286
+ typeof path !== 'string' ||
287
+ !safePath(path) ||
288
+ typeof digest !== 'string' ||
289
+ !REGEXP_TEST(/^[a-f0-9]{64}$/, digest) ||
290
+ typeof bytes !== 'number' ||
291
+ !NUMBER_IS_SAFE_INTEGER(bytes) ||
292
+ bytes < 0 ||
293
+ bytes > MAX_PLUGIN_FILE_BYTES ||
294
+ SET_HAS(paths, path)
295
+ )
296
+ return drift('manifest.json lists an invalid file');
297
+ totalBytes += bytes;
298
+ if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) return drift(`plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes`);
75
299
  let data: Buffer;
76
- try { data = await regularFile(directory, entry.path); }
77
- catch (error) { return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); }
78
- if (data.length !== entry.bytes || sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`);
79
- paths.add(entry.path);
300
+ try {
301
+ data = await regularFile(directory, path, MAX_PLUGIN_FILE_BYTES, bytes, hooks);
302
+ } catch (error) {
303
+ return drift(error instanceof PluginError ? error.message : `${path} is missing`);
304
+ }
305
+ if (sha256(data) !== digest) return drift(`${path} changed since installation`);
306
+ SET_ADD(paths, path);
307
+ appendIntrinsicArray(files, { path, data });
308
+ }
309
+ SET_ADD(paths, 'manifest.json');
310
+ await rejectExtras(directory, '', paths, drift);
311
+ const frozen: Readonly<{ path: string; data: Buffer }>[] = [];
312
+ for (let index = 0; index < files.length; index += 1) {
313
+ appendIntrinsicArray(frozen, OBJECT_FREEZE(files[index]!));
80
314
  }
81
- await rejectExtras(directory, '', new Set([...paths, 'manifest.json']), drift);
315
+ return frozenHostedPromiseValue(frozen);
82
316
  }
83
317
 
84
318
  /** Re-verify, then return every stored file including the payload `manifest.json`. */
85
- export async function readStoredPluginFiles(directory: string, expectedDigest: string): Promise<readonly { path: string; data: Buffer }[]> {
86
- await verifyStoredPlugin(directory, expectedDigest);
87
- const manifest = await regularFile(directory, 'manifest.json');
88
- const entries = JSON.parse(manifest.toString('utf8')) as { path: string }[];
89
- const files = [{ path: 'manifest.json', data: manifest }];
90
- for (const entry of entries) files.push({ path: entry.path, data: await regularFile(directory, entry.path) });
91
- return files;
319
+ export async function readStoredPluginFiles(
320
+ directory: string,
321
+ expectedDigest: string,
322
+ hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS,
323
+ ): Promise<readonly { path: string; data: Buffer }[]> {
324
+ return await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks);
325
+ }
326
+
327
+ function ownHook(
328
+ hooks: StoredPluginReadTestHooks,
329
+ name: keyof StoredPluginReadTestHooks,
330
+ ): ((...args: unknown[]) => Promise<void>) | undefined {
331
+ const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(hooks, name);
332
+ return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'function'
333
+ ? descriptor.value as (...args: unknown[]) => Promise<void>
334
+ : undefined;
92
335
  }
93
336
 
94
337
  /** Drop a materialized plugin directory. Missing is a no-op. */
95
338
  export async function removeStoredPlugin(directory: string): Promise<void> {
96
- await rm(directory, { recursive: true, force: true });
339
+ await RM(directory, { recursive: true, force: true });
97
340
  }
98
341
 
99
- async function rejectExtras(root: string, prefix: string, paths: Set<string>, drift: (m: string) => never): Promise<void> {
100
- for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
101
- const path = prefix + entry.name;
102
- if (entry.isDirectory() && [...paths].some(file => file.startsWith(`${path}/`))) await rejectExtras(root, `${path}/`, paths, drift);
103
- else if (!entry.isFile() || !paths.has(path)) drift(`${path}: unlisted file or unsupported file type`);
342
+ async function rejectExtras(
343
+ root: string,
344
+ prefix: string,
345
+ paths: Set<string>,
346
+ drift: (m: string) => never,
347
+ ): Promise<void> {
348
+ let entries = 0;
349
+ async function visit(currentPrefix: string): Promise<void> {
350
+ const directory = await openDirectory(PATH_JOIN(root, currentPrefix));
351
+ try {
352
+ for (;;) {
353
+ const entry = await readDirectoryEntry(directory);
354
+ if (entry === null) break;
355
+ entries += 1;
356
+ if (entries > MAX_PLUGIN_STORE_ENTRIES) drift('plugin store contains too many entries');
357
+ const path = currentPrefix + entry.name;
358
+ let declaredDescendant = false;
359
+ SET_FOR_EACH(paths, file => {
360
+ if (STRING_STARTS_WITH(file, `${path}/`)) declaredDescendant = true;
361
+ });
362
+ if (directoryEntryIsDirectory(entry) && declaredDescendant) await visit(`${path}/`);
363
+ else if (!directoryEntryIsFile(entry) || !SET_HAS(paths, path)) {
364
+ drift(`${path}: unlisted file or unsupported file type`);
365
+ }
366
+ }
367
+ } finally {
368
+ await closeDirectory(directory);
369
+ }
104
370
  }
371
+ await visit(prefix);
105
372
  }
@@ -9,19 +9,41 @@
9
9
  */
10
10
  const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
11
11
  const RANGE = /^(\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/;
12
+ const MATH_MAX = Math.max;
13
+ const NUMBER = Number;
14
+ const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger;
15
+ const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as (
16
+ regexp: RegExp,
17
+ value: string,
18
+ ) => RegExpExecArray | null;
19
+ const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (
20
+ regexp: RegExp,
21
+ value: string,
22
+ ) => boolean;
23
+ const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as (
24
+ value: string,
25
+ start: number,
26
+ end?: number,
27
+ ) => string;
28
+ const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as (
29
+ value: string,
30
+ separator: string,
31
+ ) => string[];
12
32
 
13
33
  interface Parsed { readonly triple: readonly [number, number, number]; readonly pre?: string }
14
34
 
15
35
  export function parseVersion(value: string): Parsed | undefined {
16
- const m = VERSION.exec(value);
36
+ const m = REGEXP_EXEC(VERSION, value);
17
37
  if (!m) return undefined;
18
- const triple = [Number(m[1]), Number(m[2]), Number(m[3])] as const;
19
- if (triple.some(n => !Number.isSafeInteger(n))) return undefined;
38
+ const triple = [NUMBER(m[1]), NUMBER(m[2]), NUMBER(m[3])] as const;
39
+ for (let index = 0; index < triple.length; index += 1) {
40
+ if (!NUMBER_IS_SAFE_INTEGER(triple[index])) return undefined;
41
+ }
20
42
  return m[4] === undefined ? { triple } : { triple, pre: m[4] };
21
43
  }
22
44
 
23
45
  export function isVersionRange(value: string): boolean {
24
- return RANGE.test(value);
46
+ return REGEXP_TEST(RANGE, value);
25
47
  }
26
48
 
27
49
  function compare(a: Parsed, b: Parsed): number {
@@ -33,16 +55,16 @@ function compare(a: Parsed, b: Parsed): number {
33
55
  }
34
56
 
35
57
  function comparePrerelease(a: string, b: string): number {
36
- const as = a.split('.');
37
- const bs = b.split('.');
38
- const n = Math.max(as.length, bs.length);
58
+ const as = STRING_SPLIT(a, '.');
59
+ const bs = STRING_SPLIT(b, '.');
60
+ const n = MATH_MAX(as.length, bs.length);
39
61
  for (let i = 0; i < n; i++) {
40
62
  const left = as[i];
41
63
  const right = bs[i];
42
64
  if (left === undefined) return -1;
43
65
  if (right === undefined) return 1;
44
- const leftNum = /^\d+$/.test(left) ? Number(left) : undefined;
45
- const rightNum = /^\d+$/.test(right) ? Number(right) : undefined;
66
+ const leftNum = REGEXP_TEST(/^\d+$/, left) ? NUMBER(left) : undefined;
67
+ const rightNum = REGEXP_TEST(/^\d+$/, right) ? NUMBER(right) : undefined;
46
68
  if (leftNum !== undefined && rightNum !== undefined) {
47
69
  if (leftNum !== rightNum) return leftNum - rightNum;
48
70
  continue;
@@ -59,15 +81,18 @@ export function satisfiesRange(version: string, range: string): boolean {
59
81
  const v = parseVersion(version);
60
82
  if (v === undefined || !isVersionRange(range)) return false;
61
83
  if (range === '*') return true;
62
- const [lowerText, upperText] = range.split(' ') as [string, string | undefined];
63
- const operator = /^[\^~]|^>=/.exec(lowerText)?.[0] ?? '';
64
- const lower = parseVersion(lowerText.slice(operator.length))!;
84
+ const parts = STRING_SPLIT(range, ' ');
85
+ const lowerText = parts[0]!;
86
+ const upperText = parts[1];
87
+ const operator = REGEXP_EXEC(/^[\^~]|^>=/, lowerText)?.[0] ?? '';
88
+ const lower = parseVersion(STRING_SLICE(lowerText, operator.length))!;
65
89
  if (compare(v, lower) < 0) return false;
66
90
  if (operator === '') return compare(v, lower) === 0;
67
91
  let upper: Parsed | undefined;
68
- if (upperText !== undefined) upper = parseVersion(upperText.slice(1));
92
+ if (upperText !== undefined) upper = parseVersion(STRING_SLICE(upperText, 1));
69
93
  else if (operator === '^') {
70
- const [major, minor] = lower.triple;
94
+ const major = lower.triple[0];
95
+ const minor = lower.triple[1];
71
96
  upper = major > 0 ? { triple: [major + 1, 0, 0] } : minor > 0 ? { triple: [0, minor + 1, 0] } : { triple: [0, 0, lower.triple[2] + 1] };
72
97
  } else if (operator === '~') upper = { triple: [lower.triple[0], lower.triple[1] + 1, 0] };
73
98
  return upper === undefined || compare(v, upper) < 0;