@poe-platform/safe-fs 0.1.722 → 0.1.724

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ console.log(await fs.readFile("note.txt", "utf8"));
25
25
 
26
26
  Output: `hello`, then `hello world`. Nothing touches the host filesystem. Raw adapters exchange `Uint8Array` values; the Node bridge adds strings, encodings, `Buffer` results, and stat predicates. Its `cwd` is the relative-path base and, by default, the confinement boundary. Set an explicit `root` to use a different boundary, for example `{ cwd: "/work", root: "/" }` to address a complete provider namespace. The bridge supports `unlink` when the provider offers atomic file removal; unsupported methods fail without host fallback. `createHostFileSystem()` provides trusted, unrestricted native host access when a rooted adapter is not desired.
27
27
 
28
- For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation, updates, and extraction can use this adapter's private owned staging in an isolated host tree. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
28
+ For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation and updates can use this adapter's private owned staging in an isolated host tree. Unzip extraction requires atomic ancestry verification, currently supported by MemoryFileSystem; the real adapter and mount views refuse it. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
29
29
 
30
30
  To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression commands, host adapters need atomic no-replace publication. Supply `createRealFileSystem({ root, renameNoReplace })` only when that callback binds a qualified native primitive such as Linux `renameat2` with `RENAME_NOREPLACE`. It receives resolved absolute host paths and an optional signal in its third argument. Existing destinations must reject `EEXIST` atomically; existence checks followed by rename and copy/delete are insufficient. Without this binding the adapter explicitly refuses those operations. See the [no-replace contract](src/contracts/filesystem.md#atomic-no-replace-rename).
31
31
 
@@ -109,7 +109,7 @@ before implementing the host operation; ordinary writes do not provide it.
109
109
  | `createOverlayFileSystem({ upper, lower })` | Reading through to a lower layer and writing changes to an upper layer |
110
110
  | `withFileSystemQuota(filesystem, { maxBytes })` | Enforcing a cumulative logical-byte ceiling across every write path |
111
111
 
112
- `createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
112
+ `createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. A trusted `reserveReadFile` callback can reserve host resources and return a release function; cancellation retains the reservation until the backend read settles. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
113
113
 
114
114
  ## Write an adapter
115
115
 
@@ -233,8 +233,8 @@ There are no package environment variables, implicit credentials, or automatic `
233
233
  | Mount | Required `root`: fallback filesystem. `mounts` defaults to `{}` and maps absolute virtual paths to filesystems. |
234
234
  | Overlay | Required `upper` and `lower`; `maxBufferBytes` is unlimited unless configured. |
235
235
  | Quota | `withFileSystemQuota` requires a nonnegative safe-integer `maxBytes`. It serializes mutations and counts files, symlinks, copies, hard links, truncation, and streaming writes. |
236
- | Node bridge | `cwd` defaults to `/`, must be an absolute virtual path; optional lifetime `signal`. |
237
- | Portable bridge | Same `cwd` and `signal`, plus required `codec` with `isEncoding`, `encode`, and `decode` functions. |
236
+ | Node bridge | `cwd` defaults to `/`, must be an absolute virtual path; optional lifetime `signal` and trusted `readFileMaxBytes` cap forwarded to backend reads before copy/decode. |
237
+ | Portable bridge | Same `cwd`, `signal` and `readFileMaxBytes`, plus required `codec` with `isEncoding`, `encode`, and `decode` functions. |
238
238
  | Catalog example | Required `files`: a plain record of single-component filenames to text strings; no other options. |
239
239
 
240
240
  ### Per-operation options
@@ -16,6 +16,8 @@ export declare class FileSystemBridge<Binary extends Uint8Array> {
16
16
  readonly cwd?: string;
17
17
  readonly root?: string;
18
18
  readonly signal?: AbortSignal;
19
+ readonly readFileMaxBytes?: number;
20
+ readonly reserveReadFile?: () => () => void;
19
21
  }, primitives: BridgePrimitives<Binary>);
20
22
  readFile(path: unknown, options?: {
21
23
  encoding?: null | undefined;
@@ -44,6 +44,8 @@ export class FileSystemBridge {
44
44
  #cwd;
45
45
  #root;
46
46
  #signal;
47
+ #readFileMaxBytes;
48
+ #reserveReadFile;
47
49
  #primitives;
48
50
  #codec;
49
51
  constructor(fs, options, primitives) {
@@ -67,6 +69,13 @@ export class FileSystemBridge {
67
69
  this.#root = primitives.paths.resolve("/", options.root ?? cwd);
68
70
  assertBridgePath(this.#root, this.#cwd);
69
71
  this.#signal = options.signal;
72
+ const maximum = options.readFileMaxBytes;
73
+ if (maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
74
+ throw new TypeError("Invalid readFileMaxBytes");
75
+ this.#readFileMaxBytes = maximum;
76
+ if (options.reserveReadFile !== undefined && typeof options.reserveReadFile !== "function")
77
+ throw new TypeError("Invalid reserveReadFile");
78
+ this.#reserveReadFile = options.reserveReadFile;
70
79
  }
71
80
  #encoding(value, fallback, names = false) {
72
81
  const selected = value == null ? fallback : value;
@@ -121,9 +130,29 @@ export class FileSystemBridge {
121
130
  if (options.encoding === "buffer")
122
131
  throw new TypeError("Invalid read encoding");
123
132
  const codec = this.#encoding(options.encoding, "buffer");
124
- const bytes = await this.#call([path], (signal, resolved) => this.#fs.readFile(resolved[0], signal), options.signal);
125
- const buffer = this.#primitives.copyBytes(bytes);
126
- return codec === "buffer" ? buffer : this.#codec.decode(buffer, codec);
133
+ const maxBytes = this.#readFileMaxBytes;
134
+ const release = this.#reserveReadFile?.();
135
+ let pending;
136
+ try {
137
+ const bytes = await this.#call([path], (signal, resolved) => {
138
+ pending = this.#fs.readFile(resolved[0], { ...signal, ...(maxBytes === undefined ? {} : { maxBytes }) });
139
+ return pending;
140
+ }, options.signal);
141
+ if (maxBytes !== undefined && bytes.byteLength > maxBytes)
142
+ throw fsError("EFBIG", "readFile");
143
+ const buffer = this.#primitives.copyBytes(bytes);
144
+ return codec === "buffer" ? buffer : this.#codec.decode(buffer, codec);
145
+ }
146
+ finally {
147
+ // Cancellation may reject #call before a backend acknowledges it. Keep
148
+ // its reservation until the actual read settles, as well as through decode.
149
+ if (release !== undefined) {
150
+ if (pending === undefined)
151
+ release();
152
+ else
153
+ void pending.then(release, release);
154
+ }
155
+ }
127
156
  }
128
157
  async #write(path, data, value, fallback) {
129
158
  const options = optionsRecord(value, ["encoding", "flag", "mode", "flush", "signal"]);
@@ -10,6 +10,10 @@ export interface FsBridgeOptions {
10
10
  /** Optional confinement boundary; defaults to cwd. */
11
11
  readonly root?: string;
12
12
  readonly signal?: AbortSignal;
13
+ /** Trusted per-read backend byte cap, enforced before copying or decoding. */
14
+ readonly readFileMaxBytes?: number;
15
+ /** Reserve host resources; release after decoding and actual backend settlement, including cancellation. */
16
+ readonly reserveReadFile?: () => () => void;
13
17
  }
14
18
  export interface FsBridgeFileSystem extends FileSystem {
15
19
  rmdir?(path: string, options?: FsOptions): Promise<void>;
@@ -70,6 +70,8 @@ export interface FileSystemCapabilities {
70
70
  /** Owned staging serialized within a trusted host; requires external tree isolation. */
71
71
  readonly trustedOwnedStaging?: boolean;
72
72
  readonly atomicFileStaging?: boolean;
73
+ /** Atomically verifies every supplied root-to-parent directory identity at publication. */
74
+ readonly atomicStagingAncestry?: boolean;
73
75
  readonly atomicFilePublication?: boolean;
74
76
  readonly atomicFileMutation?: boolean;
75
77
  readonly atomicEntryRemoval?: boolean;
@@ -206,6 +208,7 @@ export interface CreateStagedFileOptions extends FsOptions {
206
208
  readonly mtimeMs?: number;
207
209
  }
208
210
  export interface PublishStagedFileOptions extends FsOptions {
211
+ readonly ancestors?: readonly FileStagingEntry[];
209
212
  readonly parent: FileStat;
210
213
  readonly destination: FileStat | null;
211
214
  }
@@ -35,6 +35,7 @@ export declare class MemoryFileSystem implements FileSystem {
35
35
  timestamps: true;
36
36
  atomicRename: true;
37
37
  atomicFileStaging: true;
38
+ atomicStagingAncestry: true;
38
39
  atomicFileMutation: true;
39
40
  atomicEntryRemoval: true;
40
41
  atomicTreeRemoval: true;
@@ -68,7 +68,7 @@ export class MemoryFileSystem {
68
68
  permissions: true,
69
69
  timestamps: true,
70
70
  atomicRename: true,
71
- atomicFileStaging: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicTreeRemoval: true,
71
+ atomicFileStaging: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicTreeRemoval: true,
72
72
  atomicDirectoryMetadata: true,
73
73
  streamingRead: true,
74
74
  retainedRead: true,
@@ -134,7 +134,7 @@ export class MemoryFileSystem {
134
134
  }
135
135
  retained.set("/", this.root);
136
136
  }
137
- const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream"]);
137
+ const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream", "writeFileConditional", "removeFileConditional"]);
138
138
  const reads = new Set(["access", "capabilitiesFor", "compareEntry", "lstat", "stat", "readFile", "readStream", "readdir", "readlink", "realpath"]);
139
139
  const check = (path, followFinal) => {
140
140
  if (!roots.some(root => root === "/" || path === root || path.startsWith(`${root}/`)))
@@ -172,7 +172,7 @@ export class MemoryFileSystem {
172
172
  for (const path of paths) {
173
173
  if (typeof path !== "string")
174
174
  throw new FsError("EINVAL");
175
- check(path, ["chmod", "utimes", "appendFile", "writeFile", "writeStream", "link"].includes(String(property)));
175
+ check(path, ["chmod", "utimes", "appendFile", "writeFile", "writeStream", "writeFileConditional", "link"].includes(String(property)));
176
176
  }
177
177
  return Reflect.apply(value, target, args);
178
178
  };
@@ -655,6 +655,25 @@ export class MemoryFileSystem {
655
655
  }
656
656
  async publishStagedFile(staging, destination, options) {
657
657
  options.signal?.throwIfAborted();
658
+ if (options.ancestors) {
659
+ let path = "/";
660
+ const parentPath = destination.slice(0, destination.lastIndexOf("/")) || "/";
661
+ const paths = ["/", ...parentPath.split("/").filter(Boolean).map(component => {
662
+ path = path === "/" ? `/${component}` : `${path}/${component}`;
663
+ return path;
664
+ })];
665
+ if (paths.length !== options.ancestors.length)
666
+ this.fail("EINVAL", "publishStagedFile", destination);
667
+ for (let index = 0; index < paths.length; index++) {
668
+ const expected = options.ancestors[index];
669
+ if (expected.path !== paths[index])
670
+ this.fail("EINVAL", "publishStagedFile", destination);
671
+ const entry = this.entry(expected.path, "publishStagedFile");
672
+ if (entry.node?.type !== "directory")
673
+ this.fail("EAGAIN", "publishStagedFile", destination);
674
+ this.expectEntry(entry.node, expected.stat, expected.path, false);
675
+ }
676
+ }
658
677
  const { directory, file } = this.stagingLocations(staging);
659
678
  this.expectEntry(file.node, staging.file.stat, staging.file.path);
660
679
  const target = this.entry(destination, "publishStagedFile", true);
@@ -131,6 +131,7 @@ export class MountFileSystem {
131
131
  readOnly: all("readOnly"),
132
132
  ...(append === undefined ? {} : { append }),
133
133
  ...semantics,
134
+ atomicStagingAncestry: false,
134
135
  atomicRename: mounts.length === 1 && all("atomicRename"),
135
136
  ...(streamingRead === undefined ? {} : { streamingRead }),
136
137
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
@@ -154,7 +155,7 @@ export class MountFileSystem {
154
155
  ? { ...observed, descriptorWriteStream: false } : observed;
155
156
  const resize = declared.atomicResize === true && (typeof location.mount.backend.resizeFile !== "function" || declared.readOnly === true)
156
157
  ? { ...declared, atomicResize: false } : declared;
157
- const withOpen = { ...resize, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
158
+ const withOpen = { ...resize, atomicStagingAncestry: false, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
158
159
  const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
159
160
  : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
160
161
  if (location.synthetic)
@@ -2,11 +2,15 @@ import type { FileStat, FileSystem, FsOptions, OpenFileOptions } from "../../con
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  export interface ObjectFileVersion {
4
4
  readonly revision: string;
5
+ /** Opaque lease/reservation token; independent of namespace revision. */
6
+ readonly publicationToken?: string;
5
7
  readonly stat: FileStat;
6
8
  read(position: number, maxBytes: number, options?: FsOptions): Promise<Uint8Array>;
7
9
  close(): Promise<void>;
8
10
  }
9
11
  export interface ObjectFilePublicationOptions extends FsOptions {
12
+ /** Token from this descriptor's current retained version, if supplied by its store. */
13
+ readonly previousToken?: string;
10
14
  readonly size: number;
11
15
  readonly mode: number;
12
16
  }
@@ -50,6 +50,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
50
50
  };
51
51
  const version = (value) => {
52
52
  if (!value || typeof value.revision !== "string" || value.revision.length === 0 || value.revision.length > 4096
53
+ || value.publicationToken !== undefined && (typeof value.publicationToken !== "string" || value.publicationToken.length === 0 || value.publicationToken.length > 4096)
53
54
  || !value.stat || value.stat.type !== "file" || !Number.isSafeInteger(value.stat.size) || value.stat.size < 0
54
55
  || !Number.isSafeInteger(value.stat.mode) || value.stat.mode < 0 || value.stat.mode > 0o177777
55
56
  || ![value.stat.mtimeMs, value.stat.atimeMs, value.stat.ctimeMs].every(Number.isFinite)
@@ -57,7 +58,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
57
58
  throw new FsError("EIO", { message: "Invalid immutable object version" });
58
59
  if (value.stat.size > maxFileBytes)
59
60
  throw new FsError("EFBIG", { message: "Object descriptor file limit exceeded" });
60
- return Object.freeze({ revision: value.revision, stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
61
+ return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
61
62
  };
62
63
  const clearPages = (state) => {
63
64
  stagedBytes -= state.pages.size * chunkBytes;
@@ -181,7 +182,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
181
182
  yield bytes;
182
183
  }
183
184
  })();
184
- received = await store.publish(path, expected, source, { ...selected, size: state.size, mode: (state.head?.stat.mode ?? admitted.mode) & 0o7777 });
185
+ received = await store.publish(path, expected, source, { ...selected, ...(state.head?.publicationToken === undefined ? {} : { previousToken: state.head.publicationToken }), size: state.size, mode: (state.head?.stat.mode ?? admitted.mode) & 0o7777 });
185
186
  });
186
187
  const published = version(received);
187
188
  if (emitted !== state.size || published.stat.size !== state.size || published.revision === expected)
@@ -27,7 +27,9 @@ function namespaceMetadata(metadata, signal) {
27
27
  abort();
28
28
  });
29
29
  }
30
- async function scanUsedBytes(fs, limits, options, change, retained = change?.retained === true) {
30
+ async function scanUsedBytes(fs, limits, options, changeInput, retained = typeof changeInput !== "function" && changeInput?.retained === true) {
31
+ let change = typeof changeInput === "function" ? undefined : changeInput;
32
+ let prepare = typeof changeInput === "function" ? changeInput : undefined;
31
33
  let total = 0;
32
34
  let retainedTotal = 0n;
33
35
  let shrinkCredited = false;
@@ -46,6 +48,24 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
46
48
  const count = entries.length;
47
49
  admitDirectoryEntries(count, remaining, directory.path);
48
50
  remaining -= count;
51
+ if (prepare) {
52
+ change = await prepare();
53
+ prepare = undefined;
54
+ }
55
+ if (change?.stat.type === "directory") {
56
+ const stat = await fs.stat(directory.path, options);
57
+ const comparable = completeIdentity(stat) && completeIdentity(change.stat);
58
+ const comparison = comparable
59
+ ? stat.identityScope === change.stat.identityScope && stat.dev === change.stat.dev && stat.ino === change.stat.ino ? "same" : "distinct"
60
+ : fs.compareEntry ? await fs.compareEntry(change.path, fs, directory.path, options) : "unknown";
61
+ options?.signal?.throwIfAborted();
62
+ if (comparison !== "same" && comparison !== "distinct" && comparison !== "unknown")
63
+ throw new FsError("EIO", { syscall: "compareEntry", path: change.path });
64
+ if (comparison !== "distinct") {
65
+ total += Math.max(0, change.delta);
66
+ possibleAliases++;
67
+ }
68
+ }
49
69
  let processed = 0;
50
70
  for (const entry of entries) {
51
71
  options?.signal?.throwIfAborted();
@@ -69,7 +89,7 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
69
89
  if (!Number.isSafeInteger(stat.size) || stat.size < 0)
70
90
  throw new FsError("EIO", { syscall: "lstat", path, message: "invalid quota entry size" });
71
91
  retainedTotal += BigInt(stat.size);
72
- if (!change || stat.type !== "file")
92
+ if (!change || change.stat.type !== "file" || stat.type !== "file")
73
93
  continue;
74
94
  const known = completeIdentity(stat);
75
95
  const same = known && change.stat.identityScope === stat.identityScope && change.stat.dev === stat.dev && change.stat.ino === stat.ino;
@@ -89,7 +109,7 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
89
109
  continue;
90
110
  }
91
111
  total += stat.size;
92
- if (!change || stat.type !== "file")
112
+ if (!change || change.stat.type !== "file" || stat.type !== "file")
93
113
  continue;
94
114
  const scope = change.stat.identityScope;
95
115
  const comparable = [scope, stat.identityScope].every(value => typeof value === "symbol" || typeof value === "object" && value !== null)
@@ -169,6 +189,35 @@ export function withFileSystemQuota(fs, options) {
169
189
  return typeof original === "function" ? original.bind(fs) : original;
170
190
  },
171
191
  });
192
+ const creationChange = async (path, delta, fsOptions) => {
193
+ // Resolve existing ancestors before comparing directory identities. A dangling
194
+ // final symlink cannot safely identify its publication directory here.
195
+ let target;
196
+ try {
197
+ target = fs.canonicalizeMissingTarget?.(path, fsOptions) ?? path;
198
+ }
199
+ catch (error) {
200
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
201
+ throw new FileSystemQuotaError(options.maxBytes);
202
+ throw error;
203
+ }
204
+ try {
205
+ const entry = fs.canonicalizeMissingTarget ? undefined : await fs.lstat(target, fsOptions);
206
+ if (entry?.type === "symlink")
207
+ throw new FsError("ENOTSUP", { syscall: "write", path, message: "quota cannot locate missing symlink referent" });
208
+ }
209
+ catch (error) {
210
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
211
+ throw error;
212
+ }
213
+ const boundary = target.lastIndexOf("/");
214
+ const parent = await fs.realpath(boundary <= 0 ? "/" : target.slice(0, boundary), fsOptions);
215
+ const stat = await fs.stat(parent, fsOptions);
216
+ fsOptions?.signal?.throwIfAborted();
217
+ if (stat.type !== "directory")
218
+ throw new FsError("ENOTDIR", { syscall: "write", path });
219
+ return { path: parent, stat, delta };
220
+ };
172
221
  const assertDelta = async (path, nextBytes, fsOptions) => {
173
222
  fsOptions?.signal?.throwIfAborted();
174
223
  let current;
@@ -181,7 +230,9 @@ export function withFileSystemQuota(fs, options) {
181
230
  }
182
231
  const projected = current?.type === "file" && nextBytes > current.size
183
232
  ? await usedBytes(fs, scanLimits, fsOptions, { path, stat: current, delta: nextBytes - current.size })
184
- : await usedBytes(fs, scanLimits, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
233
+ : !current && nextBytes > 0
234
+ ? await usedBytes(fs, scanLimits, fsOptions, () => creationChange(path, nextBytes, fsOptions))
235
+ : await usedBytes(fs, scanLimits, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
185
236
  fsOptions?.signal?.throwIfAborted();
186
237
  if (projected > options.maxBytes)
187
238
  throw new FileSystemQuotaError(options.maxBytes);
@@ -205,8 +256,15 @@ export function withFileSystemQuota(fs, options) {
205
256
  const conditional = descriptor.capabilities.publication === "conditional" && pinned !== undefined;
206
257
  let publishedSize = pinned?.size ?? 0;
207
258
  let reserved = 0;
259
+ let publicationAliases = 1;
260
+ if (conditional) {
261
+ const baseline = await usedBytes(fs, scanLimits, admitted);
262
+ publicationAliases = await usedBytes(fs, scanLimits, admitted, () => creationChange(path, 1, admitted)) - baseline;
263
+ if (!Number.isSafeInteger(publicationAliases) || publicationAliases < 1)
264
+ throw new FsError("ENOTSUP", { syscall: "open", path, message: "quota cannot determine publication aliases" });
265
+ }
208
266
  const reserve = (size) => {
209
- const next = Math.max(0, size - publishedSize);
267
+ const next = Math.max(0, size - publishedSize) * publicationAliases;
210
268
  reservedBytes += next - reserved;
211
269
  reserved = next;
212
270
  };
@@ -231,13 +289,13 @@ export function withFileSystemQuota(fs, options) {
231
289
  if (!Number.isSafeInteger(nextBytes) || nextBytes < 0)
232
290
  throw new FsError("EFBIG", { syscall: "write", path });
233
291
  const projected = conditional
234
- ? await usedBytes(fs, scanLimits, operationOptions) - reserved + Math.max(0, nextBytes - publishedSize)
292
+ ? await usedBytes(fs, scanLimits, operationOptions) - reserved + Math.max(0, nextBytes - publishedSize) * publicationAliases
235
293
  : await usedBytes(fs, scanLimits, operationOptions, { path, stat, delta: nextBytes - stat.size, retained: true });
236
294
  operationOptions.signal?.throwIfAborted();
237
295
  if (projected > options.maxBytes)
238
296
  throw new FileSystemQuotaError(options.maxBytes);
239
297
  if (conditional)
240
- reserve(Math.max(nextBytes, publishedSize + reserved));
298
+ reserve(Math.max(nextBytes, publishedSize + reserved / publicationAliases));
241
299
  };
242
300
  const forwarded = forwardFileDescriptor(descriptor, (syscall, operationOptions, action) => syscall === "close" && !conditional ? action() : mutate(async () => {
243
301
  try {
@@ -429,7 +429,9 @@ export class S3FileSystem {
429
429
  fail("EIO", "readFile", path, "response body length does not match ContentLength");
430
430
  return bytes;
431
431
  }).catch((error) => {
432
- this.dispose(iterator ?? output.Body);
432
+ this.dispose(iterator);
433
+ if (output.Body !== iterator)
434
+ this.dispose(output.Body, iterator === undefined);
433
435
  throw error;
434
436
  });
435
437
  }
@@ -0,0 +1,8 @@
1
+ import { type ByteSource } from '../../contracts/io.js';
2
+ /** Admit the fixed manifest grammar before JSON.parse can allocate its graph. */
3
+ export declare function admitManifest(source: ByteSource, options: {
4
+ maxManifestBytes: number;
5
+ maxEntries: number;
6
+ maxBytes: number;
7
+ signal?: AbortSignal | undefined;
8
+ }): ByteSource;
@@ -0,0 +1,171 @@
1
+ import { FsError } from '../../contracts/errors.js';
2
+ import { readBytes } from '../../contracts/io.js';
3
+ /** Admit the fixed manifest grammar before JSON.parse can allocate its graph. */
4
+ export async function* admitManifest(source, options) {
5
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
6
+ const frames = [];
7
+ let started = false;
8
+ let done = false;
9
+ let token = '';
10
+ let quoted = false;
11
+ let escaped = false;
12
+ let entries = 0;
13
+ let bytes = 0;
14
+ let wireBytes = 0;
15
+ let allocation = 0;
16
+ // Conservative accounting for graph storage, array growth, and validation
17
+ // tables. This is an admission estimate, not a process-RSS guarantee.
18
+ const allocationLimit = options.maxManifestBytes * 4;
19
+ const invalid = () => { throw new FsError('EIO', { message: 'Invalid S3 namespace structure' }); };
20
+ const reserve = (amount) => {
21
+ if (amount > allocationLimit - allocation)
22
+ throw new FsError('EFBIG', { message: 'S3 namespace graph exceeds allocation budget' });
23
+ allocation += amount;
24
+ };
25
+ const open = (shape) => {
26
+ reserve(256);
27
+ frames.push({ shape, state: shape === 'bytes' ? 'valueOrEnd' : 'keyOrEnd', key: '', keys: new Set() });
28
+ };
29
+ const scalar = () => {
30
+ const frame = frames.at(-1) ?? invalid();
31
+ let value;
32
+ try {
33
+ value = JSON.parse(token);
34
+ }
35
+ catch {
36
+ invalid();
37
+ }
38
+ token = '';
39
+ if (frame.state === 'key' || frame.state === 'keyOrEnd') {
40
+ if (typeof value !== 'string' || frame.keys.has(value))
41
+ invalid();
42
+ const key = value;
43
+ if (frame.shape === 'nodes') {
44
+ if (key.length > 4096)
45
+ invalid();
46
+ if (++entries > options.maxEntries)
47
+ throw new FsError('ENOSPC');
48
+ }
49
+ else {
50
+ const fields = frame.shape === 'namespace' ? ['version', 'identity', 'nextInode', 'nodes']
51
+ : ['ino', 'revision', 'type', 'mode', 'time', 'bytes'];
52
+ if (!fields.includes(key))
53
+ invalid();
54
+ }
55
+ reserve(64 + key.length * 2);
56
+ frame.keys.add(key);
57
+ frame.key = key;
58
+ frame.state = 'colon';
59
+ return;
60
+ }
61
+ if (frame.state !== 'value' && frame.state !== 'valueOrEnd')
62
+ invalid();
63
+ if (frame.shape === 'bytes') {
64
+ if (!Number.isInteger(value) || value < 0 || value > 255)
65
+ invalid();
66
+ if (++bytes > options.maxBytes)
67
+ throw new FsError('ENOSPC');
68
+ reserve(16);
69
+ }
70
+ else if (frame.shape === 'nodes' || frame.key === 'nodes' || frame.key === 'bytes')
71
+ invalid();
72
+ else if (frame.key === 'identity' || frame.key === 'type') {
73
+ if (typeof value !== 'string' || (frame.key === 'identity' ? value.length < 1 || value.length > 128 : !['file', 'directory'].includes(value)))
74
+ invalid();
75
+ reserve(value.length * 2);
76
+ }
77
+ else if (typeof value !== 'number' || !Number.isFinite(value))
78
+ invalid();
79
+ frame.state = 'commaOrEnd';
80
+ };
81
+ const feed = (text) => {
82
+ for (const character of text) {
83
+ if (token) {
84
+ if (quoted) {
85
+ token += character;
86
+ if (token.length > 4096 * 6 + 2)
87
+ invalid();
88
+ if (escaped)
89
+ escaped = false;
90
+ else if (character === '\\')
91
+ escaped = true;
92
+ else if (character === '"') {
93
+ quoted = false;
94
+ scalar();
95
+ }
96
+ continue;
97
+ }
98
+ if (![' ', '\n', '\r', '\t', ',', '}', ']'].includes(character)) {
99
+ if (token.length >= 64)
100
+ invalid();
101
+ token += character;
102
+ continue;
103
+ }
104
+ scalar();
105
+ }
106
+ if ([' ', '\n', '\r', '\t'].includes(character))
107
+ continue;
108
+ if (done)
109
+ invalid();
110
+ if (!started) {
111
+ if (character !== '{')
112
+ invalid();
113
+ started = true;
114
+ open('namespace');
115
+ continue;
116
+ }
117
+ const frame = frames.at(-1) ?? invalid();
118
+ if (character === '}' || character === ']') {
119
+ if ((character === ']') !== (frame.shape === 'bytes')
120
+ || !['commaOrEnd', 'keyOrEnd', 'valueOrEnd'].includes(frame.state))
121
+ invalid();
122
+ if (frame.shape === 'namespace' && frame.keys.size !== 4 || frame.shape === 'node' && frame.keys.size !== 6)
123
+ invalid();
124
+ frames.pop();
125
+ if (!frames.length)
126
+ done = true;
127
+ continue;
128
+ }
129
+ if (frame.state === 'commaOrEnd') {
130
+ if (character !== ',')
131
+ invalid();
132
+ frame.state = frame.shape === 'bytes' ? 'value' : 'key';
133
+ continue;
134
+ }
135
+ if (frame.state === 'colon') {
136
+ if (character !== ':')
137
+ invalid();
138
+ frame.state = 'value';
139
+ continue;
140
+ }
141
+ if (frame.state === 'key' || frame.state === 'keyOrEnd') {
142
+ if (character !== '"')
143
+ invalid();
144
+ }
145
+ else if (character === '{' || character === '[') {
146
+ const shape = frame.shape === 'namespace' && frame.key === 'nodes' ? 'nodes'
147
+ : frame.shape === 'nodes' ? 'node' : frame.shape === 'node' && frame.key === 'bytes' ? 'bytes' : invalid();
148
+ if ((character === '[') !== (shape === 'bytes'))
149
+ invalid();
150
+ frame.state = 'commaOrEnd';
151
+ open(shape);
152
+ continue;
153
+ }
154
+ else if (frame.shape === 'nodes' || frame.key === 'nodes' || frame.key === 'bytes')
155
+ invalid();
156
+ quoted = character === '"';
157
+ escaped = false;
158
+ token = character;
159
+ }
160
+ };
161
+ for await (const chunk of readBytes(source, options.signal)) {
162
+ if (chunk.byteLength > options.maxManifestBytes - wireBytes)
163
+ throw new FsError('EFBIG');
164
+ wireBytes += chunk.byteLength;
165
+ feed(decoder.decode(chunk, { stream: true }));
166
+ yield chunk;
167
+ }
168
+ feed(decoder.decode());
169
+ if (!done || token || frames.length)
170
+ invalid();
171
+ }
@@ -2,6 +2,7 @@ import { FsError } from '../../contracts/errors.js';
2
2
  import { collectBytes } from '../../contracts/io.js';
3
3
  import { dirname, normalizePath, validatePath } from '../../contracts/virtual-path.js';
4
4
  import { withObjectFileDescriptors } from '../object-publication/index.js';
5
+ import { admitManifest } from './manifest-admission.js';
5
6
  function limit(value) {
6
7
  if (value === undefined)
7
8
  return Infinity;
@@ -17,7 +18,7 @@ export async function createS3NamespaceFileSystem(options) {
17
18
  throw new TypeError('An explicit S3 bucket and manifest key are required');
18
19
  const maxBytes = limit(options.maxBytes);
19
20
  const maxEntries = limit(options.maxEntries);
20
- const maxManifestBytes = limit(options.maxManifestBytes);
21
+ const maxManifestBytes = limit(options.maxManifestBytes ?? 4 * 1024 * 1024);
21
22
  const maxAttempts = limit(options.maxAttempts);
22
23
  const descriptorOptions = options;
23
24
  for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
@@ -78,7 +79,7 @@ export async function createS3NamespaceFileSystem(options) {
78
79
  forwarded.signal?.throwIfAborted();
79
80
  try {
80
81
  const response = await client.getObjectStream(object, forwarded.signal ? { abortSignal: forwarded.signal } : {});
81
- const body = await collectBytes(response.Body, { ...forwarded, ...(maxManifestBytes === Infinity ? {} : { maxBytes: maxManifestBytes }) });
82
+ const body = await collectBytes(admitManifest(response.Body, { maxManifestBytes, maxEntries, maxBytes, ...(forwarded.signal ? { signal: forwarded.signal } : {}) }), { ...forwarded, maxBytes: maxManifestBytes });
82
83
  if (typeof response.ETag !== 'string' || !response.ETag || response.ETag.startsWith('W/'))
83
84
  throw new FsError('EIO', { message: 'S3 namespace requires a strong object validator' });
84
85
  let decoded;
@@ -6,6 +6,76 @@ import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admissi
6
6
  import { assertCallbackAuthorityAllowed, compareEntries, registerEntryAuthority } from "../mount/comparison.js";
7
7
  import { compareWebDavResources, ownedResponseIdentifier, recordOwnedResourceStat, registerResourceQuery, resourceIdentifier } from "./resource-id.js";
8
8
  const timestampNamespace = "urn:virtual-bash:metadata";
9
+ const maxTimestampPropertyBytes = 4096;
10
+ /** Admit only five scalar members before native JSON parsing can allocate a graph. */
11
+ function preflightTimestamps(property) {
12
+ // Check the untrimmed text first; counting UTF-8 bytes does not allocate an encoded copy.
13
+ let bytes = 0;
14
+ for (const point of property.text) {
15
+ const code = point.codePointAt(0);
16
+ bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
17
+ if (bytes > maxTimestampPropertyBytes)
18
+ throw new Error("timestamp property exceeds 4KiB limit");
19
+ }
20
+ const text = scalar(property);
21
+ let offset = 0;
22
+ const invalid = () => { throw new Error("invalid timestamp property structure"); };
23
+ const whitespace = () => {
24
+ while (" \t\r\n".includes(text[offset] ?? "\0"))
25
+ offset++;
26
+ };
27
+ const stringEnd = () => {
28
+ if (text[offset++] !== '"')
29
+ invalid();
30
+ while (offset < text.length) {
31
+ const char = text[offset++];
32
+ if (char === '"')
33
+ return;
34
+ if (char === "\\")
35
+ offset++;
36
+ }
37
+ invalid();
38
+ };
39
+ if (text[offset++] !== "{")
40
+ invalid();
41
+ const fields = new Set();
42
+ while (true) {
43
+ whitespace();
44
+ const start = offset;
45
+ stringEnd();
46
+ const field = JSON.parse(text.slice(start, offset));
47
+ if (typeof field !== "string" || !["version", "etag", "type", "atimeMs", "mtimeMs"].includes(field)
48
+ || fields.has(field) || fields.size >= 5)
49
+ return invalid();
50
+ fields.add(field);
51
+ whitespace();
52
+ if (text[offset++] !== ":")
53
+ invalid();
54
+ whitespace();
55
+ if (text[offset] === '"')
56
+ stringEnd();
57
+ else {
58
+ const start = offset;
59
+ while (offset < text.length && text[offset] !== "," && text[offset] !== "}") {
60
+ if ('{[:"\\]'.includes(text[offset]))
61
+ invalid();
62
+ offset++;
63
+ }
64
+ if (offset === start)
65
+ invalid();
66
+ }
67
+ whitespace();
68
+ const delimiter = text[offset++];
69
+ if (delimiter === "}")
70
+ break;
71
+ if (delimiter !== ",")
72
+ invalid();
73
+ }
74
+ whitespace();
75
+ if (offset !== text.length || fields.size !== 5)
76
+ invalid();
77
+ return text;
78
+ }
9
79
  const propfindBody = '<?xml version="1.0" encoding="utf-8"?>'
10
80
  + '<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/>'
11
81
  + `<d:getlastmodified/><d:creationdate/><d:getetag/><v:timestamps xmlns:v="${timestampNamespace}"/></d:prop></d:propfind>`;
@@ -612,7 +682,7 @@ export class WebDavFileSystem {
612
682
  let mtimeMs = date("getlastmodified") ?? 0;
613
683
  let atimeMs = 0;
614
684
  if (timestampProperty) {
615
- const timestamps = JSON.parse(scalar(timestampProperty));
685
+ const timestamps = JSON.parse(preflightTimestamps(timestampProperty));
616
686
  if (typeof timestamps !== "object" || timestamps === null
617
687
  || !("version" in timestamps) || timestamps.version !== 1
618
688
  || !("etag" in timestamps) || typeof timestamps.etag !== "string" || !/^"[\x21\x23-\x7e\x80-\xff]*"$/.test(timestamps.etag)
@@ -6,6 +6,10 @@ export interface NodeFsBridgeOptions {
6
6
  /** Optional confinement boundary; defaults to cwd. */
7
7
  readonly root?: string;
8
8
  readonly signal?: AbortSignal;
9
+ /** Trusted per-read backend byte cap, enforced before copying or decoding. */
10
+ readonly readFileMaxBytes?: number;
11
+ /** Reserve host resources; release after decoding and actual backend settlement, including cancellation. */
12
+ readonly reserveReadFile?: () => () => void;
9
13
  }
10
14
  export interface NodeFsBridgeFileSystem extends FileSystem {
11
15
  rmdir?(path: string, options?: FsOptions): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.722",
3
+ "version": "0.1.724",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",