@muhkoo/connect 0.12.2-alpha.0 → 0.13.1-alpha.0

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.
@@ -5327,7 +5327,7 @@ function base64UrlDecode(s) {
5327
5327
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
5328
5328
  * `package.json` `version`.
5329
5329
  */
5330
- const VERSION = "0.12.2-alpha.0";
5330
+ const VERSION = "0.13.1-alpha.0";
5331
5331
 
5332
5332
  /**
5333
5333
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -12030,13 +12030,40 @@ function isUnder(path, dir) {
12030
12030
  * directory on the next lookup — a file literally named `..` could never be
12031
12031
  * addressed again.
12032
12032
  */
12033
+ /** The longest a single entry name may be. Matches the common filesystem limit. */
12034
+ const MAX_NAME_BYTES = 255;
12035
+ /**
12036
+ * Is this a name we are willing to store, and to hand back as a path segment?
12037
+ *
12038
+ * This is the READ-side check as much as the write-side one, and the read side
12039
+ * is the one that matters: a directory record is sealed with a key that any
12040
+ * holder of the master seed can derive, so its contents are not necessarily
12041
+ * something this SDK wrote. A name arriving from a record is input.
12042
+ *
12043
+ * What each rule is for:
12044
+ * - `/` and backslash would become path structure. Backslash is a separator on
12045
+ * Windows, which makes it traversal there and an odd filename elsewhere.
12046
+ * - `.` and `..` navigate rather than name.
12047
+ * - NUL terminates a path in every syscall that takes one.
12048
+ * - Other control characters and bidi overrides let a name print as something
12049
+ * other than what it is - the classic way a file passes review.
12050
+ * - A length cap, because nothing else imposes one.
12051
+ */
12052
+ function isSafeName(name) {
12053
+ if (!name || name === "." || name === "..")
12054
+ return false;
12055
+ if (name.includes("/") || name.includes(String.fromCharCode(92)))
12056
+ return false;
12057
+ const CONTROL_OR_BIDI = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]");
12058
+ if (CONTROL_OR_BIDI.test(name))
12059
+ return false;
12060
+ return new TextEncoder().encode(name).length <= MAX_NAME_BYTES;
12061
+ }
12033
12062
  function assertValidName(name) {
12034
12063
  if (!name)
12035
12064
  throw new Error("VFS: name cannot be empty");
12036
- if (name === "." || name === "..")
12037
- throw new Error(`VFS: "${name}" is not a usable name`);
12038
- if (name.includes("/"))
12039
- throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
12065
+ if (!isSafeName(name))
12066
+ throw new Error(`VFS: ${JSON.stringify(name)} is not a usable name`);
12040
12067
  }
12041
12068
  /**
12042
12069
  * MIME type for a filename.
@@ -12148,6 +12175,40 @@ function escapeLiteral(s) {
12148
12175
  * cost storage until a future sweep, which is the trade this shape makes in
12149
12176
  * exchange for single-record writes.
12150
12177
  */
12178
+ /**
12179
+ * How deep a tree may be before we stop walking it.
12180
+ *
12181
+ * Well past anything a real project needs, and low enough that a malformed or
12182
+ * hostile record cannot spend the caller's memory before the cycle guard sees a
12183
+ * repeat.
12184
+ */
12185
+ const MAX_DEPTH = 64;
12186
+ /**
12187
+ * How many directories `walk` lists at once.
12188
+ *
12189
+ * Enough to hide per-request latency on a wide tree; low enough that a big
12190
+ * project does not open a request per directory. Browsers cap concurrency per
12191
+ * origin anyway, so a larger number mostly moves the queue.
12192
+ */
12193
+ const WALK_CONCURRENCY = 8;
12194
+ /**
12195
+ * `Promise.all` with a ceiling, preserving input order.
12196
+ *
12197
+ * Results are written back BY INDEX rather than pushed as they settle, so the
12198
+ * output order matches the input regardless of which finishes first — which is
12199
+ * what lets `walk` parallelise without changing what it returns.
12200
+ */
12201
+ async function mapLimit(items, limit, fn) {
12202
+ if (items.length <= 1)
12203
+ return items.length ? [await fn(items[0])] : [];
12204
+ const out = new Array(items.length);
12205
+ let next = 0;
12206
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
12207
+ for (let i = next++; i < items.length; i = next++)
12208
+ out[i] = await fn(items[i]);
12209
+ }));
12210
+ return out;
12211
+ }
12151
12212
  const DEFAULT_HISTORY_LIMIT = 20;
12152
12213
  class VfsNamespace {
12153
12214
  deps;
@@ -12276,9 +12337,29 @@ class VfsNamespace {
12276
12337
  const dir = await this.openDir(path);
12277
12338
  const base = this.resolve(path);
12278
12339
  return Object.entries(dir.node.entries)
12340
+ // Validate the name HERE, where it is read.
12341
+ //
12342
+ // A directory record is sealed under a key derivable from the master
12343
+ // seed, so its contents are not necessarily something this SDK
12344
+ // wrote, and the write-side check never sees them. An unusable name
12345
+ // is dropped rather than thrown on: one bad entry must not make the
12346
+ // whole directory unlistable, and callers walk this output straight
12347
+ // into filesystem paths.
12348
+ .filter(([name]) => {
12349
+ if (isSafeName(name))
12350
+ return true;
12351
+ this.warn(`ignoring entry with an unusable name in ${base}`);
12352
+ return false;
12353
+ })
12279
12354
  .map(([name, entry]) => toStat(join(base, name), name, entry))
12280
12355
  .sort(byDirsThenName);
12281
12356
  }
12357
+ warn(message) {
12358
+ // Kept quiet by default in a browser console that the user cannot act
12359
+ // on, but never silent: a dropped entry is a real difference between
12360
+ // what is stored and what is shown.
12361
+ console.warn(`[muhkoo/vfs] ${message}`);
12362
+ }
12282
12363
  /**
12283
12364
  * Every file path under a prefix, depth-first.
12284
12365
  *
@@ -12286,17 +12367,40 @@ class VfsNamespace {
12286
12367
  * one fetch per directory. Fine for a project, wrong as a default.
12287
12368
  */
12288
12369
  async walk(path = ".") {
12289
- const out = [];
12290
- const visit = async (dirPath) => {
12291
- for (const entry of await this.list(dirPath)) {
12292
- if (entry.kind === "dir")
12293
- await visit(entry.path);
12294
- else
12295
- out.push(entry.path);
12296
- }
12370
+ /**
12371
+ * Sibling directories are listed CONCURRENTLY.
12372
+ *
12373
+ * Each `list` is a round trip for one directory record, and walking them
12374
+ * one after another cost the sum of those latencies: measured against a
12375
+ * real project, the walk alone was 1–2.1s before a single file had been
12376
+ * read. Directories are independent, so there was never a reason to
12377
+ * queue them.
12378
+ *
12379
+ * Order is preserved exactly. Each entry maps to its own slice of the
12380
+ * result and the slices are concatenated in listing order, so the output
12381
+ * is identical to the depth-first version — callers and tests that
12382
+ * depend on it are unaffected, and only the timing changes.
12383
+ */
12384
+ const seen = new Set();
12385
+ const visit = async (dirPath, depth) => {
12386
+ if (depth > MAX_DEPTH) {
12387
+ this.warn(`stopping at ${dirPath}: deeper than ${MAX_DEPTH} levels`);
12388
+ return [];
12389
+ }
12390
+ // Claimed BEFORE the first await, so two concurrent visits to the
12391
+ // same directory cannot both get past this. A record whose entry
12392
+ // points back at an ancestor would otherwise recurse forever, and
12393
+ // nothing in the format prevents one.
12394
+ if (seen.has(dirPath)) {
12395
+ this.warn(`stopping at ${dirPath}: already visited (the tree contains a cycle)`);
12396
+ return [];
12397
+ }
12398
+ seen.add(dirPath);
12399
+ const entries = await this.list(dirPath);
12400
+ const slices = await mapLimit(entries, WALK_CONCURRENCY, async (entry) => entry.kind === "dir" ? visit(entry.path, depth + 1) : [entry.path]);
12401
+ return slices.flat();
12297
12402
  };
12298
- await visit(this.resolve(path));
12299
- return out;
12403
+ return visit(this.resolve(path), 0);
12300
12404
  }
12301
12405
  async readFile(path) {
12302
12406
  const entry = await this.expectFile(path);
@@ -12568,6 +12672,12 @@ class VfsNamespace {
12568
12672
  const root = await this.root();
12569
12673
  const reachable = new Set();
12570
12674
  const visit = async (dir) => {
12675
+ // `reachable` was already the visited set - it just was not being
12676
+ // TESTED before recursing, so a cycle looped forever. Here the
12677
+ // consequence would have been worse than a hang: sweep decides what
12678
+ // to delete, and it never got as far as deciding.
12679
+ if (reachable.has(dirKey(dir.id)))
12680
+ return;
12571
12681
  reachable.add(dirKey(dir.id));
12572
12682
  for (const entry of Object.values(dir.node.entries)) {
12573
12683
  if (entry.kind === "dir") {
@@ -12725,10 +12835,16 @@ class VfsNamespace {
12725
12835
  });
12726
12836
  }
12727
12837
  /** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
12728
- async purge(dir) {
12838
+ async purge(dir, seen = new Set()) {
12839
+ // A record that references an ancestor would otherwise recurse forever:
12840
+ // the id is only removed from the cache AFTER the loop, so re-entering
12841
+ // it hits the same cached record every time.
12842
+ if (seen.has(dir.id))
12843
+ return;
12844
+ seen.add(dir.id);
12729
12845
  for (const entry of Object.values(dir.node.entries)) {
12730
12846
  if (entry.kind === "dir") {
12731
- await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
12847
+ await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)), seen);
12732
12848
  }
12733
12849
  else {
12734
12850
  await this.releaseFile(dir, entry);
@@ -12742,18 +12858,32 @@ class VfsNamespace {
12742
12858
  * directory gets a fresh id and key, so the copy is independent of the
12743
12859
  * original — writing into one must never appear in the other.
12744
12860
  */
12745
- async cloneEntry(entry) {
12861
+ async cloneEntry(entry, seen = new Set()) {
12746
12862
  if (entry.kind === "file") {
12747
12863
  // Same bytes, second owner: take a reference so deleting either one
12748
12864
  // leaves the other readable.
12749
12865
  await this.deps.content.retain(entry.manifest);
12750
12866
  return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
12751
12867
  }
12868
+ // The most expensive walker to leave unguarded: it mints a NEW id, key
12869
+ // and stored record at every level, so a cyclic tree does not merely
12870
+ // hang - it writes records to the store, and to the user's bill, in an
12871
+ // unbounded loop.
12872
+ if (seen.has(entry.id) || seen.size > MAX_DEPTH) {
12873
+ throw new VfsConflictError("VFS: refusing to copy a directory that contains a cycle");
12874
+ }
12875
+ seen.add(entry.id);
12752
12876
  const source = await this.openDirById(entry.id, fromBase64(entry.key));
12753
12877
  const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
12754
12878
  const node = { v: 1, entries: {}, mtime: Date.now() };
12755
12879
  for (const [name, child] of Object.entries(source.node.entries)) {
12756
- node.entries[name] = await this.cloneEntry(child);
12880
+ // Names are validated on read everywhere else; a copy must not be
12881
+ // the way a bad one is carried into a fresh record.
12882
+ if (!isSafeName(name)) {
12883
+ this.warn(`skipping entry with an unusable name while copying`);
12884
+ continue;
12885
+ }
12886
+ node.entries[name] = await this.cloneEntry(child, seen);
12757
12887
  }
12758
12888
  const open = { id: clone.id, key: fromBase64(clone.key), node };
12759
12889
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));