@poe-platform/safe-fs 0.1.666 → 0.1.668

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,5 +1,7 @@
1
1
  export { S3FileSystem, S3RenameError } from "./filesystem.js";
2
2
  export type { S3FileSystemOptions } from "./filesystem.js";
3
+ export { createS3NamespaceFileSystem } from './namespace.js';
4
+ export type { S3NamespaceOptions } from './namespace.js';
3
5
  export { MockS3Client } from "./mock.js";
4
6
  export type { MockS3ClientOptions, MockS3Operation, MockS3Request } from "./mock.js";
5
7
  export { createS3Transport, encodeCopySource, S3ServiceError } from "./transport.js";
@@ -1,3 +1,4 @@
1
1
  export { S3FileSystem, S3RenameError } from "./filesystem.js";
2
+ export { createS3NamespaceFileSystem } from './namespace.js';
2
3
  export { MockS3Client } from "./mock.js";
3
4
  export { createS3Transport, encodeCopySource, S3ServiceError } from "./transport.js";
@@ -0,0 +1,13 @@
1
+ import type { FileSystem } from '../../contracts/filesystem.js';
2
+ import { type ObjectFileDescriptorOptions } from '../object-publication/index.js';
3
+ import type { S3Transport } from './transport.js';
4
+ export interface S3NamespaceOptions extends ObjectFileDescriptorOptions {
5
+ readonly client: S3Transport;
6
+ readonly bucket: string;
7
+ readonly key: string;
8
+ readonly maxBytes?: number;
9
+ readonly maxEntries?: number;
10
+ readonly maxManifestBytes?: number;
11
+ readonly maxAttempts?: number;
12
+ }
13
+ export declare function createS3NamespaceFileSystem(options: S3NamespaceOptions): Promise<FileSystem>;
@@ -0,0 +1,393 @@
1
+ import { FsError } from '../../contracts/errors.js';
2
+ import { collectBytes } from '../../contracts/io.js';
3
+ import { dirname, normalizePath, validatePath } from '../../contracts/virtual-path.js';
4
+ import { withObjectFileDescriptors } from '../object-publication/index.js';
5
+ function limit(value, maximum) {
6
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
7
+ throw new RangeError('Invalid S3 namespace limit');
8
+ return value;
9
+ }
10
+ export async function createS3NamespaceFileSystem(options) {
11
+ const client = options.client;
12
+ if (client?.capabilities?.conditionalPut !== true || client.capabilities.streamingRead !== true || !client.getObjectStream)
13
+ throw new FsError('ENOTSUP', { message: 'S3 namespaces require verified conditional PUT and bounded streaming reads' });
14
+ if (![options.bucket, options.key].every(value => typeof value === 'string' && value.length > 0 && !value.includes('\0')))
15
+ throw new TypeError('An explicit S3 bucket and manifest key are required');
16
+ const maxBytes = limit(options.maxBytes ?? 1048576, 67108864);
17
+ const maxEntries = limit(options.maxEntries ?? 1024, 65536);
18
+ const maxManifestBytes = limit(options.maxManifestBytes ?? 8388608, 268435456);
19
+ const maxAttempts = limit(options.maxAttempts ?? 8, 64);
20
+ const descriptorOptions = { ...options, maxFileBytes: options.maxFileBytes ?? maxBytes, maxOpenFiles: options.maxOpenFiles ?? 16 };
21
+ for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
22
+ if (descriptorOptions[key] !== undefined)
23
+ limit(descriptorOptions[key], key === 'chunkBytes' ? 1048576 : Number.MAX_SAFE_INTEGER);
24
+ }
25
+ const object = Object.freeze({ Bucket: options.bucket, Key: options.key });
26
+ const scope = Object.freeze({});
27
+ let identity;
28
+ const path = (value) => {
29
+ validatePath(value);
30
+ if (!value)
31
+ throw new FsError('ENOENT');
32
+ if (value.length > 4096)
33
+ throw new FsError('ENAMETOOLONG');
34
+ return normalizePath(value);
35
+ };
36
+ const entryPath = (value) => {
37
+ const name = path(value);
38
+ const terminal = value.split('/').filter(Boolean).at(-1);
39
+ if (terminal === '.' || terminal === '..')
40
+ throw new FsError('EINVAL');
41
+ return name;
42
+ };
43
+ const status = (error) => error?.$metadata?.httpStatusCode;
44
+ const validate = (value) => {
45
+ if (!value || value.version !== 1 || typeof value.identity !== 'string' || value.identity.length < 1 || value.identity.length > 128
46
+ || Object.keys(value).length !== 4 || !Object.keys(value).every(key => ['version', 'identity', 'nextInode', 'nodes'].includes(key))
47
+ || identity !== undefined && value.identity !== identity || !Number.isSafeInteger(value.nextInode) || value.nextInode < 2
48
+ || !value.nodes || typeof value.nodes !== 'object' || Array.isArray(value.nodes))
49
+ throw new FsError('EIO', { message: 'Invalid or replaced S3 namespace' });
50
+ const entries = Object.entries(value.nodes);
51
+ if (entries.length > maxEntries)
52
+ throw new FsError('ENOSPC');
53
+ const inodes = new Set();
54
+ let size = 0;
55
+ for (const [name, node] of entries) {
56
+ if (path(name) !== name || !node || !['file', 'directory'].includes(node.type) || !Array.isArray(node.bytes)
57
+ || Object.keys(node).length !== 6 || !Object.keys(node).every(key => ['ino', 'revision', 'type', 'mode', 'time', 'bytes'].includes(key))
58
+ || !Number.isSafeInteger(node.ino) || node.ino < 1 || node.ino >= value.nextInode || inodes.has(node.ino)
59
+ || !Number.isSafeInteger(node.revision) || node.revision < 1 || !Number.isSafeInteger(node.mode) || node.mode < 0 || node.mode > 0o7777
60
+ || !Number.isFinite(node.time) || node.type === 'directory' && node.bytes.length !== 0)
61
+ throw new FsError('EIO', { message: 'Invalid S3 namespace entry' });
62
+ inodes.add(node.ino);
63
+ size += node.bytes.length;
64
+ if (size > maxBytes)
65
+ throw new FsError('ENOSPC');
66
+ if (node.bytes.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255))
67
+ throw new FsError('EIO');
68
+ if (name !== '/' && value.nodes[dirname(name)]?.type !== 'directory')
69
+ throw new FsError('EIO', { message: 'Disconnected S3 namespace entry' });
70
+ }
71
+ if (value.nodes['/']?.type !== 'directory' || value.nodes['/'].ino !== 1)
72
+ throw new FsError('EIO');
73
+ return value;
74
+ };
75
+ const read = async (forwarded = {}) => {
76
+ forwarded.signal?.throwIfAborted();
77
+ try {
78
+ const response = await client.getObjectStream(object, forwarded.signal ? { abortSignal: forwarded.signal } : {});
79
+ const body = await collectBytes(response.Body, { ...forwarded, maxBytes: maxManifestBytes });
80
+ if (typeof response.ETag !== 'string' || !response.ETag || response.ETag.startsWith('W/'))
81
+ throw new FsError('EIO', { message: 'S3 namespace requires a strong object validator' });
82
+ let decoded;
83
+ try {
84
+ decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
85
+ }
86
+ catch {
87
+ throw new FsError('EIO', { message: 'Invalid S3 namespace encoding' });
88
+ }
89
+ return { value: validate(decoded), etag: response.ETag };
90
+ }
91
+ catch (error) {
92
+ forwarded.signal?.throwIfAborted();
93
+ if (error instanceof FsError || status(error) === 404 && identity === undefined)
94
+ throw error;
95
+ throw new FsError('EIO', { message: 'S3 namespace read failed' });
96
+ }
97
+ };
98
+ const commit = async (value, etag, forwarded) => {
99
+ validate(value);
100
+ let encodedBound = 256 + value.identity.length * 6;
101
+ for (const [name, node] of Object.entries(value.nodes))
102
+ encodedBound += 256 + name.length * 6 + node.bytes.length * 4;
103
+ if (encodedBound > maxManifestBytes)
104
+ throw new FsError('EFBIG');
105
+ const body = new TextEncoder().encode(JSON.stringify(value));
106
+ if (body.length > maxManifestBytes)
107
+ throw new FsError('EFBIG');
108
+ forwarded.signal?.throwIfAborted();
109
+ try {
110
+ await client.putObject({ ...object, Body: body, ...(etag === undefined ? { IfNoneMatch: '*' } : { IfMatch: etag }) }, forwarded.signal ? { abortSignal: forwarded.signal } : {});
111
+ forwarded.signal?.throwIfAborted();
112
+ return true;
113
+ }
114
+ catch (error) {
115
+ forwarded.signal?.throwIfAborted();
116
+ if (status(error) === 412 || status(error) === 409)
117
+ return false;
118
+ throw new FsError('EIO', { message: 'S3 namespace commit could not be confirmed' });
119
+ }
120
+ };
121
+ for (let attempt = 0; identity === undefined && attempt < maxAttempts; attempt++) {
122
+ try {
123
+ identity = (await read()).value.identity;
124
+ }
125
+ catch (error) {
126
+ if (status(error) !== 404)
127
+ throw error;
128
+ const initial = { version: 1, identity: crypto.randomUUID(), nextInode: 2, nodes: {
129
+ '/': { ino: 1, revision: 1, type: 'directory', mode: 0o755, time: Date.now(), bytes: [] },
130
+ } };
131
+ if (await commit(initial, undefined, {}))
132
+ identity = initial.identity;
133
+ }
134
+ }
135
+ if (identity === undefined)
136
+ throw new FsError('EAGAIN');
137
+ const mutate = async (forwarded, action) => {
138
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
139
+ const current = await read(forwarded);
140
+ const result = action(current.value);
141
+ if (await commit(current.value, current.etag, forwarded))
142
+ return result;
143
+ }
144
+ throw new FsError('EAGAIN', { message: 'S3 namespace conditional retry limit exceeded' });
145
+ };
146
+ const entry = (value, name) => {
147
+ const node = value.nodes[name];
148
+ if (!node)
149
+ throw new FsError('ENOENT', { path: name });
150
+ return node;
151
+ };
152
+ const directory = (value, name) => {
153
+ const node = entry(value, name);
154
+ if (node.type !== 'directory')
155
+ throw new FsError('ENOTDIR', { path: name });
156
+ return node;
157
+ };
158
+ const stat = (node) => Object.freeze({ type: node.type, size: node.bytes.length,
159
+ mode: (node.type === 'file' ? 0o100000 : 0o040000) | node.mode, mtimeMs: node.time, atimeMs: node.time, ctimeMs: node.time,
160
+ identityScope: scope, ino: node.ino, revision: node.revision });
161
+ const create = (value, name, type, mode) => {
162
+ directory(value, dirname(name));
163
+ if (value.nodes[name])
164
+ throw new FsError('EEXIST', { path: name });
165
+ if (Object.keys(value.nodes).length >= maxEntries || !Number.isSafeInteger(value.nextInode + 1))
166
+ throw new FsError('ENOSPC');
167
+ const node = { ino: value.nextInode++, revision: 1, type, mode, time: Date.now(), bytes: [] };
168
+ value.nodes[name] = node;
169
+ return node;
170
+ };
171
+ const resolveName = (value, input, allowMissing = false, newDirectory = false, parentMode) => {
172
+ path(input);
173
+ const components = input.split('/').filter(Boolean);
174
+ let current = '/';
175
+ for (let index = 0; index < components.length; index++) {
176
+ directory(value, current);
177
+ const component = components[index];
178
+ if (component === '.')
179
+ continue;
180
+ if (component === '..') {
181
+ current = dirname(current);
182
+ continue;
183
+ }
184
+ current = (current === '/' ? '' : current) + '/' + component;
185
+ if (!value.nodes[current]) {
186
+ if (index + 1 < components.length && parentMode !== undefined)
187
+ create(value, current, 'directory', parentMode);
188
+ else if (index + 1 < components.length || !allowMissing)
189
+ throw new FsError('ENOENT', { path: current });
190
+ }
191
+ }
192
+ if (input.endsWith('/')) {
193
+ if (value.nodes[current])
194
+ directory(value, current);
195
+ else if (!newDirectory)
196
+ throw new FsError('ENOTDIR');
197
+ }
198
+ return current;
199
+ };
200
+ const write = (value, name, data, exclusive = false, mode = 0o644, append = false) => {
201
+ let node = value.nodes[name];
202
+ if (node && exclusive)
203
+ throw new FsError('EEXIST', { path: name });
204
+ node ??= create(value, name, 'file', mode);
205
+ if (node.type !== 'file')
206
+ throw new FsError('EISDIR', { path: name });
207
+ const otherBytes = Object.values(value.nodes).reduce((size, current) => size + (current === node ? 0 : current.bytes.length), 0);
208
+ if (data.length + (append ? node.bytes.length : 0) > maxBytes - otherBytes)
209
+ throw new FsError('ENOSPC');
210
+ node.bytes = append ? [...node.bytes, ...data] : Array.from(data);
211
+ node.revision++;
212
+ node.time = Date.now();
213
+ return node;
214
+ };
215
+ const remove = (value, name, recursive, required) => {
216
+ if (name === '/')
217
+ throw new FsError('EBUSY');
218
+ const node = entry(value, name);
219
+ if (required && node.type !== required)
220
+ throw new FsError(required === 'file' ? 'EISDIR' : 'ENOTDIR');
221
+ const children = Object.keys(value.nodes).filter(candidate => candidate.startsWith(name + '/'));
222
+ if (children.length && !recursive)
223
+ throw new FsError('ENOTEMPTY');
224
+ for (const child of children)
225
+ delete value.nodes[child];
226
+ delete value.nodes[name];
227
+ };
228
+ const conditionalRemove = (input, forwarded, recursive) => mutate(forwarded, value => {
229
+ const name = resolveName(value, input);
230
+ const node = entry(value, name);
231
+ const parent = directory(value, dirname(name));
232
+ if (forwarded.expected?.identityScope !== scope || forwarded.parent.identityScope !== scope
233
+ || ![forwarded.expected.ino, forwarded.parent.ino].every(Number.isSafeInteger)
234
+ || !recursive && !Number.isSafeInteger(forwarded.expected.revision))
235
+ throw new FsError('ENOTSUP');
236
+ if (forwarded.expected.ino !== node.ino || forwarded.expected.type !== node.type
237
+ || forwarded.parent.ino !== parent.ino || forwarded.parent.type !== 'directory'
238
+ || !recursive && (forwarded.expected.revision !== node.revision || forwarded.expected.size !== node.bytes.length
239
+ || forwarded.expected.mode !== stat(node).mode || forwarded.expected.mtimeMs !== node.time || forwarded.expected.ctimeMs !== node.time))
240
+ throw new FsError('EAGAIN');
241
+ remove(value, name, recursive, recursive ? 'directory' : undefined);
242
+ });
243
+ const observe = async (input, forwarded = {}) => {
244
+ const { value } = await read(forwarded);
245
+ return stat(entry(value, resolveName(value, input)));
246
+ };
247
+ const filesystem = {
248
+ capabilities: Object.freeze({ read: true, write: true, append: true, stat: true, readdir: true, realpath: true, access: true,
249
+ mkdir: true, recursiveMkdir: true, explicitDirectories: true, implicitDirectories: false, exclusiveCreate: true,
250
+ remove: true, removeDirectory: true, recursiveRemove: true, atomicTreeRemoval: true, atomicEntryRemoval: true,
251
+ rename: true, atomicRename: true, atomicRenameNoReplace: true, copy: true, exclusiveCopy: true,
252
+ readOnly: false, symlinks: false, hardlinks: false, permissions: false, timestamps: false }),
253
+ stat: observe,
254
+ lstat: observe,
255
+ async readFile(input, forwarded = {}) {
256
+ const { value } = await read(forwarded);
257
+ const node = entry(value, resolveName(value, input));
258
+ if (node.type !== 'file')
259
+ throw new FsError('EISDIR');
260
+ if (forwarded.maxBytes !== undefined && node.bytes.length > forwarded.maxBytes)
261
+ throw new FsError('EFBIG');
262
+ return Uint8Array.from(node.bytes);
263
+ },
264
+ async writeFile(input, data, forwarded = {}) {
265
+ if (!(data instanceof Uint8Array))
266
+ throw new FsError('EINVAL');
267
+ if (data.length > maxBytes)
268
+ throw new FsError('ENOSPC');
269
+ const flag = forwarded.flag ?? 'w';
270
+ if (!['w', 'wx', 'a', 'ax'].includes(flag))
271
+ throw new FsError('EINVAL');
272
+ const owned = Uint8Array.from(data);
273
+ await mutate(forwarded, value => write(value, resolveName(value, input, true), owned, flag === 'wx' || flag === 'ax', forwarded.mode, flag === 'a' || flag === 'ax'));
274
+ },
275
+ async appendFile(input, data, forwarded = {}) {
276
+ if (!(data instanceof Uint8Array))
277
+ throw new FsError('EINVAL');
278
+ if (data.length > maxBytes)
279
+ throw new FsError('ENOSPC');
280
+ const owned = Uint8Array.from(data);
281
+ await mutate(forwarded, value => write(value, resolveName(value, input, true), owned, false, forwarded.mode, true));
282
+ },
283
+ async readdir(input, forwarded = {}) {
284
+ const { value } = await read(forwarded);
285
+ const name = resolveName(value, input);
286
+ directory(value, name);
287
+ const entries = Object.entries(value.nodes).filter(([candidate]) => candidate !== '/' && dirname(candidate) === name);
288
+ if (forwarded.maxEntries !== undefined && entries.length > forwarded.maxEntries)
289
+ throw new FsError('EFBIG');
290
+ return entries.map(([candidate, node]) => ({ name: candidate.slice(candidate.lastIndexOf('/') + 1), type: node.type }));
291
+ },
292
+ async mkdir(input, forwarded = {}) {
293
+ await mutate(forwarded, value => {
294
+ const name = resolveName(value, input, true, true, forwarded.recursive ? forwarded.mode ?? 0o755 : undefined);
295
+ if (forwarded.recursive && value.nodes[name])
296
+ directory(value, name);
297
+ else
298
+ create(value, name, 'directory', forwarded.mode ?? 0o755);
299
+ });
300
+ },
301
+ async rm(input, forwarded = {}) {
302
+ entryPath(input);
303
+ await mutate(forwarded, value => {
304
+ const name = resolveName(value, input, forwarded.force === true, true);
305
+ if (!value.nodes[name] && forwarded.force)
306
+ return;
307
+ remove(value, name, forwarded.recursive === true);
308
+ });
309
+ },
310
+ async unlink(input, forwarded = {}) { entryPath(input); await mutate(forwarded, value => remove(value, resolveName(value, input), false, 'file')); },
311
+ async rmdir(input, forwarded = {}) { entryPath(input); await mutate(forwarded, value => remove(value, resolveName(value, input), false, 'directory')); },
312
+ async removeTreeConditional(input, forwarded) { entryPath(input); return conditionalRemove(input, forwarded, true); },
313
+ async removeEntryConditional(input, forwarded) { entryPath(input); return conditionalRemove(input, forwarded, false); },
314
+ async rename(source, destination, forwarded = {}) {
315
+ entryPath(source);
316
+ entryPath(destination);
317
+ await mutate(forwarded, value => {
318
+ const from = resolveName(value, source);
319
+ const node = entry(value, from);
320
+ const to = resolveName(value, destination, true, node.type === 'directory');
321
+ if (from === to)
322
+ return;
323
+ if (from === '/' || to === '/')
324
+ throw new FsError('EBUSY');
325
+ if (to.startsWith(from + '/'))
326
+ throw new FsError('EINVAL');
327
+ directory(value, dirname(to));
328
+ if (value.nodes[to]) {
329
+ if (forwarded.noReplace)
330
+ throw new FsError('EEXIST');
331
+ remove(value, to, false, node.type);
332
+ }
333
+ for (const name of Object.keys(value.nodes).filter(name => name === from || name.startsWith(from + '/'))) {
334
+ value.nodes[to + name.slice(from.length)] = value.nodes[name];
335
+ delete value.nodes[name];
336
+ }
337
+ });
338
+ },
339
+ async copyFile(source, destination, forwarded = {}) {
340
+ await mutate(forwarded, value => {
341
+ const from = resolveName(value, source), to = resolveName(value, destination, true);
342
+ const node = entry(value, from);
343
+ if (node.type !== 'file')
344
+ throw new FsError('EISDIR');
345
+ if (from === to)
346
+ throw new FsError('EINVAL');
347
+ write(value, to, Uint8Array.from(node.bytes), forwarded.exclusive, node.mode);
348
+ });
349
+ },
350
+ async realpath(input, forwarded = {}) { return resolveName((await read(forwarded)).value, input); },
351
+ async access(input, mode = 0, forwarded = {}) {
352
+ if (!Number.isInteger(mode) || mode < 0 || mode > 7)
353
+ throw new FsError('EINVAL');
354
+ resolveName((await read(forwarded)).value, input);
355
+ },
356
+ };
357
+ const version = (node) => {
358
+ const bytes = Uint8Array.from(node.bytes);
359
+ let closed = false;
360
+ return { revision: node.ino + ':' + node.revision, stat: stat(node),
361
+ async read(position, maxLength, forwarded = {}) {
362
+ forwarded.signal?.throwIfAborted();
363
+ if (closed)
364
+ throw new FsError('EBADF');
365
+ return bytes.slice(position, position + maxLength);
366
+ },
367
+ async close() { closed = true; },
368
+ };
369
+ };
370
+ return withObjectFileDescriptors(filesystem, {
371
+ async acquire(input, forwarded) {
372
+ const { value } = await read(forwarded);
373
+ const node = value.nodes[resolveName(value, input, true)];
374
+ if (!node)
375
+ return undefined;
376
+ if (node.type !== 'file')
377
+ throw new FsError('EISDIR');
378
+ return version(node);
379
+ },
380
+ async publish(input, expectedRevision, source, forwarded) {
381
+ const bytes = await collectBytes(source, { ...forwarded, maxBytes });
382
+ if (bytes.length !== forwarded.size)
383
+ throw new FsError('EIO');
384
+ const published = await mutate(forwarded, value => {
385
+ const name = resolveName(value, input, true), node = value.nodes[name];
386
+ if (expectedRevision === null ? node !== undefined : !node || node.ino + ':' + node.revision !== expectedRevision)
387
+ throw new FsError('EAGAIN');
388
+ return write(value, name, bytes, false, forwarded.mode);
389
+ });
390
+ return version(published);
391
+ },
392
+ }, descriptorOptions);
393
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.666",
3
+ "version": "0.1.668",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",