@muhkoo/connect 0.12.2-alpha.0 → 0.13.0-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.
package/dist/connect.d.ts CHANGED
@@ -5725,6 +5725,7 @@ declare class VfsNamespace {
5725
5725
  stat(path: string): Promise<VfsStat>;
5726
5726
  /** Immediate children of a directory. One record fetch. */
5727
5727
  list(path?: string): Promise<VfsStat[]>;
5728
+ private warn;
5728
5729
  /**
5729
5730
  * Every file path under a prefix, depth-first.
5730
5731
  *
@@ -3235,7 +3235,7 @@ function base64UrlDecode(s) {
3235
3235
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
3236
3236
  * `package.json` `version`.
3237
3237
  */
3238
- const VERSION = "0.12.2-alpha.0";
3238
+ const VERSION = "0.13.0-alpha.0";
3239
3239
 
3240
3240
  /**
3241
3241
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -11761,13 +11761,40 @@ function isUnder(path, dir) {
11761
11761
  * directory on the next lookup — a file literally named `..` could never be
11762
11762
  * addressed again.
11763
11763
  */
11764
+ /** The longest a single entry name may be. Matches the common filesystem limit. */
11765
+ const MAX_NAME_BYTES = 255;
11766
+ /**
11767
+ * Is this a name we are willing to store, and to hand back as a path segment?
11768
+ *
11769
+ * This is the READ-side check as much as the write-side one, and the read side
11770
+ * is the one that matters: a directory record is sealed with a key that any
11771
+ * holder of the master seed can derive, so its contents are not necessarily
11772
+ * something this SDK wrote. A name arriving from a record is input.
11773
+ *
11774
+ * What each rule is for:
11775
+ * - `/` and backslash would become path structure. Backslash is a separator on
11776
+ * Windows, which makes it traversal there and an odd filename elsewhere.
11777
+ * - `.` and `..` navigate rather than name.
11778
+ * - NUL terminates a path in every syscall that takes one.
11779
+ * - Other control characters and bidi overrides let a name print as something
11780
+ * other than what it is - the classic way a file passes review.
11781
+ * - A length cap, because nothing else imposes one.
11782
+ */
11783
+ function isSafeName(name) {
11784
+ if (!name || name === "." || name === "..")
11785
+ return false;
11786
+ if (name.includes("/") || name.includes(String.fromCharCode(92)))
11787
+ return false;
11788
+ const CONTROL_OR_BIDI = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]");
11789
+ if (CONTROL_OR_BIDI.test(name))
11790
+ return false;
11791
+ return new TextEncoder().encode(name).length <= MAX_NAME_BYTES;
11792
+ }
11764
11793
  function assertValidName(name) {
11765
11794
  if (!name)
11766
11795
  throw new Error("VFS: name cannot be empty");
11767
- if (name === "." || name === "..")
11768
- throw new Error(`VFS: "${name}" is not a usable name`);
11769
- if (name.includes("/"))
11770
- throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
11796
+ if (!isSafeName(name))
11797
+ throw new Error(`VFS: ${JSON.stringify(name)} is not a usable name`);
11771
11798
  }
11772
11799
  /**
11773
11800
  * MIME type for a filename.
@@ -11879,6 +11906,14 @@ function escapeLiteral(s) {
11879
11906
  * cost storage until a future sweep, which is the trade this shape makes in
11880
11907
  * exchange for single-record writes.
11881
11908
  */
11909
+ /**
11910
+ * How deep a tree may be before we stop walking it.
11911
+ *
11912
+ * Well past anything a real project needs, and low enough that a malformed or
11913
+ * hostile record cannot spend the caller's memory before the cycle guard sees a
11914
+ * repeat.
11915
+ */
11916
+ const MAX_DEPTH = 64;
11882
11917
  const DEFAULT_HISTORY_LIMIT = 20;
11883
11918
  class VfsNamespace {
11884
11919
  deps;
@@ -12007,9 +12042,29 @@ class VfsNamespace {
12007
12042
  const dir = await this.openDir(path);
12008
12043
  const base = this.resolve(path);
12009
12044
  return Object.entries(dir.node.entries)
12045
+ // Validate the name HERE, where it is read.
12046
+ //
12047
+ // A directory record is sealed under a key derivable from the master
12048
+ // seed, so its contents are not necessarily something this SDK
12049
+ // wrote, and the write-side check never sees them. An unusable name
12050
+ // is dropped rather than thrown on: one bad entry must not make the
12051
+ // whole directory unlistable, and callers walk this output straight
12052
+ // into filesystem paths.
12053
+ .filter(([name]) => {
12054
+ if (isSafeName(name))
12055
+ return true;
12056
+ this.warn(`ignoring entry with an unusable name in ${base}`);
12057
+ return false;
12058
+ })
12010
12059
  .map(([name, entry]) => toStat(join(base, name), name, entry))
12011
12060
  .sort(byDirsThenName);
12012
12061
  }
12062
+ warn(message) {
12063
+ // Kept quiet by default in a browser console that the user cannot act
12064
+ // on, but never silent: a dropped entry is a real difference between
12065
+ // what is stored and what is shown.
12066
+ console.warn(`[muhkoo/vfs] ${message}`);
12067
+ }
12013
12068
  /**
12014
12069
  * Every file path under a prefix, depth-first.
12015
12070
  *
@@ -12018,15 +12073,32 @@ class VfsNamespace {
12018
12073
  */
12019
12074
  async walk(path = ".") {
12020
12075
  const out = [];
12021
- const visit = async (dirPath) => {
12076
+ // Guard against a tree that is not one.
12077
+ //
12078
+ // `visit` recurses on a PATH, re-resolved from the root each time, so a
12079
+ // record whose entry points back at an ancestor - or, before names were
12080
+ // validated, an entry simply named ".." - recurses forever and grows
12081
+ // `out` without bound. Nothing in the format prevents that, and `mount`
12082
+ // calls this on every sync pass.
12083
+ const seen = new Set();
12084
+ const visit = async (dirPath, depth) => {
12085
+ if (depth > MAX_DEPTH) {
12086
+ this.warn(`stopping at ${dirPath}: deeper than ${MAX_DEPTH} levels`);
12087
+ return;
12088
+ }
12089
+ if (seen.has(dirPath)) {
12090
+ this.warn(`stopping at ${dirPath}: already visited (the tree contains a cycle)`);
12091
+ return;
12092
+ }
12093
+ seen.add(dirPath);
12022
12094
  for (const entry of await this.list(dirPath)) {
12023
12095
  if (entry.kind === "dir")
12024
- await visit(entry.path);
12096
+ await visit(entry.path, depth + 1);
12025
12097
  else
12026
12098
  out.push(entry.path);
12027
12099
  }
12028
12100
  };
12029
- await visit(this.resolve(path));
12101
+ await visit(this.resolve(path), 0);
12030
12102
  return out;
12031
12103
  }
12032
12104
  async readFile(path) {
@@ -12299,6 +12371,12 @@ class VfsNamespace {
12299
12371
  const root = await this.root();
12300
12372
  const reachable = new Set();
12301
12373
  const visit = async (dir) => {
12374
+ // `reachable` was already the visited set - it just was not being
12375
+ // TESTED before recursing, so a cycle looped forever. Here the
12376
+ // consequence would have been worse than a hang: sweep decides what
12377
+ // to delete, and it never got as far as deciding.
12378
+ if (reachable.has(dirKey(dir.id)))
12379
+ return;
12302
12380
  reachable.add(dirKey(dir.id));
12303
12381
  for (const entry of Object.values(dir.node.entries)) {
12304
12382
  if (entry.kind === "dir") {
@@ -12456,10 +12534,16 @@ class VfsNamespace {
12456
12534
  });
12457
12535
  }
12458
12536
  /** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
12459
- async purge(dir) {
12537
+ async purge(dir, seen = new Set()) {
12538
+ // A record that references an ancestor would otherwise recurse forever:
12539
+ // the id is only removed from the cache AFTER the loop, so re-entering
12540
+ // it hits the same cached record every time.
12541
+ if (seen.has(dir.id))
12542
+ return;
12543
+ seen.add(dir.id);
12460
12544
  for (const entry of Object.values(dir.node.entries)) {
12461
12545
  if (entry.kind === "dir") {
12462
- await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
12546
+ await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)), seen);
12463
12547
  }
12464
12548
  else {
12465
12549
  await this.releaseFile(dir, entry);
@@ -12473,18 +12557,32 @@ class VfsNamespace {
12473
12557
  * directory gets a fresh id and key, so the copy is independent of the
12474
12558
  * original — writing into one must never appear in the other.
12475
12559
  */
12476
- async cloneEntry(entry) {
12560
+ async cloneEntry(entry, seen = new Set()) {
12477
12561
  if (entry.kind === "file") {
12478
12562
  // Same bytes, second owner: take a reference so deleting either one
12479
12563
  // leaves the other readable.
12480
12564
  await this.deps.content.retain(entry.manifest);
12481
12565
  return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
12482
12566
  }
12567
+ // The most expensive walker to leave unguarded: it mints a NEW id, key
12568
+ // and stored record at every level, so a cyclic tree does not merely
12569
+ // hang - it writes records to the store, and to the user's bill, in an
12570
+ // unbounded loop.
12571
+ if (seen.has(entry.id) || seen.size > MAX_DEPTH) {
12572
+ throw new VfsConflictError("VFS: refusing to copy a directory that contains a cycle");
12573
+ }
12574
+ seen.add(entry.id);
12483
12575
  const source = await this.openDirById(entry.id, fromBase64(entry.key));
12484
12576
  const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
12485
12577
  const node = { v: 1, entries: {}, mtime: Date.now() };
12486
12578
  for (const [name, child] of Object.entries(source.node.entries)) {
12487
- node.entries[name] = await this.cloneEntry(child);
12579
+ // Names are validated on read everywhere else; a copy must not be
12580
+ // the way a bad one is carried into a fresh record.
12581
+ if (!isSafeName(name)) {
12582
+ this.warn(`skipping entry with an unusable name while copying`);
12583
+ continue;
12584
+ }
12585
+ node.entries[name] = await this.cloneEntry(child, seen);
12488
12586
  }
12489
12587
  const open = { id: clone.id, key: fromBase64(clone.key), node };
12490
12588
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));