@poe-platform/safe-fs 0.1.800 → 0.1.801

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
@@ -123,6 +123,7 @@ Capture `retainFileSystemCleanup(scoped, callback, { maxOperations })` before
123
123
  cancellation to clean up through a bounded view after ordinary scoped operations
124
124
  close. Cleanup preserves its owning scope and limits across separately bundled
125
125
  command runtimes, including restrictions added by filesystem wrappers.
126
+ Omit `maxOperations` or pass `Infinity` for unlimited cleanup operations.
126
127
 
127
128
  | Backend or wrapper | Use it for |
128
129
  | --- | --- |
@@ -252,7 +253,7 @@ There are no package environment variables, implicit credentials, or automatic `
252
253
 
253
254
  | API | Options and defaults |
254
255
  | --- | --- |
255
- | `createFileSystem(config, { registry })` | Required `config.type`; `config.options` defaults to an empty record. `registry` is required. Built-in `memory` accepts optional file, retained-byte, metadata and total-byte quotas; built-in `real` requires `root`. |
256
+ | `createFileSystem(config, { registry })` | Required `config.type`; `config.options` defaults to an empty record. `registry` is required. Built-in `memory` accepts optional file, retained-byte, metadata, total-byte, `maxPathBytes` and `maxPathComponents` quotas (omitted, `undefined`, or `Infinity` disables each quota); built-in `real` requires `root`. |
256
257
  | `createNodeFileSystemAdapterRegistry(extensions?)` | Optional map of additional adapter descriptors; defaults to only `memory` and `real`. |
257
258
  | Memory / read-only | Memory accepts independent optional `maxFileBytes`, `maxRetainedBytes`, `maxMetadataUnits` and `maxBytes` quotas, all unlimited by default. Read-only takes the backing filesystem, without an options object. |
258
259
  | Real | Required `root`: existing absolute host directory; the constructor/factory also accepts the root string directly. |
@@ -277,6 +278,8 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
277
278
  | `copyFile` | `exclusive` (default false) |
278
279
  | `readStream` | `start` (default 0), `endExclusive` (default end of file), `chunkSize` (built-in default 64 KiB) |
279
280
 
281
+ `validatePath(path, { maxPathBytes, maxPathComponents })` accepts optional UTF-8 byte and component quotas; both default to `Infinity`. Memory accepts the same constructor options and counts cumulative symlink expansion. Device operations accept these quotas under `pathLimits`. WebDAV accepts `maxTimestampPropertyBytes` for timestamp metadata, also unlimited by default.
282
+
280
283
  `access` takes a separate mode bitmask from `ACCESS_MODES`. `chmod` takes a mode, `utimes` takes millisecond timestamps, and `truncate` takes a byte length (default 0). For conditional chmod, check `capabilitiesFor(path, { conditionalChmod: true })` (or `capabilities`) and require `conditionalChmod: true`; supply `parent`, `expected`, and complete root-to-parent `ancestors` together. An optional mutation-free `commitGuard` must return literal `true` synchronously. Memory validates at its metadata commit; mount and supported Memory overlays preserve wrapper ancestry. Real uses its existing externally isolated host-tree boundary and does not prevent races with other processes. Backend limits still apply. Node-shaped bridge methods translate their own options rather than accepting these raw option objects; see the [bridge signatures](src/bridge/filesystem.ts).
281
284
 
282
285
  `collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxBytes` and `maxMemoryBytes` accept explicit `Infinity`. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`. Collection limits are disabled by default on Node, browser, and Worker targets. Each call owns its budget; concurrent collections do not consume one another’s allowance. The returned view may retain geometric spare capacity. Configured budgets cover collection, not caller-retained results, transport buffering, archive decoding, strings, or the rest of the runtime; use streaming APIs and limit concurrent workloads for larger inputs.
@@ -105,6 +105,8 @@ export interface FileSystemCapabilities {
105
105
  readonly [capability: string]: boolean | undefined;
106
106
  }
107
107
  export interface FsOptions {
108
+ /** Optional virtual device path traversal quotas; disabled by default. */
109
+ readonly pathLimits?: import("./virtual-path.js").PathLimits;
108
110
  readonly signal?: AbortSignal;
109
111
  }
110
112
  export interface ChmodOptions extends FsOptions {
@@ -1,2 +1,3 @@
1
1
  export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./virtual-path.js";
2
2
  export { posixPath, basename, dirname, extname, joinPath, isAbsolutePath } from "./portable-path.js";
3
+ export type { PathLimits } from "./virtual-path.js";
@@ -1,6 +1,11 @@
1
- export declare const MAX_PATH_BYTES = 65536;
2
- export declare const MAX_PATH_COMPONENTS = 2048;
3
- export declare function validatePath(path: string, maxComponents?: number): void;
1
+ export declare const MAX_PATH_BYTES: number;
2
+ export declare const MAX_PATH_COMPONENTS: number;
3
+ export interface PathLimits {
4
+ readonly maxPathBytes?: number | undefined;
5
+ readonly maxPathComponents?: number | undefined;
6
+ }
7
+ export declare function pathByteLength(path: string): number;
8
+ export declare function validatePath(path: string, limits?: PathLimits | number): void;
4
9
  export declare function resolvePath2(cwd: string, path0: string): string;
5
10
  export declare function resolvePath(cwd: string, ...paths: string[]): string;
6
11
  export declare function normalizePath(path: string, cwd?: string): string;
@@ -1,14 +1,31 @@
1
1
  import { FsError } from "./errors.js";
2
- export const MAX_PATH_BYTES = 65_536;
3
- export const MAX_PATH_COMPONENTS = 2048;
4
- export function validatePath(path, maxComponents = MAX_PATH_COMPONENTS) {
2
+ export const MAX_PATH_BYTES = Infinity;
3
+ export const MAX_PATH_COMPONENTS = Infinity;
4
+ export function pathByteLength(path) {
5
+ let bytes = 0;
6
+ for (const point of path) {
7
+ const code = point.codePointAt(0);
8
+ bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
9
+ }
10
+ return bytes;
11
+ }
12
+ export function validatePath(path, limits = {}) {
13
+ const maxBytes = typeof limits === "number" ? Infinity : limits.maxPathBytes ?? Infinity;
14
+ const maxComponents = typeof limits === "number" ? limits : limits.maxPathComponents ?? Infinity;
15
+ for (const value of [maxBytes, maxComponents]) {
16
+ if (value !== Infinity && (!Number.isSafeInteger(value) || value < 0)) {
17
+ throw new RangeError("Path limits must be nonnegative safe integers or Infinity");
18
+ }
19
+ }
5
20
  if (typeof path !== "string" || path.includes("\0")) {
6
21
  throw new FsError("EINVAL", { syscall: "resolve", message: "paths must be strings without NUL bytes" });
7
22
  }
8
- if (path.length > MAX_PATH_BYTES) {
23
+ if (maxBytes !== Infinity && pathByteLength(path) > maxBytes) {
9
24
  throw new FsError("ENAMETOOLONG", { syscall: "resolve", path });
10
25
  }
11
- const limit = Math.min(maxComponents, MAX_PATH_COMPONENTS);
26
+ if (maxComponents === Infinity)
27
+ return;
28
+ const limit = maxComponents;
12
29
  let components = 0;
13
30
  let inComponent = false;
14
31
  for (let i = 0; i < path.length; i++) {
@@ -1,5 +1,5 @@
1
1
  import { FsError, isFsError } from "../../contracts/errors.js";
2
- import { MAX_PATH_COMPONENTS, validatePath } from "../../contracts/virtual-path.js";
2
+ import { pathByteLength, validatePath } from "../../contracts/virtual-path.js";
3
3
  import { capturePathNamespace, pathNamespace } from "../path-namespace.js";
4
4
  import { isCleanAbsolutePath, tryResolveMemoryDevicePath } from "../memory/index.js";
5
5
  export const nullPath = "/dev/null";
@@ -23,20 +23,20 @@ export function lexicalDevicePath(path) {
23
23
  return `/${parts.join("/")}`;
24
24
  }
25
25
  async function resolveResizeDevicePath(filesystem, path, options, create, namespace, traversal) {
26
- validatePath(path);
26
+ validatePath(path, options.pathLimits);
27
27
  const components = (value) => {
28
28
  const result = value.split("/").filter(Boolean);
29
29
  if (value.endsWith("/"))
30
30
  result.push("");
31
31
  return result;
32
32
  };
33
- const pending = components(path);
34
- let remainingComponents = MAX_PATH_COMPONENTS - pending.length;
33
+ let pending = components(path);
34
+ let remainingComponents = (options.pathLimits?.maxPathComponents ?? Infinity) - pending.length;
35
35
  if (remainingComponents < 0)
36
36
  throw new FsError("ENAMETOOLONG", { path });
37
37
  const parts = [];
38
38
  let links = 0;
39
- let expanded = path.length;
39
+ let expanded = pathByteLength(path);
40
40
  let boundary;
41
41
  while (pending.length) {
42
42
  options.signal?.throwIfAborted();
@@ -99,9 +99,9 @@ async function resolveResizeDevicePath(filesystem, path, options, create, namesp
99
99
  throw new FsError("ELOOP", { path });
100
100
  const target = await Reflect.apply(readlink, filesystem, [candidate, options]);
101
101
  options.signal?.throwIfAborted();
102
- validatePath(target);
103
- expanded += target.length;
104
- if (expanded > 65536)
102
+ validatePath(target, options.pathLimits);
103
+ expanded += pathByteLength(target);
104
+ if (expanded > (options.pathLimits?.maxPathBytes ?? Infinity))
105
105
  throw new FsError("ENAMETOOLONG", { path });
106
106
  if (target.startsWith("/"))
107
107
  parts.splice(0, parts.length, ...(boundary ?? "/").split("/").filter(Boolean));
@@ -111,7 +111,7 @@ async function resolveResizeDevicePath(filesystem, path, options, create, namesp
111
111
  if (targetParts.length > remainingComponents)
112
112
  throw new FsError("ENAMETOOLONG", { path });
113
113
  remainingComponents -= targetParts.length;
114
- pending.unshift(...targetParts);
114
+ pending = targetParts.concat(pending);
115
115
  continue;
116
116
  }
117
117
  }
@@ -141,7 +141,8 @@ function exceedsComponentByteLimit(value) {
141
141
  }
142
142
  export async function resolveDevicePath(filesystem, path, options, followFinal = true, resizeCreate, traversal) {
143
143
  options.signal?.throwIfAborted();
144
- if (Reflect.get(filesystem, pathNamespace) === undefined) {
144
+ validatePath(path, options.pathLimits);
145
+ if (options.pathLimits === undefined && Reflect.get(filesystem, pathNamespace) === undefined) {
145
146
  const fast = tryResolveMemoryDevicePath(filesystem, path, resizeCreate);
146
147
  if (fast !== undefined)
147
148
  return fast;
@@ -155,13 +156,13 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
155
156
  const aliases = typeof lstat === "function";
156
157
  if (!aliases && lexical !== nullPath && lexical !== deviceDirectory && lexical !== "/")
157
158
  return lexical;
158
- const pending = path.split("/");
159
- let remainingComponents = MAX_PATH_COMPONENTS - pending.filter(Boolean).length;
159
+ let pending = path.split("/");
160
+ let remainingComponents = (options.pathLimits?.maxPathComponents ?? Infinity) - pending.filter(Boolean).length;
160
161
  if (remainingComponents < 0)
161
162
  throw new FsError("ENAMETOOLONG", { path });
162
163
  const parts = [];
163
164
  let links = 0;
164
- let expanded = path.length;
165
+ let expanded = pathByteLength(path);
165
166
  let absolute = path.startsWith("/");
166
167
  let traversalFailure;
167
168
  let failedDepth = Infinity;
@@ -218,9 +219,9 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
218
219
  throw new FsError("ELOOP", { path });
219
220
  const target = await Reflect.apply(readlink, filesystem, [lookup, options]);
220
221
  options.signal?.throwIfAborted();
221
- validatePath(target);
222
- expanded += target.length;
223
- if (expanded > 65536)
222
+ validatePath(target, options.pathLimits);
223
+ expanded += pathByteLength(target);
224
+ if (expanded > (options.pathLimits?.maxPathBytes ?? Infinity))
224
225
  throw new FsError("ENAMETOOLONG", { path });
225
226
  const targetSplit = target.split("/");
226
227
  const nonEmptyCount = targetSplit.filter(Boolean).length;
@@ -232,7 +233,7 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
232
233
  absolute = true;
233
234
  failedDepth = Infinity;
234
235
  }
235
- pending.unshift(...targetSplit);
236
+ pending = targetSplit.concat(pending);
236
237
  continue;
237
238
  }
238
239
  if (stat && pending.length && stat.type !== "directory") {
@@ -1,5 +1,5 @@
1
1
  import { FsError } from "../../contracts/errors.js";
2
- import { normalizePath } from "../../contracts/virtual-path.js";
2
+ import { normalizePath, validatePath, pathByteLength } from "../../contracts/virtual-path.js";
3
3
  import { assertCallbackAuthorityAllowed, compareEntries, registerEntryAuthority } from "../mount/comparison.js";
4
4
  import { getOwnedS3Entry } from "../s3/registry.js";
5
5
  import { getOwnedWebDavEntry } from "../webdav/resource-id.js";
@@ -56,10 +56,10 @@ DUMMY_POOL_FILE_NODE.atimeMs = DUMMY_POOL_FILE_NODE.mtimeMs = DUMMY_POOL_FILE_NO
56
56
  const defaultDateNow = Date.now;
57
57
  let fastWriteCachedNow = Date.now();
58
58
  let fastWriteNowTick = 0;
59
- const lastFastMapMiss = {
60
- map: undefined, key: "", slot: -1,
61
- };
62
59
  class FastDirectoryEntriesMap {
60
+ // Keep speculative slots local so a failed write cannot pin a tenant globally.
61
+ _missKey = "";
62
+ _missSlot = -1;
63
63
  _table = new Int16Array(128).fill(-1);
64
64
  _mask = 127;
65
65
  _keys = new Array(64).fill("");
@@ -96,9 +96,8 @@ class FastDirectoryEntriesMap {
96
96
  getForWriteWithHash(k, h) {
97
97
  let slot = h & this._mask;
98
98
  if (this.size === 0) {
99
- lastFastMapMiss.map = this;
100
- lastFastMapMiss.key = k;
101
- lastFastMapMiss.slot = slot;
99
+ this._missKey = k;
100
+ this._missSlot = slot;
102
101
  return undefined;
103
102
  }
104
103
  const mask = this._mask;
@@ -108,9 +107,8 @@ class FastDirectoryEntriesMap {
108
107
  while (true) {
109
108
  const idx = table[slot];
110
109
  if (idx === -1) {
111
- lastFastMapMiss.map = this;
112
- lastFastMapMiss.key = k;
113
- lastFastMapMiss.slot = firstDeleted !== -1 ? firstDeleted : slot;
110
+ this._missKey = k;
111
+ this._missSlot = firstDeleted !== -1 ? firstDeleted : slot;
114
112
  return undefined;
115
113
  }
116
114
  if (idx === -2) {
@@ -160,6 +158,7 @@ class FastDirectoryEntriesMap {
160
158
  return this.get(k) !== undefined;
161
159
  }
162
160
  _rebuild(newCap) {
161
+ this._missKey = "";
163
162
  const newTableLen = newCap * 2;
164
163
  const newMask = newTableLen - 1;
165
164
  const newTable = newCap <= 16384 ? new Int16Array(newTableLen).fill(-1) : new Int32Array(newTableLen).fill(-1);
@@ -199,15 +198,16 @@ class FastDirectoryEntriesMap {
199
198
  this._next = writeIdx;
200
199
  }
201
200
  set(k, v) {
202
- if (this === lastFastMapMiss.map && k === lastFastMapMiss.key && this._next < this._keys.length) {
201
+ if (k === this._missKey && this._next < this._keys.length) {
203
202
  const entryIdx = this._next++;
204
- this._table[lastFastMapMiss.slot] = entryIdx;
203
+ this._table[this._missSlot] = entryIdx;
205
204
  this._keys[entryIdx] = k;
206
205
  this._vals[entryIdx] = v;
207
206
  this.size++;
208
- lastFastMapMiss.map = undefined;
207
+ this._missKey = "";
209
208
  return this;
210
209
  }
210
+ this._missKey = "";
211
211
  if (this._next >= this._keys.length) {
212
212
  this._rebuild(this.size * 2 <= this._keys.length ? this._keys.length : this._keys.length * 2);
213
213
  }
@@ -239,6 +239,7 @@ class FastDirectoryEntriesMap {
239
239
  }
240
240
  }
241
241
  delete(k) {
242
+ this._missKey = "";
242
243
  if (this.size === 0)
243
244
  return false;
244
245
  let slot = this._hash(k);
@@ -266,6 +267,7 @@ class FastDirectoryEntriesMap {
266
267
  }
267
268
  }
268
269
  clear() {
270
+ this._missKey = "";
269
271
  if (this._next > 0) {
270
272
  this._table.fill(-1);
271
273
  for (let i = 0; i < this._next; i++) {
@@ -710,7 +712,7 @@ export class MemoryFileSystem {
710
712
  const owner = ownedStores.get(this);
711
713
  if (path !== "")
712
714
  this.validatePath(path, "realpath");
713
- return resolveMissingTarget(owner.root, path || ".", options.signal);
715
+ return resolveMissingTarget(owner.root, path || ".", options.signal, this.ledger.limits);
714
716
  }
715
717
  async confineExtraction(roots, options = {}) {
716
718
  options.signal?.throwIfAborted();
@@ -968,6 +970,8 @@ export class MemoryFileSystem {
968
970
  this.fail("EINVAL", syscall, path);
969
971
  if (path.length === 0)
970
972
  this.fail("ENOENT", syscall, path);
973
+ if (this.ledger.limits.maxPathBytes !== Infinity || this.ledger.limits.maxPathComponents !== Infinity)
974
+ validatePath(path, this.ledger.limits);
971
975
  }
972
976
  permission(node, mask, syscall, path) {
973
977
  if (((node.mode >> 6) & mask) !== mask)
@@ -989,6 +993,8 @@ export class MemoryFileSystem {
989
993
  node.mtimeMs = node.ctimeMs = now;
990
994
  }
991
995
  resolveNode(path, syscall, followFinal = true) {
996
+ if (this.ledger.limits.maxPathBytes !== Infinity || this.ledger.limits.maxPathComponents !== Infinity)
997
+ validatePath(path, this.ledger.limits);
992
998
  const cache = memoryCaches.get(this.ledger);
993
999
  if (this.symlinkCount === 0 && isCleanAbsolutePath(path)) {
994
1000
  const fastDir = cache.lastFastDirNode;
@@ -1075,7 +1081,7 @@ export class MemoryFileSystem {
1075
1081
  }
1076
1082
  }
1077
1083
  // Bound all component arrays allocated by this resolution, including cycles.
1078
- let remainingPathUnits = 65_536 - path.length;
1084
+ let remainingPathUnits = this.ledger.limits.maxPathBytes - pathByteLength(path);
1079
1085
  if (remainingPathUnits < 0)
1080
1086
  this.fail("ENAMETOOLONG", syscall, path);
1081
1087
  let initialComponents = 0;
@@ -1085,12 +1091,12 @@ export class MemoryFileSystem {
1085
1091
  inComp = false;
1086
1092
  else if (!inComp) {
1087
1093
  inComp = true;
1088
- if (++initialComponents > 256)
1094
+ if (++initialComponents > this.ledger.limits.maxPathComponents)
1089
1095
  this.fail("ENAMETOOLONG", syscall, path);
1090
1096
  }
1091
1097
  }
1092
- const pending = path.split("/").filter(Boolean);
1093
- let remainingComponents = 256 - pending.length;
1098
+ let pending = path.split("/").filter(Boolean);
1099
+ let remainingComponents = this.ledger.limits.maxPathComponents - pending.length;
1094
1100
  if (path.endsWith("/"))
1095
1101
  pending.push("");
1096
1102
  const stack = [{ node: this.root, name: "" }];
@@ -1140,9 +1146,9 @@ export class MemoryFileSystem {
1140
1146
  if (node.type === "symlink" && (options.followFinal !== false || pending.length > 0)) {
1141
1147
  if (++links > 40)
1142
1148
  this.fail("ELOOP", syscall, path);
1143
- if (node.target.length > remainingPathUnits)
1149
+ if (pathByteLength(node.target) > remainingPathUnits)
1144
1150
  this.fail("ENAMETOOLONG", syscall, path);
1145
- remainingPathUnits -= node.target.length;
1151
+ remainingPathUnits -= pathByteLength(node.target);
1146
1152
  const target = node.target.split("/").filter(Boolean);
1147
1153
  if (target.length > remainingComponents)
1148
1154
  this.fail("ENAMETOOLONG", syscall, path);
@@ -1151,7 +1157,7 @@ export class MemoryFileSystem {
1151
1157
  target.push("");
1152
1158
  if (options.resizeCreate !== undefined && target.at(-1) === "" && pending[0] === "")
1153
1159
  target.pop();
1154
- pending.unshift(...target);
1160
+ pending = target.concat(pending);
1155
1161
  if (node.target.startsWith("/"))
1156
1162
  stack.splice(1);
1157
1163
  continue;
@@ -1167,6 +1173,8 @@ export class MemoryFileSystem {
1167
1173
  };
1168
1174
  }
1169
1175
  file(path, syscall) {
1176
+ if (this.ledger.limits.maxPathBytes !== Infinity || this.ledger.limits.maxPathComponents !== Infinity)
1177
+ validatePath(path, this.ledger.limits);
1170
1178
  if (this.symlinkCount === 0 && path.charCodeAt(0) === 47 && path.length > 1 && path.indexOf("/", 1) === -1 && !path.includes("\0")) {
1171
1179
  const name = path === _lastFileSlice1Path ? _lastFileSlice1Name : (_lastFileSlice1Path = path, _lastFileSlice1Name = path.slice(1));
1172
1180
  if (name !== "." && name !== "..") {
@@ -1444,6 +1452,8 @@ export class MemoryFileSystem {
1444
1452
  }, syscall, path);
1445
1453
  }
1446
1454
  writeData(path, data, options, syscall) {
1455
+ if (this.ledger.limits.maxPathBytes !== Infinity || this.ledger.limits.maxPathComponents !== Infinity)
1456
+ validatePath(path, this.ledger.limits);
1447
1457
  if (!(data instanceof Uint8Array))
1448
1458
  throw new TypeError("Memory files require Uint8Array data");
1449
1459
  const target = this.prepareWrite(path, options, syscall);
@@ -1574,6 +1584,8 @@ export class MemoryFileSystem {
1574
1584
  this.changed(node, now);
1575
1585
  }
1576
1586
  writeMemoryFileFast(path, data, append, mode) {
1587
+ if (this.ledger.limits.maxPathBytes !== Infinity || this.ledger.limits.maxPathComponents !== Infinity)
1588
+ validatePath(path, this.ledger.limits);
1577
1589
  const cache = memoryCaches.get(this.ledger);
1578
1590
  const syscall = append ? "appendFile" : "writeFile";
1579
1591
  if (append &&
@@ -1582,8 +1594,8 @@ export class MemoryFileSystem {
1582
1594
  cache.lastFastFileNode.nlink !== 0 &&
1583
1595
  cache.lastFastDirNode !== undefined &&
1584
1596
  cache.lastFastDirNode.nlink !== 0 &&
1585
- (cache.lastFastDirNode.mode & 1) !== 0 &&
1586
- (cache.lastFastFileNode.mode & 2) !== 0) {
1597
+ (cache.lastFastDirNode.mode & 0o100) !== 0 &&
1598
+ (cache.lastFastFileNode.mode & 0o200) !== 0) {
1587
1599
  const current = cache.lastFastFileNode;
1588
1600
  const length = current.byteLength + data.byteLength;
1589
1601
  this.admitSize(current, length, syscall, path);
@@ -1599,7 +1611,7 @@ export class MemoryFileSystem {
1599
1611
  const cachedDir = cache.lastFastDirNode;
1600
1612
  if (cachedDir !== undefined &&
1601
1613
  cachedDir.nlink !== 0 &&
1602
- (cachedDir.mode & 1) !== 0 &&
1614
+ (cachedDir.mode & 0o100) !== 0 &&
1603
1615
  cachedPrefix.length > 1 &&
1604
1616
  path.startsWith(cachedPrefix) &&
1605
1617
  path.indexOf("/", cachedPrefix.length) === -1) {
@@ -1616,7 +1628,7 @@ export class MemoryFileSystem {
1616
1628
  name = path.slice(start);
1617
1629
  if (exceedsComponentByteLimit(name))
1618
1630
  this.fail("ENAMETOOLONG", syscall, path);
1619
- if (start > 1 && (parent.mode & 1) !== 0) {
1631
+ if (start > 1 && (parent.mode & 0o100) !== 0) {
1620
1632
  cache.lastFastDirPrefix = path.slice(0, start);
1621
1633
  cache.lastFastDirNode = parent;
1622
1634
  }
@@ -1753,6 +1765,7 @@ export class MemoryFileSystem {
1753
1765
  let ino = this.nextInode;
1754
1766
  this.nextInode = ino + batchCount;
1755
1767
  let nextIdx = 1;
1768
+ entries._missKey = "";
1756
1769
  entries._next = 1 + batchCount;
1757
1770
  entries.size = 1 + batchCount;
1758
1771
  const table = entries._table;
@@ -1802,6 +1815,8 @@ export class MemoryFileSystem {
1802
1815
  }
1803
1816
  }
1804
1817
  writeMemoryFileInDirFast(dirPrefix, name, data, append, mode, nameHash) {
1818
+ if (this.ledger.limits.maxPathBytes !== Infinity || this.ledger.limits.maxPathComponents !== Infinity)
1819
+ validatePath(dirPrefix + name, this.ledger.limits);
1805
1820
  const cache = this._cache;
1806
1821
  if (!append &&
1807
1822
  dirPrefix === cache.lastFastDirPrefix &&
@@ -1838,12 +1853,11 @@ export class MemoryFileSystem {
1838
1853
  node.allocation = DUMMY_POOL_ALLOCATION;
1839
1854
  node.sourceRef = node.data.subarray(0);
1840
1855
  const entryIdx = entries._next++;
1841
- entries._table[lastFastMapMiss.slot] = entryIdx;
1856
+ entries._table[entries._missSlot] = entryIdx;
1842
1857
  entries._keys[entryIdx] = name;
1843
1858
  entries._vals[entryIdx] = node;
1844
1859
  entries.size++;
1845
- lastFastMapMiss.map = undefined;
1846
- lastFastMapMiss.key = "";
1860
+ entries._missKey = "";
1847
1861
  this.ledger.reserve(name.length * 2, 2, "writeFile", name);
1848
1862
  this.totalBytes += length;
1849
1863
  this.mutationTick = (this.mutationTick + 1) | 0;
@@ -1864,8 +1878,8 @@ export class MemoryFileSystem {
1864
1878
  cache.lastFastFileNode.nlink !== 0 &&
1865
1879
  cache.lastFastDirNode !== undefined &&
1866
1880
  cache.lastFastDirNode.nlink !== 0 &&
1867
- (cache.lastFastDirNode.mode & 1) !== 0 &&
1868
- (cache.lastFastFileNode.mode & 2) !== 0) {
1881
+ (cache.lastFastDirNode.mode & 0o100) !== 0 &&
1882
+ (cache.lastFastFileNode.mode & 0o200) !== 0) {
1869
1883
  const current = cache.lastFastFileNode;
1870
1884
  const length = current.byteLength + data.byteLength;
1871
1885
  this.admitSize(current, length, syscall, name);
@@ -1878,7 +1892,7 @@ export class MemoryFileSystem {
1878
1892
  const cachedDir = cache.lastFastDirNode;
1879
1893
  if (cachedDir !== undefined &&
1880
1894
  cachedDir.nlink !== 0 &&
1881
- (cachedDir.mode & 1) !== 0 &&
1895
+ (cachedDir.mode & 0o100) !== 0 &&
1882
1896
  dirPrefix === cache.lastFastDirPrefix) {
1883
1897
  parent = cachedDir;
1884
1898
  if (exceedsComponentByteLimit(name))
@@ -1905,7 +1919,7 @@ export class MemoryFileSystem {
1905
1919
  this.permission(parent, 1, syscall, name);
1906
1920
  if (exceedsComponentByteLimit(name))
1907
1921
  this.fail("ENAMETOOLONG", syscall, name);
1908
- if (dirPrefix.length > 1 && (parent.mode & 1) !== 0) {
1922
+ if (dirPrefix.length > 1 && (parent.mode & 0o100) !== 0) {
1909
1923
  cache.lastFastDirPrefix = dirPrefix;
1910
1924
  cache.lastFastDirNode = parent;
1911
1925
  }
@@ -1984,12 +1998,8 @@ export class MemoryFileSystem {
1984
1998
  cache.lastFastFilePath = "";
1985
1999
  cache.lastFastFileName = name;
1986
2000
  cache.lastFastFileNode = node;
1987
- lastFastMapMiss.map = undefined;
1988
- lastFastMapMiss.key = "";
1989
2001
  }
1990
2002
  catch (error) {
1991
- lastFastMapMiss.map = undefined;
1992
- lastFastMapMiss.key = "";
1993
2003
  this.ledger.release(nameBytes, 2);
1994
2004
  throw error;
1995
2005
  }
@@ -3175,6 +3185,7 @@ export function tryOpenMemoryRedirectHandleSync(filesystem, path, append, mode,
3175
3185
  return undefined;
3176
3186
  }
3177
3187
  signal?.throwIfAborted();
3188
+ validatePath(path, owner.ledger.limits);
3178
3189
  const validMode = (mode & 0o777) === mode
3179
3190
  ? mode
3180
3191
  : mem.mode(mode, 0o666, "open", path);
@@ -3276,14 +3287,13 @@ export function tryOpenMemoryRedirectHandleSync(filesystem, path, append, mode,
3276
3287
  }
3277
3288
  const prevNlink = parent.cachedNlinkRev === parent.revision ? parent.cachedNlink : (parent.entries.size === 0 ? 2 : undefined);
3278
3289
  const entries = parent.entries;
3279
- if (lastFastMapMiss.map === entries && lastFastMapMiss.key === name && entries._next < entries._keys.length) {
3290
+ if (entries._missKey === name && entries._next < entries._keys.length) {
3280
3291
  const entryIdx = entries._next++;
3281
- entries._table[lastFastMapMiss.slot] = entryIdx;
3292
+ entries._table[entries._missSlot] = entryIdx;
3282
3293
  entries._keys[entryIdx] = name;
3283
3294
  entries._vals[entryIdx] = newNode;
3284
3295
  entries.size++;
3285
- lastFastMapMiss.map = undefined;
3286
- lastFastMapMiss.key = "";
3296
+ entries._missKey = "";
3287
3297
  }
3288
3298
  else {
3289
3299
  entries.set(name, newNode);
@@ -3359,6 +3369,7 @@ export function tryResolveMemoryDevicePath(filesystem, path, resizeCreate) {
3359
3369
  const mem = filesystem;
3360
3370
  if (!ownedStores.has(mem) || mem.symlinkCount !== 0 || !isStockMemoryMethods(mem, deviceFastMethodNames, false))
3361
3371
  return undefined;
3372
+ validatePath(path, ownedStores.get(mem).ledger.limits);
3362
3373
  if (!isCleanAbsolutePath(path) || path === "/dev" || path.startsWith("/dev/"))
3363
3374
  return undefined;
3364
3375
  let current = mem.root;
@@ -3540,6 +3551,7 @@ export function tryGetMemoryDirectoryEntryNamesSync(filesystem, path) {
3540
3551
  const owner = ownedStores.get(mem);
3541
3552
  if (!owner || mem.symlinkCount !== 0 || !isStockMemoryMethods(mem, readFileFastMethodNames, false))
3542
3553
  return undefined;
3554
+ validatePath(path, owner.ledger.limits);
3543
3555
  const root = mem.root;
3544
3556
  if (path === "/") {
3545
3557
  if (((root.mode >> 6) & 4) !== 4)
@@ -3606,6 +3618,7 @@ export function tryMkdirMemorySync(filesystem, path, recursive, mode, signal, di
3606
3618
  return false;
3607
3619
  }
3608
3620
  signal?.throwIfAborted();
3621
+ validatePath(path, owner.ledger.limits);
3609
3622
  const validMode = mem.mode(mode, 0o777, "mkdir", path);
3610
3623
  if (recursive) {
3611
3624
  let current = owner.root;
@@ -3657,6 +3670,7 @@ export function tryRmRfMemorySync(filesystem, path, signal) {
3657
3670
  return false;
3658
3671
  }
3659
3672
  signal?.throwIfAborted();
3673
+ validatePath(path, owner.ledger.limits);
3660
3674
  let current = owner.root;
3661
3675
  let start = 1;
3662
3676
  let targetNode;
@@ -1,9 +1,13 @@
1
1
  export interface MemoryFileSystemLimits {
2
2
  readonly maxBytes?: number;
3
+ readonly maxPathBytes: number;
4
+ readonly maxPathComponents: number;
3
5
  readonly maxFileBytes: number;
4
6
  readonly maxRetainedBytes: number;
5
7
  readonly maxMetadataUnits: number;
6
8
  }
7
- export type MemoryFileSystemOptions = Partial<MemoryFileSystemLimits>;
9
+ export type MemoryFileSystemOptions = {
10
+ readonly [Key in keyof MemoryFileSystemLimits]?: MemoryFileSystemLimits[Key] | undefined;
11
+ };
8
12
  export declare const defaultMemoryFileSystemLimits: Readonly<MemoryFileSystemLimits>;
9
13
  export declare function normalizeMemoryFileSystemLimits(options: unknown): Readonly<MemoryFileSystemLimits>;
@@ -1,25 +1,21 @@
1
1
  import { readConfigRecord } from "../../config.js";
2
2
  export const defaultMemoryFileSystemLimits = Object.freeze({
3
+ maxPathBytes: Infinity,
4
+ maxPathComponents: Infinity,
3
5
  maxFileBytes: Infinity,
4
6
  maxRetainedBytes: Infinity,
5
7
  maxMetadataUnits: Infinity,
6
8
  });
7
9
  export function normalizeMemoryFileSystemLimits(options) {
8
- const keys = ["maxFileBytes", "maxRetainedBytes", "maxMetadataUnits"];
10
+ const keys = ["maxPathBytes", "maxPathComponents", "maxFileBytes", "maxRetainedBytes", "maxMetadataUnits"];
9
11
  const record = readConfigRecord(options, "memory option", [...keys, "maxBytes"]);
10
12
  const limits = { ...defaultMemoryFileSystemLimits };
11
- if (Object.hasOwn(record, "maxBytes") && record.maxBytes !== undefined) {
12
- if (typeof record.maxBytes !== "number" || !Number.isSafeInteger(record.maxBytes) || record.maxBytes < 0) {
13
- throw new RangeError("maxBytes must be a nonnegative safe integer");
14
- }
15
- limits.maxBytes = record.maxBytes;
16
- }
17
- for (const key of keys) {
18
- if (!Object.hasOwn(record, key))
19
- continue;
13
+ for (const key of [...keys, "maxBytes"]) {
20
14
  const value = record[key];
15
+ if (value === undefined || value === Infinity)
16
+ continue;
21
17
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < (key === "maxMetadataUnits" ? 1 : 0)) {
22
- throw new RangeError(`${key} must be a ${key === "maxMetadataUnits" ? "positive" : "nonnegative"} safe integer`);
18
+ throw new RangeError(`${key} must be a ${key === "maxMetadataUnits" ? "positive" : "nonnegative"} safe integer or Infinity`);
23
19
  }
24
20
  limits[key] = value;
25
21
  }
@@ -1,3 +1,4 @@
1
+ import { type PathLimits } from "../../contracts/virtual-path.js";
1
2
  type Node = {
2
3
  type: "file";
3
4
  mode: number;
@@ -10,5 +11,5 @@ type Node = {
10
11
  mode: number;
11
12
  entries: ReadonlyMap<string, Node>;
12
13
  };
13
- export declare function resolveMissingTarget(root: Node, path: string, signal?: AbortSignal): string;
14
+ export declare function resolveMissingTarget(root: Node, path: string, signal?: AbortSignal, limits?: PathLimits): string;
14
15
  export {};
@@ -1,3 +1,4 @@
1
+ import { pathByteLength, validatePath } from "../../contracts/virtual-path.js";
1
2
  import { FsError } from "../../contracts/errors.js";
2
3
  function utf8ByteLength(value) {
3
4
  let bytes = 0;
@@ -32,11 +33,14 @@ function components(path, signal) {
32
33
  }
33
34
  return result;
34
35
  }
35
- export function resolveMissingTarget(root, path, signal) {
36
+ export function resolveMissingTarget(root, path, signal, limits = {}) {
37
+ validatePath(path, limits);
36
38
  const fail = (code, failedPath = path) => {
37
39
  throw new FsError(code, { syscall: "realpath", path: failedPath });
38
40
  };
39
41
  const origin = components(path, signal);
42
+ let remainingBytes = (limits.maxPathBytes ?? Infinity) - pathByteLength(path);
43
+ let remainingComponents = (limits.maxPathComponents ?? Infinity) - origin.length;
40
44
  const initial = { node: root, name: "" };
41
45
  let position = initial;
42
46
  let links = 0;
@@ -89,7 +93,10 @@ export function resolveMissingTarget(root, path, signal) {
89
93
  if (node.type === "symlink") {
90
94
  if (++links > 40)
91
95
  return fail("ELOOP");
96
+ validatePath(node.target, { maxPathBytes: remainingBytes, maxPathComponents: remainingComponents });
92
97
  const names = components(node.target, signal);
98
+ remainingBytes -= pathByteLength(node.target);
99
+ remainingComponents -= names.length;
93
100
  if (node.target.endsWith("/"))
94
101
  names.push({ name: ".", start: node.target.length });
95
102
  if (node.target.startsWith("/"))
@@ -18,7 +18,7 @@ export async function createS3NamespaceFileSystem(options) {
18
18
  throw new TypeError('An explicit S3 bucket and manifest key are required');
19
19
  const maxBytes = limit(options.maxBytes);
20
20
  const maxEntries = limit(options.maxEntries);
21
- const maxManifestBytes = limit(options.maxManifestBytes ?? 4 * 1024 * 1024);
21
+ const maxManifestBytes = options.maxManifestBytes === Infinity ? Infinity : limit(options.maxManifestBytes);
22
22
  const maxAttempts = limit(options.maxAttempts);
23
23
  const descriptorOptions = options;
24
24
  for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
@@ -537,8 +537,8 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
537
537
  }
538
538
  export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
539
539
  const maximum = options?.maxOperations ?? Infinity;
540
- if (options?.maxOperations !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
541
- throw new RangeError("cleanup maxOperations must be a nonnegative safe integer");
540
+ if (maximum !== Infinity && (!Number.isSafeInteger(maximum) || maximum < 0))
541
+ throw new RangeError("cleanup maxOperations must be a nonnegative safe integer or Infinity");
542
542
  if (typeof cleanupCallback !== "function")
543
543
  throw new TypeError("cleanup callback must be a function");
544
544
  const scope = originals.get(filesystem);
@@ -29,6 +29,8 @@ export interface WebDavFileSystemOptions {
29
29
  readonly maxResponseBytes?: number;
30
30
  /** Metadata response ceiling before decoding/parsing; unlimited unless configured. */
31
31
  readonly maxXmlBytes?: number;
32
+ /** Timestamp property UTF-8 byte ceiling; unlimited unless configured. */
33
+ readonly maxTimestampPropertyBytes?: number;
32
34
  readonly maxEntries?: number;
33
35
  /** XML structure ceilings; unlimited unless configured. */
34
36
  readonly xmlLimits?: Pick<XmlLimits, "maxNodes" | "maxContentNodes" | "maxDepth" | "maxAttributes">;
@@ -80,6 +82,7 @@ export declare class WebDavFileSystem implements FileSystem {
80
82
  private readonly headers;
81
83
  private readonly maxResponseBytes;
82
84
  private readonly maxXmlBytes;
85
+ private readonly maxTimestampPropertyBytes;
83
86
  private readonly maxEntries;
84
87
  private readonly xmlLimits;
85
88
  private readonly timeoutMs;
@@ -6,16 +6,15 @@ 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
9
  /** Admit only five scalar members before native JSON parsing can allocate a graph. */
11
- function preflightTimestamps(property) {
10
+ function preflightTimestamps(property, maxTimestampPropertyBytes) {
12
11
  // Check the untrimmed text first; counting UTF-8 bytes does not allocate an encoded copy.
13
12
  let bytes = 0;
14
13
  for (const point of property.text) {
15
14
  const code = point.codePointAt(0);
16
15
  bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
17
16
  if (bytes > maxTimestampPropertyBytes)
18
- throw new Error("timestamp property exceeds 4KiB limit");
17
+ throw new Error("timestamp property exceeds byte limit");
19
18
  }
20
19
  const text = scalar(property);
21
20
  let offset = 0;
@@ -233,6 +232,7 @@ export class WebDavFileSystem {
233
232
  headers;
234
233
  maxResponseBytes;
235
234
  maxXmlBytes;
235
+ maxTimestampPropertyBytes;
236
236
  maxEntries;
237
237
  xmlLimits;
238
238
  timeoutMs;
@@ -290,6 +290,7 @@ export class WebDavFileSystem {
290
290
  streamingAppend: this.requestStreamSupport !== false,
291
291
  });
292
292
  this.maxResponseBytes = options.maxResponseBytes === undefined ? Infinity : positive(options.maxResponseBytes, "maxResponseBytes", false, true);
293
+ this.maxTimestampPropertyBytes = positive(options.maxTimestampPropertyBytes ?? Infinity, "maxTimestampPropertyBytes", true, true);
293
294
  this.maxXmlBytes = options.maxXmlBytes === undefined ? Infinity : positive(options.maxXmlBytes, "maxXmlBytes", false, true);
294
295
  this.xmlLimits = Object.freeze({ ...options.xmlLimits });
295
296
  for (const [name, value] of Object.entries(this.xmlLimits))
@@ -714,7 +715,7 @@ export class WebDavFileSystem {
714
715
  let mtimeMs = date("getlastmodified") ?? 0;
715
716
  let atimeMs = 0;
716
717
  if (timestampProperty) {
717
- const timestamps = JSON.parse(preflightTimestamps(timestampProperty));
718
+ const timestamps = JSON.parse(preflightTimestamps(timestampProperty, this.maxTimestampPropertyBytes));
718
719
  if (typeof timestamps !== "object" || timestamps === null
719
720
  || !("version" in timestamps) || timestamps.version !== 1
720
721
  || !("etag" in timestamps) || typeof timestamps.etag !== "string" || !/^"[\x21\x23-\x7e\x80-\xff]*"$/.test(timestamps.etag)
@@ -264,27 +264,42 @@ export function* parseXmlSteps(input, limits = {}) {
264
264
  throw new XmlLimitError("maxContentNodes", "XML content node limit exceeded");
265
265
  };
266
266
  let pendingWork = 0;
267
- const skipWhitespace = () => {
267
+ const skipWhitespace = function* () {
268
268
  const start = offset;
269
269
  while (offset < source.length) {
270
270
  const c = source.charCodeAt(offset);
271
271
  if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
272
272
  break;
273
273
  offset++;
274
+ pendingWork++;
275
+ if (pendingWork >= 512) {
276
+ yield 512;
277
+ pendingWork -= 512;
278
+ }
274
279
  }
275
280
  return offset - start;
276
281
  };
277
- const scanName = () => {
282
+ const scanName = function* () {
278
283
  const start = offset;
279
284
  while (offset < source.length) {
280
285
  const c = source.charCodeAt(offset);
281
286
  if (c === 32 || c === 9 || c === 13 || c === 10 || c === 47 || c === 61 || c === 62 || c === 63)
282
287
  break;
283
288
  offset++;
289
+ pendingWork++;
290
+ if (pendingWork >= 512) {
291
+ yield 512;
292
+ pendingWork -= 512;
293
+ }
284
294
  }
285
295
  const name = source.slice(start, offset);
296
+ pendingWork += offset - start;
297
+ while (pendingWork >= 512) {
298
+ yield 512;
299
+ pendingWork -= 512;
300
+ }
286
301
  const [prefix, localName] = qualifiedNameSync(name, qualifiedNames);
287
- return [name, prefix, localName, (offset - start) * 2];
302
+ return [name, prefix, localName];
288
303
  };
289
304
  while (offset < source.length) {
290
305
  pendingWork += 1;
@@ -322,11 +337,15 @@ export function* parseXmlSteps(input, limits = {}) {
322
337
  }
323
338
  else {
324
339
  for (let index = 0; index < resolved.length; index++) {
340
+ pendingWork++;
341
+ if (pendingWork >= 512) {
342
+ yield 512;
343
+ pendingWork -= 512;
344
+ }
325
345
  const c = resolved.charCodeAt(index);
326
346
  if (c !== 32 && c !== 9 && c !== 13 && c !== 10)
327
347
  invalid("text outside the root");
328
348
  }
329
- pendingWork += resolved.length;
330
349
  while (pendingWork >= 512) {
331
350
  yield 512;
332
351
  pendingWork -= 512;
@@ -371,8 +390,7 @@ export function* parseXmlSteps(input, limits = {}) {
371
390
  else if (source.startsWith("<?", offset)) {
372
391
  const start = offset;
373
392
  offset += 2;
374
- const [target, , , wName] = scanName();
375
- pendingWork += wName;
393
+ const [target] = yield* scanName();
376
394
  while (pendingWork >= 512) {
377
395
  yield 512;
378
396
  pendingWork -= 512;
@@ -415,8 +433,8 @@ export function* parseXmlSteps(input, limits = {}) {
415
433
  }
416
434
  else if (source.charCodeAt(offset + 1) === 47) {
417
435
  offset += 2;
418
- const [name, , , wName] = scanName();
419
- pendingWork += wName + skipWhitespace();
436
+ const [name] = yield* scanName();
437
+ yield* skipWhitespace();
420
438
  while (pendingWork >= 512) {
421
439
  yield 512;
422
440
  pendingWork -= 512;
@@ -430,13 +448,14 @@ export function* parseXmlSteps(input, limits = {}) {
430
448
  else {
431
449
  offset++;
432
450
  const repeated = previousEmpty && source.startsWith(previousEmpty.suffix, offset) ? previousEmpty : undefined;
433
- const [name, prefix, localName, wName] = repeated
434
- ? [repeated.name, repeated.prefix, repeated.localName, repeated.name.length * 2]
435
- : scanName();
451
+ const [name, prefix, localName] = repeated
452
+ ? [repeated.name, repeated.prefix, repeated.localName]
453
+ : (yield* scanName());
436
454
  if (repeated)
437
455
  offset += name.length;
438
456
  // Preserve scan/validation work while reusing the admitted spelling.
439
- pendingWork += wName;
457
+ if (repeated)
458
+ pendingWork += name.length * 2;
440
459
  while (pendingWork >= 512) {
441
460
  yield 512;
442
461
  pendingWork -= 512;
@@ -446,8 +465,7 @@ export function* parseXmlSteps(input, limits = {}) {
446
465
  let namespaces = stack.at(-1)?.namespaces ?? new Map([["xml", xmlNamespace]]);
447
466
  let ownsNamespaces = stack.length === 0;
448
467
  while (!repeated) {
449
- const ws = skipWhitespace();
450
- pendingWork += ws;
468
+ const ws = (yield* skipWhitespace());
451
469
  while (pendingWork >= 512) {
452
470
  yield 512;
453
471
  pendingWork -= 512;
@@ -457,8 +475,7 @@ export function* parseXmlSteps(input, limits = {}) {
457
475
  break;
458
476
  if (ws === 0)
459
477
  invalid("attributes require whitespace");
460
- const [attribute, attrPrefix, attrLocal, wAttr] = scanName();
461
- pendingWork += wAttr;
478
+ const [attribute, attrPrefix, attrLocal] = yield* scanName();
462
479
  while (pendingWork >= 512) {
463
480
  yield 512;
464
481
  pendingWork -= 512;
@@ -469,10 +486,10 @@ export function* parseXmlSteps(input, limits = {}) {
469
486
  throw new XmlLimitError("maxAttributes", "XML attribute limit exceeded");
470
487
  if ((attributes?.size ?? 0) >= maxAttributesPerElement)
471
488
  throw new XmlLimitError("maxAttributesPerElement", "XML attribute limit exceeded");
472
- pendingWork += skipWhitespace();
489
+ yield* skipWhitespace();
473
490
  if (source[offset++] !== "=")
474
491
  invalid("missing attribute equals");
475
- pendingWork += skipWhitespace();
492
+ yield* skipWhitespace();
476
493
  const quote = source[offset++];
477
494
  if (quote !== '"' && quote !== "'")
478
495
  invalid("unquoted attribute");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.800",
3
+ "version": "0.1.801",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",