@muhkoo/connect 0.14.0-alpha.8 → 0.14.0-alpha.9

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.
@@ -1127,7 +1127,7 @@ const AES_GCM_KEY_BYTES = 32;
1127
1127
  /** Length of the AES-GCM IV in bytes (WebCrypto-recommended). */
1128
1128
  const AES_GCM_IV_BYTES$1 = 12;
1129
1129
  /** Length of the AES-GCM authentication tag in bytes (appended to ciphertext). */
1130
- const AES_GCM_TAG_BYTES = 16;
1130
+ const AES_GCM_TAG_BYTES$1 = 16;
1131
1131
  /**
1132
1132
  * Encrypt `plaintext` under a 32-byte AES-256 key with the supplied 12-byte
1133
1133
  * IV. Returns ciphertext with the 16-byte GCM tag appended (so the output
@@ -6481,7 +6481,7 @@ async function parseOrThrow(response, op) {
6481
6481
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6482
6482
  * `package.json` `version`.
6483
6483
  */
6484
- const VERSION = "0.14.0-alpha.8";
6484
+ const VERSION = "0.14.0-alpha.9";
6485
6485
 
6486
6486
  /**
6487
6487
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -12506,6 +12506,171 @@ class AccessTokensNamespace {
12506
12506
  }
12507
12507
  }
12508
12508
 
12509
+ /**
12510
+ * Absolute POSIX paths for the VFS.
12511
+ *
12512
+ * A path is a lookup route, not a stored value: nothing in the metadata records
12513
+ * a full path, only a name inside its parent directory. That is what makes
12514
+ * renaming or moving a directory a single-record edit instead of a rewrite of
12515
+ * every descendant.
12516
+ */
12517
+ /** Absolute, single leading slash, no `.` / `..`, no trailing slash. Root is `/`. */
12518
+ function normalizePath(input) {
12519
+ const out = [];
12520
+ for (const part of input.split("/")) {
12521
+ if (part === "" || part === ".")
12522
+ continue;
12523
+ if (part === "..")
12524
+ out.pop();
12525
+ else
12526
+ out.push(part);
12527
+ }
12528
+ return "/" + out.join("/");
12529
+ }
12530
+ /** Path split into its segments. `/` yields `[]`. */
12531
+ function segments(path) {
12532
+ return normalizePath(path).split("/").filter(Boolean);
12533
+ }
12534
+ /**
12535
+ * Resolve `path` against a working directory, POSIX-style.
12536
+ *
12537
+ * An absolute path ignores the cwd; a relative one hangs off it; `..` climbs
12538
+ * and stops at the root rather than escaping it. This is the single place
12539
+ * relativity is interpreted, so every VFS method behaves the same way — a
12540
+ * filesystem where `cat a.txt` and `list("a")` disagreed about what "here"
12541
+ * means would be worse than having no cwd at all.
12542
+ */
12543
+ function resolveFrom(cwd, path) {
12544
+ if (path.startsWith("/"))
12545
+ return normalizePath(path);
12546
+ return normalizePath(`${cwd}/${path}`);
12547
+ }
12548
+ function dirname(path) {
12549
+ const p = normalizePath(path);
12550
+ const i = p.lastIndexOf("/");
12551
+ return i <= 0 ? "/" : p.slice(0, i);
12552
+ }
12553
+ function basename(path) {
12554
+ const p = normalizePath(path);
12555
+ return p.slice(p.lastIndexOf("/") + 1);
12556
+ }
12557
+ function join(...parts) {
12558
+ return normalizePath(parts.join("/"));
12559
+ }
12560
+ /**
12561
+ * Is `path` inside `dir` (at any depth)? A directory is never "under" itself,
12562
+ * which is what makes it usable as the guard against moving a directory into
12563
+ * its own subtree.
12564
+ */
12565
+ function isUnder(path, dir) {
12566
+ const p = normalizePath(path);
12567
+ const d = normalizePath(dir);
12568
+ if (p === d)
12569
+ return false;
12570
+ return d === "/" ? true : p.startsWith(d + "/");
12571
+ }
12572
+ /**
12573
+ * Reject names that would make a path ambiguous or unrepresentable.
12574
+ *
12575
+ * `/` would forge a nesting level, and `.` / `..` would resolve to a different
12576
+ * directory on the next lookup — a file literally named `..` could never be
12577
+ * addressed again.
12578
+ */
12579
+ /** The longest a single entry name may be. Matches the common filesystem limit. */
12580
+ const MAX_NAME_BYTES = 255;
12581
+ /**
12582
+ * Control characters, C1 controls and bidi overrides.
12583
+ *
12584
+ * Built once, not per call: `isSafeName` runs for every entry of every
12585
+ * record read.
12586
+ */
12587
+ const CONTROL_OR_BIDI = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
12588
+ const CONTROL_OR_BIDI_ALL = new RegExp(CONTROL_OR_BIDI.source, "g");
12589
+ const utf8 = new TextEncoder();
12590
+ /**
12591
+ * A string from a record, made fit to print: cut to `max` characters, then
12592
+ * every control or bidi character written as a `\uXXXX` escape.
12593
+ *
12594
+ * For values that end up in an error message or a warning - a record id, a
12595
+ * format version. The CLI prints messages as they are, so a raw escape
12596
+ * sequence there would run in the user's terminal: clear lines, rewrite
12597
+ * what was printed above it, or set the clipboard. Names do not need this;
12598
+ * `isSafeName` refuses them outright.
12599
+ */
12600
+ function printable(value, max = Infinity) {
12601
+ const cut = value.length > max ? `${value.slice(0, max)}...` : value;
12602
+ return cut.replace(CONTROL_OR_BIDI_ALL, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
12603
+ }
12604
+ /**
12605
+ * Is this a name we are willing to store, and to hand back as a path segment?
12606
+ *
12607
+ * This is the READ-side check as much as the write-side one, and the read side
12608
+ * is the one that matters: a directory record is sealed with a key that any
12609
+ * holder of the master seed can derive, so its contents are not necessarily
12610
+ * something this SDK wrote. A name arriving from a record is input.
12611
+ *
12612
+ * What each rule is for:
12613
+ * - `/` and backslash would become path structure. Backslash is a separator on
12614
+ * Windows, which makes it traversal there and an odd filename elsewhere.
12615
+ * - `.` and `..` navigate rather than name.
12616
+ * - NUL terminates a path in every syscall that takes one.
12617
+ * - Other control characters and bidi overrides let a name print as something
12618
+ * other than what it is - the classic way a file passes review.
12619
+ * - A length cap, because nothing else imposes one.
12620
+ */
12621
+ function isSafeName(name) {
12622
+ if (!name || name === "." || name === "..")
12623
+ return false;
12624
+ if (name.includes("/") || name.includes(String.fromCharCode(92)))
12625
+ return false;
12626
+ if (CONTROL_OR_BIDI.test(name))
12627
+ return false;
12628
+ return utf8.encode(name).length <= MAX_NAME_BYTES;
12629
+ }
12630
+ function assertValidName(name) {
12631
+ if (!name)
12632
+ throw new Error("VFS: name cannot be empty");
12633
+ if (!isSafeName(name))
12634
+ throw new Error(`VFS: ${JSON.stringify(name)} is not a usable name`);
12635
+ }
12636
+ /**
12637
+ * MIME type for a filename.
12638
+ *
12639
+ * Stored with the content rather than guessed at read time, because whatever
12640
+ * eventually serves these bytes — a preview, a static host — sends this as the
12641
+ * `Content-Type`, and a browser will refuse to run a module or apply a
12642
+ * stylesheet labelled `application/octet-stream`.
12643
+ */
12644
+ function contentTypeFor(name) {
12645
+ const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
12646
+ switch (ext) {
12647
+ case "html":
12648
+ case "htm": return "text/html; charset=utf-8";
12649
+ case "css": return "text/css; charset=utf-8";
12650
+ case "js":
12651
+ case "mjs":
12652
+ case "jsx": return "text/javascript; charset=utf-8";
12653
+ case "ts":
12654
+ case "tsx": return "text/typescript; charset=utf-8";
12655
+ case "json": return "application/json; charset=utf-8";
12656
+ case "md": return "text/markdown; charset=utf-8";
12657
+ case "txt": return "text/plain; charset=utf-8";
12658
+ case "svg": return "image/svg+xml";
12659
+ case "png": return "image/png";
12660
+ case "jpg":
12661
+ case "jpeg": return "image/jpeg";
12662
+ case "gif": return "image/gif";
12663
+ case "webp": return "image/webp";
12664
+ case "avif": return "image/avif";
12665
+ case "ico": return "image/x-icon";
12666
+ case "woff2": return "font/woff2";
12667
+ case "woff": return "font/woff";
12668
+ case "wasm": return "application/wasm";
12669
+ case "map": return "application/json";
12670
+ default: return "application/octet-stream";
12671
+ }
12672
+ }
12673
+
12509
12674
  /**
12510
12675
  * Wire types for the VFS metadata layer.
12511
12676
  *
@@ -12576,6 +12741,92 @@ class VfsConflictError extends Error {
12576
12741
  this.name = "VfsConflictError";
12577
12742
  }
12578
12743
  }
12744
+ /**
12745
+ * Raised when a directory record cannot be read: it does not decrypt or parse
12746
+ * with the key its parent holds, or it does but is not a shape this SDK can
12747
+ * read - not an object, a version other than 1, `entries` missing or not a map.
12748
+ *
12749
+ * Thrown, never read as an empty directory. The next write into an "empty"
12750
+ * directory would seal an empty record over the real one, and a record this
12751
+ * version cannot read may simply be one a NEWER version wrote. Nor is the
12752
+ * directory skipped: a sync that cannot see a subtree reads its absence as a
12753
+ * deletion. The record is left exactly as stored; a recursive delete
12754
+ * (`delete(path, { recursive: true })`) detaches it when the user wants it
12755
+ * gone.
12756
+ */
12757
+ class VfsCorruptRecordError extends Error {
12758
+ /** The record's id - `dirKey(id)` is where it is stored. */
12759
+ id;
12760
+ /** The path it was reached through, when known. A moved subtree can have two. */
12761
+ path;
12762
+ constructor(id, path, reason) {
12763
+ // The id comes out of a parent entry, which anything holding the seed
12764
+ // can write. It is kept raw on `.id` for code, and escaped here for the
12765
+ // terminal a CLI prints this to. `reason` is built by this SDK.
12766
+ const where = `${printable(path ?? "a directory")} (record ${printable(id, 40)})`;
12767
+ // The root has no parent to be detached from; advising a delete that
12768
+ // is refused would leave the user with nothing that works.
12769
+ const remedy = id === ROOT_ID
12770
+ ? "It is the root of this filesystem, so it cannot be deleted; nothing has been changed."
12771
+ : "It has been left as stored; remove it with a recursive delete.";
12772
+ super(`VFS: ${where} cannot be read: ${reason}. ${remedy}`);
12773
+ this.name = "VfsCorruptRecordError";
12774
+ this.id = id;
12775
+ this.path = path;
12776
+ }
12777
+ }
12778
+ /**
12779
+ * Raised when a walk or a copy would go past a size or depth ceiling, or a
12780
+ * write would put a directory deeper than a walk reads.
12781
+ *
12782
+ * Thrown rather than returning what was gathered so far, because every caller
12783
+ * of a walk treats a path that is missing from the result as a path that does
12784
+ * not exist - and a sync then plans a deletion for it. A tree this large or
12785
+ * deep is almost always one built to be expensive to read: directory records
12786
+ * can point at the same directory many times over, so a handful of tiny
12787
+ * records can describe more paths than there are atoms.
12788
+ */
12789
+ class VfsLimitError extends Error {
12790
+ /**
12791
+ * Which ceiling was hit: total entries, total characters of path text,
12792
+ * nesting depth, or - for a copy - how many places one directory may
12793
+ * appear. Entries alone do not bound memory - a name may be 255 bytes, so
12794
+ * one path can be ~16KB - which is why path text has its own.
12795
+ */
12796
+ limit;
12797
+ /** The ceiling itself. */
12798
+ max;
12799
+ /** Where the operation started, or for a write what it was asked to write. */
12800
+ path;
12801
+ /**
12802
+ * Where the ceiling was reached: the directory being listed, or for depth
12803
+ * the entry one level too deep. Walking the tree to find the culprit would
12804
+ * hit the same ceiling, so this is the only pointer the caller gets.
12805
+ */
12806
+ at;
12807
+ constructor(limit, max, path, at, doing = "walking") {
12808
+ const what = {
12809
+ entries: `it holds more than ${max} entries`,
12810
+ pathChars: `its paths add up to more than ${max} characters`,
12811
+ depth: `it is nested more than ${max} directories deep`,
12812
+ shared: `one directory appears in it more than ${max} times`,
12813
+ }[limit];
12814
+ // The location is made of names that passed isSafeName, so it is safe
12815
+ // to print; it is shortened only because a deep one can run to 16KB.
12816
+ // Worded for whoever reads it - a CLI prints it as it is - rather than
12817
+ // in terms of the SDK call.
12818
+ const shown = at.length > 200 ? `${at.slice(0, 200)}...` : at;
12819
+ const remedy = doing === "writing"
12820
+ ? ""
12821
+ : " If you do not recognise what is there, remove it with a recursive delete.";
12822
+ super(`VFS: stopped ${doing} ${path}: ${what} (reached at ${shown}).${remedy}`);
12823
+ this.name = "VfsLimitError";
12824
+ this.limit = limit;
12825
+ this.max = max;
12826
+ this.path = path;
12827
+ this.at = at;
12828
+ }
12829
+ }
12579
12830
 
12580
12831
  /**
12581
12832
  * Sealing for VFS metadata records.
@@ -12587,6 +12838,8 @@ class VfsConflictError extends Error {
12587
12838
  * record would hand over the keys to its files.
12588
12839
  */
12589
12840
  const AES_GCM_IV_BYTES = 12;
12841
+ /** The GCM tag appended to every ciphertext, so the shortest one there can be. */
12842
+ const AES_GCM_TAG_BYTES = 16;
12590
12843
  const AES_KEY_BYTES = 32;
12591
12844
  function toBase64(bytes) {
12592
12845
  let s = "";
@@ -12651,157 +12904,70 @@ async function unseal(key, record) {
12651
12904
  const plaintext = await decryptAesGcm(key, fromBase64(r.iv), fromBase64(r.ct));
12652
12905
  return JSON.parse(new TextDecoder().decode(plaintext));
12653
12906
  }
12654
-
12655
- /**
12656
- * Absolute POSIX paths for the VFS.
12657
- *
12658
- * A path is a lookup route, not a stored value: nothing in the metadata records
12659
- * a full path, only a name inside its parent directory. That is what makes
12660
- * renaming or moving a directory a single-record edit instead of a rewrite of
12661
- * every descendant.
12662
- */
12663
- /** Absolute, single leading slash, no `.` / `..`, no trailing slash. Root is `/`. */
12664
- function normalizePath(input) {
12665
- const out = [];
12666
- for (const part of input.split("/")) {
12667
- if (part === "" || part === ".")
12668
- continue;
12669
- if (part === "..")
12670
- out.pop();
12671
- else
12672
- out.push(part);
12907
+ /** Raised by `openSealed` when the stored value itself is at fault. `message` says how. */
12908
+ class SealedRecordError extends Error {
12909
+ constructor(reason) {
12910
+ super(reason);
12911
+ this.name = "SealedRecordError";
12673
12912
  }
12674
- return "/" + out.join("/");
12675
- }
12676
- /** Path split into its segments. `/` yields `[]`. */
12677
- function segments(path) {
12678
- return normalizePath(path).split("/").filter(Boolean);
12679
12913
  }
12680
12914
  /**
12681
- * Resolve `path` against a working directory, POSIX-style.
12915
+ * `unseal` for a record that must not be guessed at.
12682
12916
  *
12683
- * An absolute path ignores the cwd; a relative one hangs off it; `..` climbs
12684
- * and stops at the root rather than escaping it. This is the single place
12685
- * relativity is interpreted, so every VFS method behaves the same way — a
12686
- * filesystem where `cat a.txt` and `list("a")` disagreed about what "here"
12687
- * means would be worse than having no cwd at all.
12688
- */
12689
- function resolveFrom(cwd, path) {
12690
- if (path.startsWith("/"))
12691
- return normalizePath(path);
12692
- return normalizePath(`${cwd}/${path}`);
12693
- }
12694
- function dirname(path) {
12695
- const p = normalizePath(path);
12696
- const i = p.lastIndexOf("/");
12697
- return i <= 0 ? "/" : p.slice(0, i);
12698
- }
12699
- function basename(path) {
12700
- const p = normalizePath(path);
12701
- return p.slice(p.lastIndexOf("/") + 1);
12702
- }
12703
- function join(...parts) {
12704
- return normalizePath(parts.join("/"));
12705
- }
12706
- /**
12707
- * Is `path` inside `dir` (at any depth)? A directory is never "under" itself,
12708
- * which is what makes it usable as the guard against moving a directory into
12709
- * its own subtree.
12917
+ * Throws SealedRecordError for everything that is wrong WITH THE STORED VALUE:
12918
+ * not an envelope, not base64, the wrong size, a ciphertext that does not
12919
+ * authenticate under `key`, plaintext that is not JSON. Every one of those
12920
+ * surfaced before as a bare DOMException or SyntaxError naming nothing.
12921
+ *
12922
+ * Anything else - no WebCrypto in this runtime, say - is rethrown untouched.
12923
+ * Blaming the record for a failure of the runtime would tell the user a
12924
+ * healthy directory is corrupt, and invite them to delete it.
12710
12925
  */
12711
- function isUnder(path, dir) {
12712
- const p = normalizePath(path);
12713
- const d = normalizePath(dir);
12714
- if (p === d)
12715
- return false;
12716
- return d === "/" ? true : p.startsWith(d + "/");
12926
+ async function openSealed(key, record) {
12927
+ const r = record;
12928
+ if (!r || typeof r !== "object" || Array.isArray(r) || typeof r.iv !== "string" || typeof r.ct !== "string") {
12929
+ throw new SealedRecordError("it is not a sealed record");
12930
+ }
12931
+ let iv;
12932
+ let ct;
12933
+ try {
12934
+ iv = fromBase64(r.iv);
12935
+ ct = fromBase64(r.ct);
12936
+ }
12937
+ catch {
12938
+ throw new SealedRecordError("its envelope is not base64");
12939
+ }
12940
+ // Checked here rather than left to the cipher, whose complaints about
12941
+ // either are indistinguishable from its complaints about the runtime.
12942
+ if (iv.length !== AES_GCM_IV_BYTES || ct.length < AES_GCM_TAG_BYTES) {
12943
+ throw new SealedRecordError("its envelope is the wrong size");
12944
+ }
12945
+ let plaintext;
12946
+ try {
12947
+ plaintext = await decryptAesGcm(key, iv, ct);
12948
+ }
12949
+ catch (err) {
12950
+ // What the WebCrypto spec requires for a tag that does not verify:
12951
+ // a different key, or bytes that were altered.
12952
+ if (err?.name !== "OperationError")
12953
+ throw err;
12954
+ throw new SealedRecordError("it does not decrypt with the key it should");
12955
+ }
12956
+ try {
12957
+ return JSON.parse(new TextDecoder().decode(plaintext));
12958
+ }
12959
+ catch {
12960
+ throw new SealedRecordError("it decrypts, but not to a record");
12961
+ }
12717
12962
  }
12963
+
12718
12964
  /**
12719
- * Reject names that would make a path ambiguous or unrepresentable.
12965
+ * Glob matching for VFS paths.
12720
12966
  *
12721
- * `/` would forge a nesting level, and `.` / `..` would resolve to a different
12722
- * directory on the next lookup — a file literally named `..` could never be
12723
- * addressed again.
12724
- */
12725
- /** The longest a single entry name may be. Matches the common filesystem limit. */
12726
- const MAX_NAME_BYTES = 255;
12727
- /**
12728
- * Is this a name we are willing to store, and to hand back as a path segment?
12729
- *
12730
- * This is the READ-side check as much as the write-side one, and the read side
12731
- * is the one that matters: a directory record is sealed with a key that any
12732
- * holder of the master seed can derive, so its contents are not necessarily
12733
- * something this SDK wrote. A name arriving from a record is input.
12734
- *
12735
- * What each rule is for:
12736
- * - `/` and backslash would become path structure. Backslash is a separator on
12737
- * Windows, which makes it traversal there and an odd filename elsewhere.
12738
- * - `.` and `..` navigate rather than name.
12739
- * - NUL terminates a path in every syscall that takes one.
12740
- * - Other control characters and bidi overrides let a name print as something
12741
- * other than what it is - the classic way a file passes review.
12742
- * - A length cap, because nothing else imposes one.
12743
- */
12744
- function isSafeName(name) {
12745
- if (!name || name === "." || name === "..")
12746
- return false;
12747
- if (name.includes("/") || name.includes(String.fromCharCode(92)))
12748
- return false;
12749
- const CONTROL_OR_BIDI = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]");
12750
- if (CONTROL_OR_BIDI.test(name))
12751
- return false;
12752
- return new TextEncoder().encode(name).length <= MAX_NAME_BYTES;
12753
- }
12754
- function assertValidName(name) {
12755
- if (!name)
12756
- throw new Error("VFS: name cannot be empty");
12757
- if (!isSafeName(name))
12758
- throw new Error(`VFS: ${JSON.stringify(name)} is not a usable name`);
12759
- }
12760
- /**
12761
- * MIME type for a filename.
12762
- *
12763
- * Stored with the content rather than guessed at read time, because whatever
12764
- * eventually serves these bytes — a preview, a static host — sends this as the
12765
- * `Content-Type`, and a browser will refuse to run a module or apply a
12766
- * stylesheet labelled `application/octet-stream`.
12767
- */
12768
- function contentTypeFor(name) {
12769
- const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
12770
- switch (ext) {
12771
- case "html":
12772
- case "htm": return "text/html; charset=utf-8";
12773
- case "css": return "text/css; charset=utf-8";
12774
- case "js":
12775
- case "mjs":
12776
- case "jsx": return "text/javascript; charset=utf-8";
12777
- case "ts":
12778
- case "tsx": return "text/typescript; charset=utf-8";
12779
- case "json": return "application/json; charset=utf-8";
12780
- case "md": return "text/markdown; charset=utf-8";
12781
- case "txt": return "text/plain; charset=utf-8";
12782
- case "svg": return "image/svg+xml";
12783
- case "png": return "image/png";
12784
- case "jpg":
12785
- case "jpeg": return "image/jpeg";
12786
- case "gif": return "image/gif";
12787
- case "webp": return "image/webp";
12788
- case "avif": return "image/avif";
12789
- case "ico": return "image/x-icon";
12790
- case "woff2": return "font/woff2";
12791
- case "woff": return "font/woff";
12792
- case "wasm": return "application/wasm";
12793
- case "map": return "application/json";
12794
- default: return "application/octet-stream";
12795
- }
12796
- }
12797
-
12798
- /**
12799
- * Glob matching for VFS paths.
12800
- *
12801
- * A deliberately small dialect — `*`, `**`, `?`, and `{a,b}` — because these
12802
- * cover what a project actually asks for (`**\/*.tsx`, `src/*.css`) and every
12803
- * addition beyond them is a new way for a pattern to mean something the caller
12804
- * did not expect.
12967
+ * A deliberately small dialect — `*`, `**`, `?`, and `{a,b}` — because these
12968
+ * cover what a project actually asks for (`**\/*.tsx`, `src/*.css`) and every
12969
+ * addition beyond them is a new way for a pattern to mean something the caller
12970
+ * did not expect.
12805
12971
  */
12806
12972
  /** `*` stops at a separator; `**` crosses them. That distinction is the whole point. */
12807
12973
  function globToRegExp(pattern) {
@@ -12848,6 +13014,118 @@ function escapeLiteral(s) {
12848
13014
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12849
13015
  }
12850
13016
 
13017
+ /**
13018
+ * Bounds shared by the VFS walkers and version control's history walks.
13019
+ *
13020
+ * Internal: `src/api.ts` re-exports everything in `VfsNamespace.ts`, and none
13021
+ * of this is public API.
13022
+ */
13023
+ /**
13024
+ * How deep a tree may be before a walk refuses it, counted in directories
13025
+ * below where the walk starts - and how deep, counted from the root, any
13026
+ * directory may be written.
13027
+ *
13028
+ * Well past anything a real project needs. Refusing means THROWING a
13029
+ * VfsLimitError: this used to stop quietly and return the shallower part,
13030
+ * which a sync reads as "everything deeper was deleted". Writes are held to it
13031
+ * from the root so that no walk, from anywhere, is refused a tree this SDK
13032
+ * wrote; version control holds a checkout to it too.
13033
+ */
13034
+ const MAX_DEPTH = 64;
13035
+ /**
13036
+ * The most entries one walk, copy or read of history may visit, unless the
13037
+ * namespace was constructed with other limits.
13038
+ *
13039
+ * A cycle guard cannot bound a walk on its own: records can name the same
13040
+ * directory twice without any cycle (D0 -> {a, b} -> D1 -> {a, b} -> ...), and
13041
+ * every level doubles the paths - sixty-four levels of that is 2^64 paths from
13042
+ * sixty-four tiny records. Repository trees are the same: every hash can be
13043
+ * genuine and each level still double the paths. A tree past the budget is
13044
+ * thrown on rather than half-read.
13045
+ *
13046
+ * Half a million is a very large real project. It was a million, which fits
13047
+ * one walk in a 512MB heap, but `vcs status` holds a commit's paths and the
13048
+ * working tree's changes at once, both chosen by whoever planted them: two
13049
+ * sides of a million, every path a change, is two million paths of output,
13050
+ * and ended within 9MB of a 512MB heap even compared as it was walked.
13051
+ */
13052
+ const DEFAULT_MAX_ENTRIES = 500_000;
13053
+ /**
13054
+ * The most characters of path text one walk or read of history may produce,
13055
+ * summed over its paths.
13056
+ *
13057
+ * The entry budget alone does not bound memory, because the attacker chooses
13058
+ * how big each entry is: a name may be 255 bytes and a tree 64 levels deep, so
13059
+ * one path can be ~16KB, and every path is its own string. Forty-odd small
13060
+ * records of long names stayed under the entry budget and still asked for
13061
+ * ~11GB. This allows an average of 64 characters a path at the entry
13062
+ * ceiling - 32MB of text, or 64MB in names a JS engine stores two bytes a
13063
+ * character - which a real project reaches only if it is near that ceiling
13064
+ * anyway.
13065
+ */
13066
+ const DEFAULT_MAX_PATH_CHARS = 64 * DEFAULT_MAX_ENTRIES;
13067
+ /** The furthest from 1970, either way, a Date can be, in milliseconds. */
13068
+ const MAX_TIME = 8.64e15;
13069
+ /**
13070
+ * Directory records one walk fetches at once - in TOTAL, not per directory.
13071
+ *
13072
+ * Sized for the network, not for memory. What ran a walk out of memory was
13073
+ * in-flight VISITS over cached records, and the walk now visits one
13074
+ * directory at a time; this bounds FETCHES, each holding at most one record -
13075
+ * 128MB sealed if all 64 are as big as a Durable Object value may be. Every
13076
+ * get is its own request, and a cold walk of D directories costs at least
13077
+ * D / WALK_CONCURRENCY round trips on top of one per level: at 30ms a get, a
13078
+ * 333-directory project took 1.46s with 8, 0.49s with 32 and 0.37s with 64,
13079
+ * against 0.25s for the old walk, which had up to 225 gets in flight on the
13080
+ * same tree. Separate from FileStorage's shard concurrency, which it once
13081
+ * matched: shards are batched, and records are not.
13082
+ */
13083
+ const WALK_CONCURRENCY = 64;
13084
+ /**
13085
+ * How many entries a walk visits between turns of the event loop.
13086
+ *
13087
+ * Once its records are cached a walk never waits on anything, and a chain of
13088
+ * promise continuations runs to the end before any timer, input event or
13089
+ * signal handler is let in. A few hundred thousand entries is seconds of that:
13090
+ * a frozen tab, or a `vfs mount` that ignores Ctrl-C. Each turn costs a
13091
+ * fraction of a millisecond, which at this spacing a real project never
13092
+ * notices. Version control's history walks use it too.
13093
+ */
13094
+ const YIELD_EVERY = 8192;
13095
+ /**
13096
+ * Let the event loop run - timers, I/O, input, signal handlers - before
13097
+ * carrying on.
13098
+ *
13099
+ * `setImmediate` is Node's turn of the loop, and a MessageChannel message is
13100
+ * a browser's: a task queued behind whatever is already waiting, which is
13101
+ * the point. Not `scheduler.yield()`, although Chromium has it: its
13102
+ * continuation is scheduled AHEAD of tasks already queued at the same
13103
+ * priority, and timers, postMessage and socket messages are such tasks - a
13104
+ * cached walk yielding through it gave a 1ms interval no ticks in 2s. And a
13105
+ * bare `setTimeout(0)` only when there is nothing else: a browser clamps a
13106
+ * timer set from inside another to 4ms, and in a hidden tab runs it about
13107
+ * once a second, or once a minute after five minutes hidden.
13108
+ *
13109
+ * In Node a MessagePort with a listener would also keep the process alive,
13110
+ * which is one more reason `setImmediate` comes first.
13111
+ */
13112
+ function nextTurn() {
13113
+ const g = globalThis;
13114
+ if (typeof g.setImmediate === "function")
13115
+ return new Promise((resolve) => g.setImmediate(resolve));
13116
+ if (typeof MessageChannel === "function") {
13117
+ return new Promise((resolve) => {
13118
+ const channel = new MessageChannel();
13119
+ channel.port1.onmessage = () => {
13120
+ channel.port1.close();
13121
+ resolve();
13122
+ };
13123
+ channel.port2.postMessage(null);
13124
+ });
13125
+ }
13126
+ return new Promise((resolve) => setTimeout(resolve, 0));
13127
+ }
13128
+
12851
13129
  /**
12852
13130
  * `client.vfs` — a real filesystem over Muhkoo primitives.
12853
13131
  *
@@ -12867,47 +13145,365 @@ function escapeLiteral(s) {
12867
13145
  * cost storage until a future sweep, which is the trade this shape makes in
12868
13146
  * exchange for single-record writes.
12869
13147
  */
13148
+ /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
13149
+ function assertWritableDepth(path) {
13150
+ const parts = segments(path);
13151
+ if (parts.length <= MAX_DEPTH)
13152
+ return;
13153
+ throw new VfsLimitError("depth", MAX_DEPTH, path, `/${parts.slice(0, MAX_DEPTH + 1).join("/")}`, "writing");
13154
+ }
13155
+ const DEFAULT_HISTORY_LIMIT = 20;
12870
13156
  /**
12871
- * How deep a tree may be before we stop walking it.
13157
+ * The one pool of record fetches a walk has, `limit` wide.
13158
+ *
13159
+ * The walk itself visits one directory at a time, so it never holds more than
13160
+ * its current path. What keeps a real tree from being listed one round trip at
13161
+ * a time is reading ahead: every record the walk opens, and every record read
13162
+ * ahead, is scanned ONCE for subdirectories not yet fetched, and those are
13163
+ * fetched while the walk works. The most recently opened record is scanned
13164
+ * first, which is roughly the order a depth-first walk wants them in.
13165
+ *
13166
+ * What it holds is per distinct RECORD - a record scanned, a read that failed
13167
+ * - never per visit, so a record a fan-out reaches a million times costs it
13168
+ * one entry. The walk's own reads come out of the same pool, and go first:
13169
+ * when every slot is reading ahead, the walk takes the next one to come free.
13170
+ * A successful read lands in the namespace's record cache, which is where the
13171
+ * walk looks when it gets there.
13172
+ */
13173
+ class ReadAhead {
13174
+ limit;
13175
+ list;
13176
+ cached;
13177
+ fetch;
13178
+ inFlight = 0;
13179
+ stopped = false;
13180
+ /** The walk, waiting for a slot. It reads one directory at a time, so there is only ever one. */
13181
+ waiting = null;
13182
+ /** Records whose subdirectories are still to be looked at, the most recently opened last. */
13183
+ toScan = [];
13184
+ /** Every record scanned so far, so none is scanned twice. */
13185
+ scanned = new Set();
13186
+ /**
13187
+ * Reads ahead that failed. The cache forgets a failed read so that one
13188
+ * blip does not poison a directory for the session; kept here, the walk
13189
+ * reports the failure when it gets there instead of asking again.
13190
+ */
13191
+ failed = new Map();
13192
+ constructor(limit, list, cached, fetch) {
13193
+ this.limit = limit;
13194
+ this.list = list;
13195
+ this.cached = cached;
13196
+ this.fetch = fetch;
13197
+ }
13198
+ /** A record the walk has opened. `children` is its listing, when the walk already has one. */
13199
+ scan(dir, path, children) {
13200
+ this.queue(dir, path, children);
13201
+ this.fill();
13202
+ }
13203
+ /** The walk is over, however it ended: start nothing more. */
13204
+ stop() {
13205
+ this.stopped = true;
13206
+ this.toScan.length = 0;
13207
+ }
13208
+ /** The record the walk needs now: read ahead already, or fetched in the next free slot. */
13209
+ async open(entry, path) {
13210
+ const failed = this.failed.get(entry.id);
13211
+ if (failed)
13212
+ return failed;
13213
+ if (this.cached(entry.id))
13214
+ return this.fetch(entry, path);
13215
+ if (this.inFlight < this.limit)
13216
+ return this.start(entry, path);
13217
+ // Every slot is reading ahead. The next one to come free is handed to
13218
+ // the walk rather than to more reading ahead.
13219
+ await new Promise((resolve) => (this.waiting = resolve));
13220
+ if (!this.cached(entry.id))
13221
+ return this.start(entry, path, true);
13222
+ // Read ahead in the meantime after all: give the slot back.
13223
+ this.inFlight--;
13224
+ this.fill();
13225
+ return this.fetch(entry, path);
13226
+ }
13227
+ queue(dir, path, children) {
13228
+ if (this.stopped || this.scanned.has(dir.id))
13229
+ return;
13230
+ this.scanned.add(dir.id);
13231
+ this.toScan.push({ path, children: children ?? this.list(dir), next: 0 });
13232
+ }
13233
+ /** `held`: the slot was handed over, and is already counted. */
13234
+ start(entry, path, held = false) {
13235
+ if (!held)
13236
+ this.inFlight++;
13237
+ const pending = this.fetch(entry, path);
13238
+ pending.then((dir) => {
13239
+ this.queue(dir, path);
13240
+ this.release();
13241
+ }, () => {
13242
+ this.failed.set(entry.id, pending);
13243
+ this.release();
13244
+ });
13245
+ return pending;
13246
+ }
13247
+ release() {
13248
+ const walk = this.waiting;
13249
+ if (walk) {
13250
+ this.waiting = null;
13251
+ walk(); // the slot passes to the walk, still counted
13252
+ return;
13253
+ }
13254
+ this.inFlight--;
13255
+ this.fill();
13256
+ }
13257
+ fill() {
13258
+ while (!this.stopped && this.inFlight < this.limit) {
13259
+ const top = this.toScan[this.toScan.length - 1];
13260
+ if (!top)
13261
+ return;
13262
+ if (top.next >= top.children.length) {
13263
+ this.toScan.pop();
13264
+ continue;
13265
+ }
13266
+ const { name, entry } = top.children[top.next++];
13267
+ if (entry.kind !== "dir" || this.cached(entry.id) || this.failed.has(entry.id))
13268
+ continue;
13269
+ void this.start(entry, join(top.path, name));
13270
+ }
13271
+ }
13272
+ }
13273
+ /** An entry without its move mark. See `DirEntry.moving`. */
13274
+ function unmarked(entry) {
13275
+ const { moving: _moving, ...rest } = entry;
13276
+ return rest;
13277
+ }
13278
+ function hasAncestor(chain, id) {
13279
+ for (let at = chain; at; at = at.up)
13280
+ if (at.id === id)
13281
+ return true;
13282
+ return false;
13283
+ }
13284
+ /**
13285
+ * The length of `join(dir, name)`, without building it.
12872
13286
  *
12873
- * Well past anything a real project needs, and low enough that a malformed or
12874
- * hostile record cannot spend the caller's memory before the cycle guard sees a
12875
- * repeat.
13287
+ * Only for a name that passed isSafeName, which join cannot rewrite. Lets a
13288
+ * walk charge for a directory's paths BEFORE it makes any of them.
12876
13289
  */
12877
- const MAX_DEPTH = 64;
13290
+ function childPathLength(dir, name) {
13291
+ return (dir === "/" ? 1 : dir.length + 1) + name.length;
13292
+ }
13293
+ /**
13294
+ * `join(dir, name)`, for the same names: what join would return, without
13295
+ * splitting and re-joining the whole path for every entry of a walk.
13296
+ */
13297
+ function childPath(dir, name) {
13298
+ return dir === "/" ? `/${name}` : `${dir}/${name}`;
13299
+ }
12878
13300
  /**
12879
- * How many directories `walk` lists at once.
13301
+ * The most places one directory may appear in a tree that is being copied.
13302
+ *
13303
+ * A copy writes a record per directory VISITED, and a record can name the same
13304
+ * directory any number of times: three records - one naming a second 300
13305
+ * times, that one naming a third 300 times - are 90,000 sealed writes, each a
13306
+ * round trip, all inside the entry budget. In a real tree a directory is in
13307
+ * one place, or in two after a move that was interrupted between attaching
13308
+ * and detaching; four allows for one of those inside another.
13309
+ */
13310
+ const MAX_SHARED = 4;
13311
+ /**
13312
+ * An entry map with no prototype.
13313
+ *
13314
+ * Every `DirNode.entries` must be one of these. An ordinary object - which is
13315
+ * what JSON.parse and `{}` both produce - answers `entries["constructor"]` with
13316
+ * the inherited Object constructor, a phantom entry no listing ever shows, and
13317
+ * treats `entries["__proto__"] = entry` as a prototype change rather than a new
13318
+ * key, so JSON.stringify drops it and a write that reported success stored
13319
+ * nothing. Those are real folder and file names; the map has to hold them
13320
+ * rather than the names being refused.
13321
+ */
13322
+ function emptyEntries() {
13323
+ return Object.create(null);
13324
+ }
13325
+ /**
13326
+ * Look a name up among a directory's OWN entries.
12880
13327
  *
12881
- * Enough to hide per-request latency on a wide tree; low enough that a big
12882
- * project does not open a request per directory. Browsers cap concurrency per
12883
- * origin anyway, so a larger number mostly moves the queue.
13328
+ * Belt and braces alongside the null-prototype map: a node built somewhere that
13329
+ * forgot `emptyEntries()` still cannot resolve a name to something inherited.
13330
+ */
13331
+ function entryIn(node, name) {
13332
+ return Object.hasOwn(node.entries, name) ? node.entries[name] : undefined;
13333
+ }
13334
+ /**
13335
+ * Refuse to put anything at `name` in `dir` when an entry this version cannot
13336
+ * read is already there.
13337
+ *
13338
+ * For every operation that refuses an existing destination - rename, copy,
13339
+ * mkdir, including the parents writeFile creates. A hidden entry is not a free
13340
+ * name: it may be a newer SDK's, and replacing it would erase it with no word
13341
+ * to anyone. writeFile and writeManifest DO replace one, because putting their
13342
+ * file at that exact name is what they were asked to do, and a recursive
13343
+ * delete of that exact name removes one.
13344
+ */
13345
+ function assertNotHidden(dir, name, path) {
13346
+ if (Object.hasOwn(dir.hidden, name)) {
13347
+ throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
13348
+ }
13349
+ }
13350
+ /** A JSON object, as opposed to null, an array or a scalar. */
13351
+ function isObject$1(value) {
13352
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13353
+ }
13354
+ /**
13355
+ * Numbers from a record, made into numbers. The record may not be something
13356
+ * this SDK wrote, and these go straight to callers typed as numbers - the CLI
13357
+ * formats `mtime` with `new Date(mtime).toISOString()`, which throws on
13358
+ * "yesterday", on an object and on 1e300. Anything else reads as 0.
12884
13359
  */
12885
- /** Directory records fetched in parallel while walking.
13360
+ const asTime = (value) => typeof value === "number" && Math.abs(value) <= MAX_TIME ? value : 0;
13361
+ const asCount = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
13362
+ /** Open a sealed value with the first of `keys` that opens it, or say why none did. */
13363
+ async function openWithAny(raw, keys) {
13364
+ let problem = "there is no key to open it with";
13365
+ for (const key of keys) {
13366
+ try {
13367
+ return { value: await openSealed(key, raw), key };
13368
+ }
13369
+ catch (err) {
13370
+ if (!(err instanceof SealedRecordError))
13371
+ throw err;
13372
+ problem = err.message;
13373
+ }
13374
+ }
13375
+ return { problem };
13376
+ }
13377
+ /**
13378
+ * How deeply a value in a directory record may nest objects and arrays.
12886
13379
  *
12887
- * Kept at 8 alongside the shard read concurrency. Raising both to 24/16 was
12888
- * neither better nor worse once enough samples were taken — the spread between
12889
- * runs is wider than the difference between the settings — so this stays at the
12890
- * simpler value. See FileStorage's note. */
12891
- const WALK_CONCURRENCY = 8;
13380
+ * Far past anything this SDK writes - an entry, its manifest, its chunks and
13381
+ * their fields are five levels - and far short of where JSON.stringify's
13382
+ * recursion gives out. JSON.parse has no such limit, so a record can hold a
13383
+ * value that parses and can never be written back.
13384
+ */
13385
+ const MAX_NESTING = 32;
13386
+ /** Does `value` nest objects or arrays more than `max` levels deep? Not recursive: that is the point. */
13387
+ function nestedDeeperThan(value, max) {
13388
+ const pending = [[value, 1]];
13389
+ for (let next = pending.pop(); next; next = pending.pop()) {
13390
+ const [at, depth] = next;
13391
+ if (typeof at !== "object" || at === null)
13392
+ continue;
13393
+ if (depth > max)
13394
+ return true;
13395
+ for (const child of Object.values(at))
13396
+ if (typeof child === "object" && child !== null)
13397
+ pending.push([child, depth + 1]);
13398
+ }
13399
+ return false;
13400
+ }
12892
13401
  /**
12893
- * `Promise.all` with a ceiling, preserving input order.
13402
+ * A decrypted history record as this version reads it, and how many versions
13403
+ * it left out.
13404
+ *
13405
+ * Checked like a directory record, and for the same reasons: anything holding
13406
+ * the seed can write one. A version is kept only with a manifest that has an
13407
+ * id - `history` lists versions and `restore` takes one by its place in that
13408
+ * list, so the two must leave out the same ones - and nothing nested past
13409
+ * MAX_NESTING is kept, because the record is written back on every save and
13410
+ * JSON.stringify cannot write it. Anything else is an empty history, as it
13411
+ * always was.
13412
+ */
13413
+ function readableHistory(value) {
13414
+ if (!isObject$1(value) || !Array.isArray(value.versions))
13415
+ return { record: { v: 1, versions: [] }, dropped: 0 };
13416
+ // No prototype, as for a directory record's fields.
13417
+ const record = Object.create(null);
13418
+ for (const [field, fieldValue] of Object.entries(value)) {
13419
+ if (field !== "versions" && !nestedDeeperThan(fieldValue, MAX_NESTING))
13420
+ record[field] = fieldValue;
13421
+ }
13422
+ const versions = value.versions.filter((version) => isObject$1(version) && isObject$1(version.manifest) && typeof version.manifest.id === "string" &&
13423
+ !nestedDeeperThan(version, MAX_NESTING));
13424
+ record.versions = versions;
13425
+ return { record: record, dropped: value.versions.length - versions.length };
13426
+ }
13427
+ /**
13428
+ * Why a decrypted directory record cannot be read, or null when it can.
12894
13429
  *
12895
- * Results are written back BY INDEX rather than pushed as they settle, so the
12896
- * output order matches the input regardless of which finishes first — which is
12897
- * what lets `walk` parallelise without changing what it returns.
13430
+ * `unseal` returns whatever JSON.parse produced, and the record may have been
13431
+ * written by anything holding the seed, or by a newer version of this SDK. An
13432
+ * unknown `v` is refused outright rather than read on a best-effort basis:
13433
+ * reading it as v1 and writing it back would silently downgrade it.
12898
13434
  */
12899
- async function mapLimit(items, limit, fn) {
12900
- if (items.length <= 1)
12901
- return items.length ? [await fn(items[0])] : [];
12902
- const out = new Array(items.length);
12903
- let next = 0;
12904
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
12905
- for (let i = next++; i < items.length; i = next++)
12906
- out[i] = await fn(items[i]);
12907
- }));
13435
+ function recordProblem(value) {
13436
+ if (!isObject$1(value))
13437
+ return "it is not an object";
13438
+ if (value.v !== 1) {
13439
+ const v = typeof value.v === "number" || typeof value.v === "string"
13440
+ ? printable(String(value.v), 32)
13441
+ : typeof value.v;
13442
+ return `it has format version ${v}, and this version reads only 1`;
13443
+ }
13444
+ if (!isObject$1(value.entries))
13445
+ return "its entries are missing or not a map";
13446
+ return null;
13447
+ }
13448
+ /**
13449
+ * What an entry id looks like: `newId()` mints 22 characters of unpadded
13450
+ * base64 with `+` and `/` dropped, and the root's id is "root". A little
13451
+ * looser than that, never looser than a store key can carry.
13452
+ *
13453
+ * The id is not just a label. `dirKey(id)` and `historyKey(id)` are store
13454
+ * keys, and Client's store puts the key in a URL: a lone surrogate made
13455
+ * encodeURIComponent throw, and an id past the Durable Object's 2048-byte key
13456
+ * limit got a 500 - from every walk above the entry, and from every attempt
13457
+ * to delete it, for good.
13458
+ */
13459
+ const ENTRY_ID = /^[A-Za-z0-9_-]{1,64}$/;
13460
+ /**
13461
+ * Is this an entry this version knows how to follow?
13462
+ *
13463
+ * Every entry needs an id this SDK could have minted - see ENTRY_ID. A file
13464
+ * needs a manifest with an id of its own - version control tells versions
13465
+ * apart by it, and skips an entry without one, so the working tree must skip
13466
+ * it too or the two disagree about what is there. A directory needs a key
13467
+ * that is really an AES-256 key, because every walk decodes it and a bad one
13468
+ * would throw from somewhere far less helpful. Anything else - an unknown
13469
+ * kind, a missing field, a bare scalar - is not an error in the RECORD, only
13470
+ * in that one entry, and hiding one entry is the same outcome as someone
13471
+ * deleting it.
13472
+ */
13473
+ function readableEntry(value) {
13474
+ if (!isObject$1(value) || typeof value.id !== "string" || !ENTRY_ID.test(value.id))
13475
+ return false;
13476
+ if (value.kind === "file")
13477
+ return isObject$1(value.manifest) && typeof value.manifest.id === "string";
13478
+ if (value.kind !== "dir" || typeof value.key !== "string")
13479
+ return false;
13480
+ try {
13481
+ return fromBase64(value.key).length === 32;
13482
+ }
13483
+ catch {
13484
+ return false; // not base64 at all
13485
+ }
13486
+ }
13487
+ /**
13488
+ * Every entry a directory references that this version can FOLLOW - the
13489
+ * visible ones, plus any set aside only because its NAME is unusable.
13490
+ *
13491
+ * For sweep and purge, which must account for everything a record points at
13492
+ * rather than show it: an entry hidden for its name is still a directory whose
13493
+ * records are reachable, or a file whose content the directory owns.
13494
+ */
13495
+ function followable(dir) {
13496
+ const out = Object.entries(dir.node.entries);
13497
+ // A null name: the entry is followed, but no path is ever built from it.
13498
+ for (const value of Object.values(dir.hidden))
13499
+ if (readableEntry(value))
13500
+ out.push([null, value]);
12908
13501
  return out;
12909
13502
  }
12910
- const DEFAULT_HISTORY_LIMIT = 20;
13503
+ /** How many of a directory's hidden entries this version cannot follow at all. */
13504
+ function unfollowable(dir) {
13505
+ return Object.values(dir.hidden).filter((value) => !readableEntry(value)).length;
13506
+ }
12911
13507
  class VfsNamespace {
12912
13508
  deps;
12913
13509
  rootKey = null;
@@ -12920,9 +13516,13 @@ class VfsNamespace {
12920
13516
  */
12921
13517
  cache = new Map();
12922
13518
  historyLimit;
13519
+ maxEntries;
13520
+ maxPathChars;
12923
13521
  constructor(deps) {
12924
13522
  this.deps = deps;
12925
13523
  this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
13524
+ this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
13525
+ this.maxPathChars = deps.limits?.pathChars ?? DEFAULT_MAX_PATH_CHARS;
12926
13526
  }
12927
13527
  /**
12928
13528
  * The working directory relative paths resolve against.
@@ -13022,9 +13622,24 @@ class VfsNamespace {
13022
13622
  }
13023
13623
  // ---- reads --------------------------------------------------------------
13024
13624
  async exists(path) {
13625
+ // The root has no entry of its own, so `find` cannot see it — and
13626
+ // answering false here told `vfs mount` at "/" that the whole remote
13627
+ // tree was missing on every pass. It always exists once the filesystem
13628
+ // can be opened; opening it is also what makes a locked VFS throw
13629
+ // rather than reassure.
13630
+ if (this.resolve(path) === "/") {
13631
+ await this.root();
13632
+ return true;
13633
+ }
13025
13634
  return (await this.find(path)) !== null;
13026
13635
  }
13027
13636
  async stat(path) {
13637
+ // The root, as `exists` sees it: a directory, there whenever the
13638
+ // filesystem can be opened. The two used to disagree about it.
13639
+ if (this.resolve(path) === "/") {
13640
+ const root = await this.root();
13641
+ return { path: "/", name: "", kind: "dir", size: 0, mtime: asTime(root.node.mtime), versions: 0 };
13642
+ }
13028
13643
  const found = await this.find(path);
13029
13644
  if (!found)
13030
13645
  throw new VfsNotFoundError(this.resolve(path));
@@ -13034,22 +13649,20 @@ class VfsNamespace {
13034
13649
  async list(path = ".") {
13035
13650
  const dir = await this.openDir(path);
13036
13651
  const base = this.resolve(path);
13652
+ return this.children(dir).map(({ name, entry }) => toStat(join(base, name), name, entry));
13653
+ }
13654
+ /**
13655
+ * A directory's entries in listing order: directories first, then by name.
13656
+ * Shared by `list` and `walk`, so a walk's output order is exactly the order
13657
+ * of the listings it is made of.
13658
+ */
13659
+ children(dir) {
13660
+ // No name check here: `readRecord` has already set every unusable name
13661
+ // aside, once per record. Checking here instead ran a regex, an encode
13662
+ // and a warning per entry on EVERY visit - and a walk may visit one
13663
+ // record a great many times - while charging none of it to the budget.
13037
13664
  return Object.entries(dir.node.entries)
13038
- // Validate the name HERE, where it is read.
13039
- //
13040
- // A directory record is sealed under a key derivable from the master
13041
- // seed, so its contents are not necessarily something this SDK
13042
- // wrote, and the write-side check never sees them. An unusable name
13043
- // is dropped rather than thrown on: one bad entry must not make the
13044
- // whole directory unlistable, and callers walk this output straight
13045
- // into filesystem paths.
13046
- .filter(([name]) => {
13047
- if (isSafeName(name))
13048
- return true;
13049
- this.warn(`ignoring entry with an unusable name in ${base}`);
13050
- return false;
13051
- })
13052
- .map(([name, entry]) => toStat(join(base, name), name, entry))
13665
+ .map(([name, entry]) => ({ name, kind: entry.kind, entry }))
13053
13666
  .sort(byDirsThenName);
13054
13667
  }
13055
13668
  warn(message) {
@@ -13063,42 +13676,153 @@ class VfsNamespace {
13063
13676
  *
13064
13677
  * Deliberately separate from `list`: this walks the whole subtree, which is
13065
13678
  * one fetch per directory. Fine for a project, wrong as a default.
13679
+ *
13680
+ * Throws VfsLimitError past the entry budget or the depth ceiling, and
13681
+ * VfsCorruptRecordError for a directory it cannot read, rather than
13682
+ * returning part of the tree. Skips (with a warning) a directory that is
13683
+ * one of its own ancestors.
13066
13684
  */
13067
13685
  async walk(path = ".") {
13686
+ return (await this.walkEntries(path)).filter((e) => e.kind === "file").map((e) => e.path);
13687
+ }
13688
+ /**
13689
+ * Every entry under a prefix - directories as well as files - depth-first,
13690
+ * each directory just before its contents. `walk` is this with the
13691
+ * directories filtered out, so the two can never disagree about what is in
13692
+ * a tree; version control uses this one because a commit records empty
13693
+ * directories too.
13694
+ */
13695
+ async walkEntries(path = ".") {
13696
+ const out = [];
13697
+ await this.eachEntry(path, (stat) => void out.push(stat));
13698
+ return out;
13699
+ }
13700
+ /**
13701
+ * `walkEntries` without the list: `visit` is called with each entry in
13702
+ * the same order, and for a file with its manifest - the handle
13703
+ * `statManifest` gives, without resolving the path all over again, which
13704
+ * on a deep tree cost far more than the walk itself. Null for a directory.
13705
+ *
13706
+ * For a caller that does not need every entry at once. Version control
13707
+ * compares the working tree with a commit as it goes, where holding the
13708
+ * whole walk beside the whole commit, and a map of one beside a map of
13709
+ * the other, ran a 512MB heap out of memory inside the default budgets.
13710
+ * The same guards and budgets as `walk`, which is built on this.
13711
+ */
13712
+ async eachEntry(path, visit) {
13068
13713
  /**
13069
- * Sibling directories are listed CONCURRENTLY.
13714
+ * One directory at a time, depth-first, with the records it is about to
13715
+ * need fetched ahead of it.
13070
13716
  *
13071
- * Each `list` is a round trip for one directory record, and walking them
13072
- * one after another cost the sum of those latencies: measured against a
13073
- * real project, the walk alone was 1–2.1s before a single file had been
13074
- * read. Directories are independent, so there was never a reason to
13075
- * queue them.
13717
+ * Everything a walk holds is its current path - and `walkEntries`
13718
+ * its output, which is why a caller comparing as it goes uses this
13719
+ * instead. It used to list sibling directories concurrently, each one fanning out into
13720
+ * its own children the same way, eight at a time PER DIRECTORY - so
13721
+ * eight per level, multiplying down the tree. Records are cached by id,
13722
+ * so on a planted fan-out nothing ever waited, and every visit up to
13723
+ * the entry budget was in flight at once, each with its own copy of
13724
+ * the ancestor set: 29 records inside both default budgets ran a 3GB
13725
+ * heap out of memory. The budgets count OUTPUT; only the shape of the
13726
+ * traversal bounds what is held while producing it.
13076
13727
  *
13077
- * Order is preserved exactly. Each entry maps to its own slice of the
13078
- * result and the slices are concatenated in listing order, so the output
13079
- * is identical to the depth-first version — callers and tests that
13080
- * depend on it are unaffected, and only the timing changes.
13728
+ * Round trips still overlap. Walking directories one after another
13729
+ * cost the sum of their latencies - 1-2.1s against a real project
13730
+ * before a single file had been read - so ReadAhead keeps up to
13731
+ * WALK_CONCURRENCY fetches going for the directories the walk will
13732
+ * reach next, and the walk's own reads come out of the same pool.
13733
+ *
13734
+ * Directories are opened by the id and key their parent holds, not by
13735
+ * re-resolving each path from the root, which is what lets the cycle
13736
+ * guard below work on IDENTITY. It used to be a set of paths, and a
13737
+ * record whose two entries both point back at itself produces a fresh
13738
+ * path at every level - /loop/a, /loop/b, /loop/a/a - so it never fired
13739
+ * and 2^64 visits ran the process out of memory.
13081
13740
  */
13082
- const seen = new Set();
13083
- const visit = async (dirPath, depth) => {
13084
- if (depth > MAX_DEPTH) {
13085
- this.warn(`stopping at ${dirPath}: deeper than ${MAX_DEPTH} levels`);
13086
- return [];
13087
- }
13088
- // Claimed BEFORE the first await, so two concurrent visits to the
13089
- // same directory cannot both get past this. A record whose entry
13090
- // points back at an ancestor would otherwise recurse forever, and
13091
- // nothing in the format prevents one.
13092
- if (seen.has(dirPath)) {
13093
- this.warn(`stopping at ${dirPath}: already visited (the tree contains a cycle)`);
13094
- return [];
13095
- }
13096
- seen.add(dirPath);
13097
- const entries = await this.list(dirPath);
13098
- const slices = await mapLimit(entries, WALK_CONCURRENCY, async (entry) => entry.kind === "dir" ? visit(entry.path, depth + 1) : [entry.path]);
13099
- return slices.flat();
13741
+ const start = this.resolve(path);
13742
+ const opened = await this.openChain(start);
13743
+ if (!opened)
13744
+ throw new VfsNotFoundError(start);
13745
+ // Bounded here, not by the cycle guard, which cannot see a tree that
13746
+ // fans out without ever looping - in entries, and in the characters of
13747
+ // the paths those entries become, since that is what the result holds.
13748
+ let budget = this.maxEntries;
13749
+ let chars = this.maxPathChars;
13750
+ // Directories already reported as cycles. A fan-out can reach one
13751
+ // record hundreds of thousands of times, and a warning per visit is a
13752
+ // line per path in the user's terminal.
13753
+ const reported = new Set();
13754
+ // The id of every directory from the root down to the one being
13755
+ // listed. It starts with the chain above `start`, so a pointer back
13756
+ // above where the walk began is a cycle too, and a subtree's walk
13757
+ // agrees with the same subtree in a walk of the root. Added to and
13758
+ // taken from as the walk goes down and back up; never copied.
13759
+ const onPath = new Set(opened.chain);
13760
+ const stack = [];
13761
+ const ahead = new ReadAhead(WALK_CONCURRENCY, (dir) => this.children(dir), (id) => this.cache.has(id), (entry, at) => this.openDirById(entry.id, fromBase64(entry.key), at));
13762
+ /** Charge for a directory's listing, and make it the walk's current one. True when it is time to yield. */
13763
+ const enter = (dir, dirPath) => {
13764
+ const children = this.children(dir);
13765
+ const turn = Math.floor(budget / YIELD_EVERY);
13766
+ budget -= children.length;
13767
+ if (budget < 0)
13768
+ throw new VfsLimitError("entries", this.maxEntries, start, dirPath);
13769
+ // Charged before any of these paths is built.
13770
+ for (const { name } of children)
13771
+ chars -= childPathLength(dirPath, name);
13772
+ if (chars < 0)
13773
+ throw new VfsLimitError("pathChars", this.maxPathChars, start, dirPath);
13774
+ stack.push({ id: dir.id, path: dirPath, children, next: 0 });
13775
+ ahead.scan(dir, dirPath, children);
13776
+ return Math.floor(budget / YIELD_EVERY) !== turn;
13100
13777
  };
13101
- return visit(this.resolve(path), 0);
13778
+ try {
13779
+ // One continuation, so a yield parks the whole walk and not one
13780
+ // branch of it while the rest carry on.
13781
+ if (enter(opened.dir, start))
13782
+ await nextTurn();
13783
+ while (stack.length) {
13784
+ const frame = stack[stack.length - 1];
13785
+ if (frame.next === frame.children.length) {
13786
+ stack.pop();
13787
+ // The bottom frame is `start`, which is part of the chain.
13788
+ if (stack.length)
13789
+ onPath.delete(frame.id);
13790
+ continue;
13791
+ }
13792
+ const { name, entry } = frame.children[frame.next++];
13793
+ const at = childPath(frame.path, name);
13794
+ if (entry.kind !== "dir") {
13795
+ visit(toStat(at, name, entry), entry.manifest);
13796
+ continue;
13797
+ }
13798
+ // A directory that is one of ITS OWN ancestors is a true cycle:
13799
+ // everything in it is already on this branch. Only the path
13800
+ // above this entry counts - not every directory seen so far -
13801
+ // because rename() deliberately leaves a moved subtree reachable
13802
+ // from two parents, and both paths must keep appearing.
13803
+ if (onPath.has(entry.id)) {
13804
+ if (!reported.has(entry.id)) {
13805
+ reported.add(entry.id);
13806
+ this.warn(`skipping ${at}: it is one of its own ancestors (the tree contains a cycle)`);
13807
+ }
13808
+ continue;
13809
+ }
13810
+ // Thrown, not truncated: a sync reads a missing path as a
13811
+ // deleted one. The frames are the levels below `start`.
13812
+ if (stack.length > MAX_DEPTH)
13813
+ throw new VfsLimitError("depth", MAX_DEPTH, start, at);
13814
+ const child = await ahead.open(entry, at);
13815
+ visit(toStat(at, name, entry), null);
13816
+ onPath.add(entry.id);
13817
+ if (enter(child, at))
13818
+ await nextTurn();
13819
+ }
13820
+ }
13821
+ finally {
13822
+ // However it ended. A walk that failed must not leave fetches
13823
+ // running in the background after the caller has moved on.
13824
+ ahead.stop();
13825
+ }
13102
13826
  }
13103
13827
  async readFile(path) {
13104
13828
  const entry = await this.expectFile(path);
@@ -13152,7 +13876,7 @@ class VfsNamespace {
13152
13876
  assertValidName(name);
13153
13877
  await this.mkdir(dirname(target), { recursive: true });
13154
13878
  const parent = await this.openDir(dirname(target));
13155
- const prior = parent.node.entries[name];
13879
+ const prior = entryIn(parent.node, name);
13156
13880
  if (prior?.kind === "dir")
13157
13881
  throw new VfsConflictError(`${target} is a directory`);
13158
13882
  await this.deps.content.retain(manifest);
@@ -13162,7 +13886,9 @@ class VfsNamespace {
13162
13886
  manifest,
13163
13887
  size,
13164
13888
  mtime: Date.now(),
13165
- versions: prior?.versions ?? 0,
13889
+ versions: asCount(prior?.versions),
13890
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
13891
+ ...(prior?.moving === true ? { moving: true } : {}),
13166
13892
  };
13167
13893
  await this.mutate(parent, (node) => {
13168
13894
  node.entries[name] = entry;
@@ -13178,13 +13904,20 @@ class VfsNamespace {
13178
13904
  const target = this.resolve(path);
13179
13905
  if (target === "/")
13180
13906
  return;
13907
+ // Before anything is created. This is also the whole of the check for
13908
+ // writeFile and writeManifest: a file may sit in the deepest
13909
+ // directory, and they create its parent through here.
13910
+ assertWritableDepth(target);
13181
13911
  const parts = segments(target);
13182
13912
  let current = "/";
13183
13913
  for (let i = 0; i < parts.length; i++) {
13184
13914
  const name = parts[i];
13185
13915
  const isLast = i === parts.length - 1;
13186
13916
  const next = join(current, name);
13187
- const existing = (await this.openDir(current)).node.entries[name];
13917
+ const dir = await this.openDir(current);
13918
+ const existing = entryIn(dir.node, name);
13919
+ if (!existing)
13920
+ assertNotHidden(dir, name, next);
13188
13921
  if (existing) {
13189
13922
  if (existing.kind === "file")
13190
13923
  throw new VfsConflictError(`${next} is a file`);
@@ -13199,6 +13932,38 @@ class VfsNamespace {
13199
13932
  current = next;
13200
13933
  }
13201
13934
  }
13935
+ /**
13936
+ * Refuse now what creating these directories with `mkdir -p` would refuse
13937
+ * part way: one deeper than MAX_DEPTH, or a name on the way held by an
13938
+ * entry this version cannot read. Writes nothing.
13939
+ *
13940
+ * For an operation that removes things before it writes - a checkout -
13941
+ * so that a refusal comes before anything is gone. A FILE in the way is
13942
+ * not refused: such an operation removes those first.
13943
+ */
13944
+ async assertCreatable(dirs) {
13945
+ let checked = 0;
13946
+ for (const target of new Set([...dirs].map((dir) => this.resolve(dir)))) {
13947
+ assertWritableDepth(target);
13948
+ let dir = await this.root();
13949
+ let at = "/";
13950
+ for (const name of segments(target)) {
13951
+ at = join(at, name);
13952
+ const entry = entryIn(dir.node, name);
13953
+ if (!entry) {
13954
+ assertNotHidden(dir, name, at);
13955
+ break; // everything below it is to be created
13956
+ }
13957
+ if (entry.kind !== "dir")
13958
+ break;
13959
+ dir = await this.openDirById(entry.id, fromBase64(entry.key), at);
13960
+ }
13961
+ // Once the tree has been walked every record is cached, and nothing
13962
+ // here waits on anything.
13963
+ if (++checked % YIELD_EVERY === 0)
13964
+ await nextTurn();
13965
+ }
13966
+ }
13202
13967
  /**
13203
13968
  * Write a file, creating parent directories as needed.
13204
13969
  *
@@ -13218,14 +13983,18 @@ class VfsNamespace {
13218
13983
  type: opts.type ?? contentTypeFor(name),
13219
13984
  });
13220
13985
  const parent = await this.openDir(parentPath);
13221
- const prior = parent.node.entries[name];
13986
+ const prior = entryIn(parent.node, name);
13222
13987
  if (prior?.kind === "dir")
13223
13988
  throw new VfsConflictError(`${target} is a directory`);
13224
13989
  const id = prior?.id ?? newId();
13225
- let versions = prior?.versions ?? 0;
13990
+ let versions = asCount(prior?.versions);
13226
13991
  if (prior)
13227
- versions = await this.pushHistory(parent, id, prior);
13228
- const entry = { kind: "file", id, manifest, size, mtime: Date.now(), versions };
13992
+ versions = await this.pushHistory(parent, name, prior, target, manifest);
13993
+ const entry = {
13994
+ kind: "file", id, manifest, size, mtime: Date.now(), versions,
13995
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
13996
+ ...(prior?.moving === true ? { moving: true } : {}),
13997
+ };
13229
13998
  await this.mutate(parent, (node) => {
13230
13999
  node.entries[name] = entry;
13231
14000
  });
@@ -13234,36 +14003,110 @@ class VfsNamespace {
13234
14003
  /**
13235
14004
  * Delete a file, or a directory when `recursive`.
13236
14005
  *
13237
- * `keepContent` removes the entry WITHOUT releasing its shards. A checkout
13238
- * needs that: the file is leaving the working tree, but commits still
13239
- * reference the same manifest, and releasing would free content history
13240
- * depends on.
14006
+ * `keepContent` removes the entry WITHOUT releasing its shards - or, for
14007
+ * a directory, the shards of anything in it. A checkout needs that: the
14008
+ * file is leaving the working tree, but commits still reference the same
14009
+ * manifest, and releasing would free content history depends on.
13241
14010
  */
13242
14011
  async delete(path, opts = {}) {
13243
14012
  const target = this.resolve(path);
13244
14013
  if (target === "/")
13245
14014
  throw new VfsConflictError("the root directory cannot be deleted");
13246
14015
  const name = basename(target);
13247
- const parent = await this.openDir(dirname(target));
13248
- const entry = parent.node.entries[name];
13249
- if (!entry)
13250
- throw new VfsNotFoundError(target);
13251
- if (entry.kind === "dir") {
13252
- const child = await this.openDirById(entry.id, fromBase64(entry.key));
13253
- const empty = Object.keys(child.node.entries).length === 0;
13254
- if (!empty && !opts.recursive)
13255
- throw new VfsConflictError(`${target} is not empty`);
13256
- await this.purge(child);
13257
- }
13258
- else if (opts.keepContent) {
13259
- await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
14016
+ const opened = await this.openChain(dirname(target));
14017
+ if (!opened)
14018
+ throw new VfsNotFoundError(dirname(target));
14019
+ const parent = opened.dir;
14020
+ const entry = entryIn(parent.node, name);
14021
+ if (!entry) {
14022
+ if (!Object.hasOwn(parent.hidden, name))
14023
+ throw new VfsNotFoundError(target);
14024
+ // An entry this version cannot read, asked for by its exact name.
14025
+ // It may be a newer version's, and what it holds cannot be known,
14026
+ // so only a recursive delete removes it - and only detaches it,
14027
+ // letting go of nothing: a leak is the safe way to be wrong. The
14028
+ // way to clear a name that mkdir, rename, copy and a checkout
14029
+ // refuse, short of deleting everything around it.
14030
+ const where = printable(target, 1024);
14031
+ if (!opts.recursive)
14032
+ throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
14033
+ await this.mutate(parent, () => void delete parent.hidden[name]);
14034
+ this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
14035
+ return;
13260
14036
  }
13261
- else {
13262
- await this.releaseFile(parent, entry);
14037
+ // Every directory above the target, the root first. None of them is
14038
+ // part of what is being deleted, whatever an entry below says.
14039
+ const above = new Set(opened.chain);
14040
+ // Three steps, in this order: read everything the delete will touch,
14041
+ // detach the entry, then let go of what it held.
14042
+ //
14043
+ // Letting go came first once, and the entry was detached last. Any
14044
+ // failure in between - a network error, most simply - left the entry
14045
+ // in place with some of its content already released, and the obvious
14046
+ // next step, trying again, released that content a SECOND time.
14047
+ // Content is reference-counted and shared by copies, so that freed
14048
+ // bytes another file still used. Now a failure before the detach has
14049
+ // changed nothing, and nothing after it can fail: releasing is
14050
+ // best-effort, and a leak is the safe way to be wrong.
14051
+ let doomed = [];
14052
+ // Whether any of it is half of an interrupted move. See below.
14053
+ let moving = entry.kind === "file" && entry.moving === true;
14054
+ if (entry.kind === "dir" && above.has(entry.id)) {
14055
+ // The entry IS a pointer to a directory above it - the root, or
14056
+ // this entry's own parent. Removing the pointer is what was asked
14057
+ // for; clearing out what it points at would delete everything
14058
+ // around the target, the target included.
14059
+ if (!opts.recursive)
14060
+ throw new VfsConflictError(`${target} is not empty`);
14061
+ this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
14062
+ }
14063
+ else if (entry.kind === "dir") {
14064
+ const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
14065
+ // An unreadable directory must still be removable, or the user
14066
+ // is stuck with an error they can do nothing about. Its subtree
14067
+ // cannot be enumerated, so it is detached rather than purged and
14068
+ // whatever it holds is left for sweep(). Only when asked for
14069
+ // recursively: without that we cannot know it is empty.
14070
+ if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
14071
+ throw err;
14072
+ this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
14073
+ return null;
14074
+ });
14075
+ if (child) {
14076
+ // Entries this version cannot read still count: calling the
14077
+ // directory empty would delete them along with it.
14078
+ const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
14079
+ if (!empty && !opts.recursive)
14080
+ throw new VfsConflictError(`${target} is not empty`);
14081
+ const found = await this.subtree(child, target, above);
14082
+ doomed = found.dirs;
14083
+ moving = entry.moving === true || found.moving;
14084
+ }
13263
14085
  }
14086
+ // A move between two directories that was interrupted leaves what it
14087
+ // moved named in both, and letting go of it here would take it from
14088
+ // the other copy: the directory record purged with every file in it,
14089
+ // or a file's content and history released. Only a marked entry can
14090
+ // be one of those (see `rename`), so only then is the whole tree
14091
+ // read, once, to find out what is still named elsewhere. Still before
14092
+ // anything changes: a failure here leaves the tree as it was.
14093
+ const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
13264
14094
  await this.mutate(parent, (node) => {
13265
14095
  delete node.entries[name];
13266
14096
  });
14097
+ if (elsewhere === null) {
14098
+ // Part of the tree cannot be read, so it is unknown whether the
14099
+ // other copy is in it. Keeping what may be its contents costs
14100
+ // storage until a sweep; letting go of them could lose them.
14101
+ this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
14102
+ return;
14103
+ }
14104
+ if (entry.kind === "dir")
14105
+ await this.purge(doomed, elsewhere, opts.keepContent);
14106
+ else
14107
+ await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
14108
+ if (elsewhere && entry.moving === true)
14109
+ await this.settle(entry.id, elsewhere);
13267
14110
  }
13268
14111
  /**
13269
14112
  * Move or rename. Works across directories, which is the same operation:
@@ -13271,7 +14114,8 @@ class VfsNamespace {
13271
14114
  *
13272
14115
  * A directory move is O(1) regardless of how much is inside it — the
13273
14116
  * subtree records are not touched at all, because nothing in them records
13274
- * its own path.
14117
+ * its own path. Except a move to a DEEPER place, which reads the subtree
14118
+ * first: what is below must still be inside MAX_DEPTH once it is moved.
13275
14119
  */
13276
14120
  async rename(from, to) {
13277
14121
  const src = this.resolve(from);
@@ -13282,28 +14126,83 @@ class VfsNamespace {
13282
14126
  throw new VfsConflictError("the root directory cannot be moved");
13283
14127
  if (isUnder(dst, src))
13284
14128
  throw new VfsConflictError(`cannot move ${src} into itself`);
14129
+ const srcName = basename(src);
13285
14130
  const dstName = basename(dst);
13286
14131
  assertValidName(dstName);
13287
14132
  const srcParent = await this.openDir(dirname(src));
13288
- const entry = srcParent.node.entries[basename(src)];
14133
+ const entry = entryIn(srcParent.node, srcName);
13289
14134
  if (!entry)
13290
14135
  throw new VfsNotFoundError(src);
14136
+ if (entry.kind === "dir") {
14137
+ assertWritableDepth(dst);
14138
+ if (segments(dst).length > segments(src).length)
14139
+ await this.assertFitsAt(src, dst);
14140
+ }
13291
14141
  await this.mkdir(dirname(dst), { recursive: true });
13292
14142
  const dstParent = await this.openDir(dirname(dst));
13293
- if (dstParent.node.entries[dstName])
14143
+ if (entryIn(dstParent.node, dstName))
13294
14144
  throw new VfsConflictError(`${dst} already exists`);
13295
- // Attach before detaching. If the second write fails the entry is
13296
- // reachable from both parents — visible and fixable. The other order
13297
- // would lose the subtree entirely.
13298
- const moved = { ...entry, mtime: Date.now() };
13299
- await this.mutate(dstParent, (node) => {
13300
- node.entries[dstName] = moved;
13301
- });
13302
- // Re-open: same-parent moves mutated the record we are about to edit.
13303
- const detachFrom = await this.openDir(dirname(src));
13304
- await this.mutate(detachFrom, (node) => {
13305
- delete node.entries[basename(src)];
13306
- });
14145
+ assertNotHidden(dstParent, dstName, dst);
14146
+ // Each record is re-opened before it is written: creating the
14147
+ // destination's parents above may have written to the source's.
14148
+ const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
14149
+ if (dstParent.id === srcParent.id) {
14150
+ // One record, so one write: the move happens entirely or not at all.
14151
+ await edit(dirname(src), (node) => {
14152
+ delete node.entries[srcName];
14153
+ node.entries[dstName] = { ...entry, mtime: Date.now() };
14154
+ });
14155
+ return;
14156
+ }
14157
+ // Two records, attached before detaching: interrupted, the entry is
14158
+ // in both rather than in neither. Both copies are marked for as long
14159
+ // as that can be so, because they share everything below them, and a
14160
+ // delete of either would otherwise let go of the other's contents.
14161
+ // An entry already marked is half of an earlier interrupted move, and
14162
+ // keeps its mark.
14163
+ const marked = entry.moving === true;
14164
+ if (!marked)
14165
+ await edit(dirname(src), (node) => void (node.entries[srcName] = { ...entry, moving: true }));
14166
+ await edit(dirname(dst), (node) => void (node.entries[dstName] = { ...entry, moving: true, mtime: Date.now() }));
14167
+ // A file's history is sealed with its directory's key, and from here
14168
+ // on the directory that opens it is this one.
14169
+ if (entry.kind === "file")
14170
+ await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
14171
+ await edit(dirname(src), (node) => void delete node.entries[srcName]);
14172
+ if (!marked) {
14173
+ await edit(dirname(dst), (node) => {
14174
+ const landed = entryIn(node, dstName);
14175
+ if (landed?.id === entry.id)
14176
+ node.entries[dstName] = unmarked(landed);
14177
+ });
14178
+ }
14179
+ }
14180
+ /**
14181
+ * Refuse to move the directory at `src` to `dst` when something in it
14182
+ * would then be deeper than MAX_DEPTH from the root. Writes nothing.
14183
+ *
14184
+ * Measured only moving deeper, because it reads the whole subtree: one
14185
+ * move of a 40-deep tree under a 30-deep directory was accepted when only
14186
+ * the moved directory was measured, and every walk of the root threw from
14187
+ * then on. A part that cannot be read cannot be measured, and does not
14188
+ * stop the move - that record stops every walk above it already.
14189
+ */
14190
+ async assertFitsAt(src, dst) {
14191
+ const room = MAX_DEPTH - segments(dst).length;
14192
+ try {
14193
+ await this.eachEntry(src, ({ kind, path }) => {
14194
+ if (kind !== "dir")
14195
+ return;
14196
+ const below = path.slice(src.length);
14197
+ if (below.split("/").length - 1 <= room)
14198
+ return;
14199
+ throw new VfsLimitError("depth", MAX_DEPTH, dst, dst + below, "writing");
14200
+ });
14201
+ }
14202
+ catch (err) {
14203
+ if (!(err instanceof VfsCorruptRecordError))
14204
+ throw err;
14205
+ }
13307
14206
  }
13308
14207
  /**
13309
14208
  * Copy a file or a directory tree.
@@ -13333,9 +14232,21 @@ class VfsNamespace {
13333
14232
  assertValidName(dstName);
13334
14233
  await this.mkdir(dirname(dst), { recursive: true });
13335
14234
  const dstParent = await this.openDir(dirname(dst));
13336
- if (dstParent.node.entries[dstName])
14235
+ if (entryIn(dstParent.node, dstName))
13337
14236
  throw new VfsConflictError(`${dst} already exists`);
13338
- const copied = await this.cloneEntry(found.entry);
14237
+ assertNotHidden(dstParent, dstName, dst);
14238
+ // Twice over: a dry run first, which fetches and checks everything the
14239
+ // copy will visit - cycles, depth, both budgets, unreadable records -
14240
+ // and writes nothing. A copy that is going to fail then fails before
14241
+ // its first write, instead of after up to a whole budget's worth of
14242
+ // records and references, all left unreachable. The records are
14243
+ // cached by then, so the real pass costs writes, not fetches.
14244
+ const run = (write) => ({
14245
+ from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
14246
+ });
14247
+ const depth = segments(dst).length;
14248
+ await this.cloneEntry(found.entry, src, run(false), null, depth);
14249
+ const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
13339
14250
  await this.mutate(dstParent, (node) => {
13340
14251
  node.entries[dstName] = copied;
13341
14252
  });
@@ -13368,25 +14279,27 @@ class VfsNamespace {
13368
14279
  */
13369
14280
  async sweep(opts = {}) {
13370
14281
  const root = await this.root();
13371
- const reachable = new Set();
13372
- const visit = async (dir) => {
13373
- // `reachable` was already the visited set - it just was not being
13374
- // TESTED before recursing, so a cycle looped forever. Here the
13375
- // consequence would have been worse than a hang: sweep decides what
13376
- // to delete, and it never got as far as deciding.
13377
- if (reachable.has(dirKey(dir.id)))
13378
- return;
13379
- reachable.add(dirKey(dir.id));
13380
- for (const entry of Object.values(dir.node.entries)) {
13381
- if (entry.kind === "dir") {
13382
- await visit(await this.openDirById(entry.id, fromBase64(entry.key)));
13383
- }
13384
- else {
13385
- reachable.add(historyKey(entry.id));
13386
- }
13387
- }
13388
- };
13389
- await visit(root);
14282
+ // A record that cannot be read throws out of here, before anything is
14283
+ // deleted: with part of the tree unknown, "unreachable" is a guess.
14284
+ // Behind a name no path can reach, though, an unreadable record is one
14285
+ // the user cannot delete either: it is counted with the entries this
14286
+ // version cannot read - refused below unless forced - and, forced, kept
14287
+ // and not followed. `references` follows entries hidden only for their
14288
+ // NAME: what they point at is still referenced, and so not an orphan.
14289
+ const named = await this.references();
14290
+ const reachable = new Set([
14291
+ ...[...named.dirs].map(dirKey),
14292
+ ...[...named.files.keys()].map(historyKey),
14293
+ ]);
14294
+ const unreadable = named.unknown;
14295
+ // The same caution for an ENTRY this version cannot read: a newer
14296
+ // SDK's entry may be the only thing keeping some record reachable, and
14297
+ // there is no way to follow it from here.
14298
+ if (unreadable && !opts.force) {
14299
+ throw new VfsConflictError(`refusing to sweep: ${unreadable} entr${unreadable === 1 ? "y" : "ies"} in this filesystem ` +
14300
+ "cannot be read by this version, so what they reference is unknown; " +
14301
+ "upgrade, or sweep with force to go ahead anyway");
14302
+ }
13390
14303
  // Only ever consider OUR records: the personal space is shared with
13391
14304
  // chat keys, space keys and the legacy file mirror, and a sweep that
13392
14305
  // reached beyond its own namespace would be a data-loss bug.
@@ -13398,7 +14311,7 @@ class VfsNamespace {
13398
14311
  const rootEmpty = Object.keys(root.node.entries).length === 0;
13399
14312
  if (rootEmpty && orphans.length && !opts.force) {
13400
14313
  throw new VfsConflictError(`refusing to sweep ${orphans.length} record(s) with an empty root — ` +
13401
- "this looks like a failed load rather than an empty filesystem; pass { force: true } if it really is empty");
14314
+ "this looks like a failed load rather than an empty filesystem; sweep with force if it really is empty");
13402
14315
  }
13403
14316
  for (const key of orphans)
13404
14317
  await this.deps.store.delete(key).catch(() => { });
@@ -13407,9 +14320,10 @@ class VfsNamespace {
13407
14320
  // ---- history ------------------------------------------------------------
13408
14321
  /** Prior versions of a file, newest first. One record fetch. */
13409
14322
  async history(path) {
13410
- const entry = await this.expectFile(path);
13411
- const record = await this.readHistory(await this.openDir(dirname(path)), entry.id);
13412
- return record.versions.map(({ size, mtime }) => ({ size, mtime }));
14323
+ const target = this.resolve(path);
14324
+ const entry = await this.expectFile(target);
14325
+ const { record } = await this.readHistory(await this.openDir(dirname(target)), basename(target), entry, target);
14326
+ return record.versions.map(({ size, mtime }) => ({ size: asCount(size), mtime: asTime(mtime) }));
13413
14327
  }
13414
14328
  /**
13415
14329
  * Restore a prior version, where 0 is the most recent one.
@@ -13421,21 +14335,22 @@ class VfsNamespace {
13421
14335
  const target = this.resolve(path);
13422
14336
  const name = basename(target);
13423
14337
  const parent = await this.openDir(dirname(target));
13424
- const entry = parent.node.entries[name];
14338
+ const entry = entryIn(parent.node, name);
13425
14339
  if (!entry || entry.kind !== "file")
13426
14340
  throw new VfsNotFoundError(target);
13427
- const record = await this.readHistory(parent, entry.id);
14341
+ const { record } = await this.readHistory(parent, name, entry, target);
13428
14342
  const version = record.versions[index];
13429
14343
  if (!version)
13430
14344
  throw new VfsNotFoundError(`${target} version ${index}`);
13431
- const versions = await this.pushHistory(parent, entry.id, entry);
14345
+ const versions = await this.pushHistory(parent, name, entry, target, version.manifest);
13432
14346
  const restored = {
13433
14347
  kind: "file",
13434
14348
  id: entry.id,
13435
14349
  manifest: version.manifest,
13436
- size: version.size,
14350
+ size: asCount(version.size),
13437
14351
  mtime: Date.now(),
13438
14352
  versions,
14353
+ ...(entry.moving === true ? { moving: true } : {}),
13439
14354
  };
13440
14355
  await this.mutate(parent, (node) => {
13441
14356
  node.entries[name] = restored;
@@ -13460,7 +14375,7 @@ class VfsNamespace {
13460
14375
  if (!parent)
13461
14376
  return null;
13462
14377
  const name = basename(target);
13463
- const entry = parent.node.entries[name];
14378
+ const entry = entryIn(parent.node, name);
13464
14379
  return entry ? { name, entry } : null;
13465
14380
  }
13466
14381
  async root() {
@@ -13469,7 +14384,7 @@ class VfsNamespace {
13469
14384
  throw new VfsLockedError();
13470
14385
  if (!this.rootKey)
13471
14386
  this.rootKey = await deriveRootKey(seed);
13472
- return this.openDirById(ROOT_ID, this.rootKey);
14387
+ return this.openDirById(ROOT_ID, this.rootKey, "/");
13473
14388
  }
13474
14389
  /** Open a directory by path, creating nothing. Throws if it is missing. */
13475
14390
  async openDir(path) {
@@ -13479,16 +14394,29 @@ class VfsNamespace {
13479
14394
  return dir;
13480
14395
  }
13481
14396
  async openDirOrNull(path) {
14397
+ return (await this.openChain(path))?.dir ?? null;
14398
+ }
14399
+ /** Open a directory by path, along with the id of every directory on the way to it. */
14400
+ async openChain(path) {
13482
14401
  let dir = await this.root();
14402
+ const chain = [dir.id];
14403
+ let at = "/";
13483
14404
  for (const name of segments(this.resolve(path))) {
13484
- const entry = dir.node.entries[name];
14405
+ const entry = entryIn(dir.node, name);
13485
14406
  if (!entry || entry.kind !== "dir")
13486
14407
  return null;
13487
- dir = await this.openDirById(entry.id, fromBase64(entry.key));
14408
+ at = join(at, name);
14409
+ dir = await this.openDirById(entry.id, fromBase64(entry.key), at);
14410
+ chain.push(dir.id);
13488
14411
  }
13489
- return dir;
14412
+ return { dir, chain };
13490
14413
  }
13491
- openDirById(id, key) {
14414
+ /**
14415
+ * `path` is only for error messages. The cache is keyed by id, so when two
14416
+ * paths reach one record - a move interrupted between attach and detach -
14417
+ * the error names whichever asked first.
14418
+ */
14419
+ openDirById(id, key, path) {
13492
14420
  const hit = this.cache.get(id);
13493
14421
  if (hit)
13494
14422
  return hit;
@@ -13505,8 +14433,26 @@ class VfsNamespace {
13505
14433
  // exists before anything has been written to it, and a directory is
13506
14434
  // created by its parent naming it rather than by its own record
13507
14435
  // appearing.
13508
- const node = (await unseal(key, raw)) ?? { v: 1, entries: {}, mtime: Date.now() };
13509
- return { id, key, node };
14436
+ if (raw === null || raw === undefined) {
14437
+ const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
14438
+ return { id, key, path, node, hidden: Object.create(null) };
14439
+ }
14440
+ // Present but unopenable is NOT absent. `unseal` folds the two
14441
+ // together, which suits a record that can simply be rebuilt; a
14442
+ // directory cannot, because rebuilding it means sealing an empty
14443
+ // one over whatever is really there. And it is not a raw
14444
+ // DOMException either: the typed error is what delete, purge and
14445
+ // sweep know how to step around, and it names the record.
14446
+ let value;
14447
+ try {
14448
+ value = await openSealed(key, raw);
14449
+ }
14450
+ catch (err) {
14451
+ if (err instanceof SealedRecordError)
14452
+ throw new VfsCorruptRecordError(id, path, err.message);
14453
+ throw err;
14454
+ }
14455
+ return this.readRecord(id, key, value, path);
13510
14456
  })().catch((err) => {
13511
14457
  // A failed read must not be remembered, or one blip poisons the
13512
14458
  // directory for the life of the session.
@@ -13517,11 +14463,110 @@ class VfsNamespace {
13517
14463
  this.cache.set(id, pending);
13518
14464
  return pending;
13519
14465
  }
14466
+ /**
14467
+ * Turn a decrypted record into an OpenDir, or throw.
14468
+ *
14469
+ * The record is THROWN on, never read as empty, when its shape is wrong -
14470
+ * see VfsCorruptRecordError. An individual entry of the wrong shape is set
14471
+ * aside in `hidden` instead: out of every read, but kept for the write.
14472
+ */
14473
+ readRecord(id, key, value, path) {
14474
+ const problem = recordProblem(value);
14475
+ if (problem)
14476
+ throw new VfsCorruptRecordError(id, path, problem);
14477
+ const { entries: stored, ...fields } = value;
14478
+ // Anything nested past MAX_NESTING is dropped outright, not kept to be
14479
+ // written back: JSON.stringify cannot write it, and every write to
14480
+ // this directory - even deleting a sibling - would throw.
14481
+ let dropped = 0;
14482
+ // No prototype, like the entry map: a field named `__proto__` is a
14483
+ // field to keep, not a prototype to set.
14484
+ const top = Object.create(null);
14485
+ for (const [field, fieldValue] of Object.entries(fields)) {
14486
+ if (nestedDeeperThan(fieldValue, MAX_NESTING))
14487
+ dropped++;
14488
+ else
14489
+ top[field] = fieldValue;
14490
+ }
14491
+ const entries = emptyEntries();
14492
+ const hidden = Object.create(null);
14493
+ let unnamed = 0;
14494
+ let unreadable = 0;
14495
+ for (const [name, entry] of Object.entries(stored)) {
14496
+ if (nestedDeeperThan(entry, MAX_NESTING)) {
14497
+ dropped++;
14498
+ continue;
14499
+ }
14500
+ // The NAME is validated here, where it is read, as well as the
14501
+ // entry. A directory record is sealed under a key derivable from
14502
+ // the master seed, so its contents are not necessarily something
14503
+ // this SDK wrote, and the write-side check never sees them. An
14504
+ // unusable name is set aside rather than thrown on: one bad entry
14505
+ // must not make the whole directory unlistable, and callers walk
14506
+ // what is left straight into filesystem paths.
14507
+ if (!isSafeName(name))
14508
+ unnamed++;
14509
+ else if (!readableEntry(entry))
14510
+ unreadable++;
14511
+ else {
14512
+ entries[name] = entry;
14513
+ continue;
14514
+ }
14515
+ hidden[name] = entry;
14516
+ }
14517
+ if (unnamed || unreadable || dropped) {
14518
+ // Once per record fetched, however often a walk comes back to it.
14519
+ // Names are left out: they are exactly the ones not fit to print.
14520
+ const count = (n) => `${n} entr${n === 1 ? "y" : "ies"}`;
14521
+ const what = [
14522
+ unnamed && `${count(unnamed)} with an unusable name`,
14523
+ unreadable && `${count(unreadable)} this version cannot read`,
14524
+ dropped && `${dropped} value${dropped === 1 ? "" : "s"} nested too deeply to keep`,
14525
+ ].filter(Boolean).join(" and ");
14526
+ this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
14527
+ }
14528
+ // A v1 record's unknown top-level fields survive a write too.
14529
+ return { id, key, path, node: { ...top, entries }, hidden };
14530
+ }
13520
14531
  /** Apply an edit to a directory record and persist it. */
13521
14532
  async mutate(dir, edit) {
13522
14533
  edit(dir.node);
13523
14534
  dir.node.mtime = Date.now();
13524
- await this.deps.store.put(dirKey(dir.id), await seal(dir.key, dir.node));
14535
+ // A write to a hidden entry's exact name replaces it: that name now
14536
+ // means what this version wrote. Forgetting the old one here is also
14537
+ // what stops a later delete of the replacement from resurrecting it.
14538
+ for (const name of Object.keys(dir.hidden)) {
14539
+ if (Object.hasOwn(dir.node.entries, name))
14540
+ delete dir.hidden[name];
14541
+ }
14542
+ const stored = Object.keys(dir.hidden).length
14543
+ ? { ...dir.node, entries: Object.assign(Object.create(null), dir.hidden, dir.node.entries) }
14544
+ : dir.node;
14545
+ try {
14546
+ let sealed;
14547
+ try {
14548
+ sealed = await seal(dir.key, stored);
14549
+ }
14550
+ catch (err) {
14551
+ // JSON.stringify's own failures: a value too deeply nested to
14552
+ // write. Reading drops those from a stored record, so this is
14553
+ // what the write itself was given.
14554
+ if (!(err instanceof RangeError || err instanceof TypeError))
14555
+ throw err;
14556
+ const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
14557
+ throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
14558
+ }
14559
+ await this.deps.store.put(dirKey(dir.id), sealed);
14560
+ }
14561
+ catch (err) {
14562
+ // The edit above was made in place, on the very object the cache
14563
+ // holds. Left there after a failed write, the cache would show a
14564
+ // change the store never took - a deleted file gone from every
14565
+ // listing while it is still stored, and a retry told it does not
14566
+ // exist. Drop it, so the next read asks the store.
14567
+ this.cache.delete(dir.id);
14568
+ throw err;
14569
+ }
13525
14570
  this.cache.set(dir.id, Promise.resolve(dir));
13526
14571
  }
13527
14572
  async createDir(parentPath, name) {
@@ -13532,80 +14577,421 @@ class VfsNamespace {
13532
14577
  node.entries[name] = entry;
13533
14578
  });
13534
14579
  }
13535
- /** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
13536
- async purge(dir, seen = new Set()) {
13537
- // A record that references an ancestor would otherwise recurse forever:
13538
- // the id is only removed from the cache AFTER the loop, so re-entering
13539
- // it hits the same cached record every time.
13540
- if (seen.has(dir.id))
14580
+ /**
14581
+ * Every directory record in a subtree, each once, opened - and nothing
14582
+ * changed. The first half of a recursive delete; see `delete`.
14583
+ *
14584
+ * Once per id, so a record that references another inside the subtree
14585
+ * cannot recurse forever, and a subtree reachable twice is not released
14586
+ * twice. And never a directory in `above` - the root and everything else
14587
+ * on the way down to the target - nor the root by any route. Deduplicating
14588
+ * alone let an entry naming the root pull the whole filesystem into the
14589
+ * delete, the root record included; a walk shows the same pointer as a
14590
+ * cycle and skips it, so this skips it too.
14591
+ *
14592
+ * `moving` says whether anything in it is marked as half of an
14593
+ * interrupted move, which is what makes a delete look for the other half.
14594
+ */
14595
+ async subtree(top, target, above) {
14596
+ const found = new Map([[top.id, top]]);
14597
+ const pending = [top];
14598
+ let spared = 0;
14599
+ let moving = false;
14600
+ for (let dir = pending.pop(); dir; dir = pending.pop()) {
14601
+ for (const [, entry] of followable(dir)) {
14602
+ if (entry.moving === true)
14603
+ moving = true;
14604
+ if (entry.kind !== "dir" || found.has(entry.id))
14605
+ continue;
14606
+ if (entry.id === ROOT_ID || above.has(entry.id)) {
14607
+ spared++;
14608
+ continue;
14609
+ }
14610
+ const child = await this.openDirById(entry.id, fromBase64(entry.key)).catch((err) => {
14611
+ // The caller asked for this whole subtree to go. One part
14612
+ // of it being unreadable is no reason to refuse the rest:
14613
+ // skip that part and leave it to sweep(). Any OTHER failure
14614
+ // stops the delete here, before anything has been released.
14615
+ if (!(err instanceof VfsCorruptRecordError))
14616
+ throw err;
14617
+ this.warn("skipping an unreadable directory while deleting its parent");
14618
+ return null;
14619
+ });
14620
+ if (!child)
14621
+ continue;
14622
+ found.set(child.id, child);
14623
+ pending.push(child);
14624
+ }
14625
+ }
14626
+ if (spared) {
14627
+ // Counted, not named: the entry may be one whose name is not fit to print.
14628
+ const what = spared === 1 ? "a directory that is" : `${spared} directories that are`;
14629
+ this.warn(`leaving ${what} above ${target} in the tree alone while deleting it`);
14630
+ }
14631
+ return { dirs: [...found.values()], moving };
14632
+ }
14633
+ /**
14634
+ * Delete these records and let go of every file in them. The second half
14635
+ * of a recursive delete, run only once the subtree is detached. Cannot
14636
+ * throw: every step is best-effort, because unreachable is not fatal.
14637
+ *
14638
+ * With `elsewhere` - what the rest of the tree still names, read because
14639
+ * something here is half of an interrupted move - a record still named
14640
+ * elsewhere is kept, and so is a file.
14641
+ */
14642
+ async purge(dirs, elsewhere, keepContent = false) {
14643
+ const gone = dirs.filter((dir) => !elsewhere?.dirs.has(dir.id));
14644
+ // By file id: two entries with one id are one file, holding one
14645
+ // reference to its content, however many directories name it.
14646
+ const files = new Map();
14647
+ for (const dir of gone) {
14648
+ for (const [, entry] of followable(dir)) {
14649
+ if (entry.kind !== "file")
14650
+ continue;
14651
+ let copies = files.get(entry.id);
14652
+ if (!copies)
14653
+ files.set(entry.id, (copies = []));
14654
+ copies.push({ dir, entry });
14655
+ }
14656
+ }
14657
+ await this.letGo(files, elsewhere, keepContent);
14658
+ for (const dir of gone) {
14659
+ this.cache.delete(dir.id);
14660
+ await this.deps.store.delete(dirKey(dir.id)).catch(() => { });
14661
+ }
14662
+ if (!elsewhere)
14663
+ return;
14664
+ for (const dir of gone) {
14665
+ for (const [, entry] of followable(dir))
14666
+ if (entry.moving === true)
14667
+ await this.settle(entry.id, elsewhere);
14668
+ }
14669
+ }
14670
+ /**
14671
+ * Unmark what is left of an interrupted move once a delete has removed
14672
+ * the other copy. Marked, a file keeps every version that falls off its
14673
+ * history, because the other copy might hold it - for good, once there
14674
+ * is no other copy - and every delete of the entry, or of a directory
14675
+ * above it, reads the whole tree. Only when exactly one marked copy is
14676
+ * left: more, and they are still shared. Best-effort, like everything
14677
+ * after a detach; failing, it stays marked, which costs only that.
14678
+ */
14679
+ async settle(id, elsewhere) {
14680
+ const holding = elsewhere.marked.get(id);
14681
+ if (holding?.length !== 1)
13541
14682
  return;
13542
- seen.add(dir.id);
13543
- for (const entry of Object.values(dir.node.entries)) {
13544
- if (entry.kind === "dir") {
13545
- await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)), seen);
14683
+ try {
14684
+ // Opened again: this delete may have written to it since the tree was read.
14685
+ const dir = await this.openDirById(holding[0].id, holding[0].key);
14686
+ const name = Object.keys(dir.node.entries).find((n) => dir.node.entries[n].id === id && dir.node.entries[n].moving === true);
14687
+ if (name !== undefined)
14688
+ await this.mutate(dir, (node) => void (node.entries[name] = unmarked(node.entries[name])));
14689
+ }
14690
+ catch {
14691
+ // It stays marked.
14692
+ }
14693
+ }
14694
+ /**
14695
+ * Let go of files that are leaving the tree: each one's content, every
14696
+ * version its history holds, and the history record. Best-effort, like
14697
+ * everything after a detach.
14698
+ *
14699
+ * A file id still named elsewhere is one file with two names, left by an
14700
+ * interrupted move: its content and history belong to the name that is
14701
+ * staying, and all that changes hands is the key its history opens with.
14702
+ * `keepContent` keeps every file's content and drops only its history
14703
+ * record, as a checkout needs.
14704
+ */
14705
+ async letGo(files, elsewhere, keepContent = false) {
14706
+ for (const [id, copies] of files) {
14707
+ const keys = copies.map(({ dir }) => dir.key);
14708
+ const staying = elsewhere?.files.get(id);
14709
+ if (staying)
14710
+ await this.rekeyHistory(id, keys, staying.key).catch(() => { });
14711
+ else if (keepContent)
14712
+ await this.deps.store.delete(historyKey(id)).catch(() => { });
14713
+ else
14714
+ await this.releaseFile(id, copies);
14715
+ }
14716
+ }
14717
+ /**
14718
+ * What the tree still names with one entry left out: every directory
14719
+ * record, and every file id with a directory that holds it. Null when
14720
+ * part of the tree cannot be read or followed, so the answer is unknown.
14721
+ */
14722
+ async namedElsewhere(dir, name) {
14723
+ try {
14724
+ const named = await this.references({ dir, name });
14725
+ return named.unknown ? null : named;
14726
+ }
14727
+ catch (err) {
14728
+ if (err instanceof VfsCorruptRecordError)
14729
+ return null;
14730
+ throw err;
14731
+ }
14732
+ }
14733
+ /**
14734
+ * Everything reachable from the root: every directory record's id and
14735
+ * key, and every file id with a directory that names it - following the
14736
+ * entries hidden only for their name, which still reference what they
14737
+ * point at. `skip` leaves one entry out: one about to be detached.
14738
+ *
14739
+ * Fetches up to WALK_CONCURRENCY records at once, each record once
14740
+ * however often it is named. Throws VfsCorruptRecordError for a record a
14741
+ * path reaches that cannot be read, unless `lenient`. `unknown` counts
14742
+ * what could not be followed at all: an entry this version cannot read,
14743
+ * and a record behind an unusable name that does not open - which no path
14744
+ * can reach, and so no recursive delete can remove - or, `lenient`, any
14745
+ * record that does not open.
14746
+ */
14747
+ async references(skip, opts = {}) {
14748
+ const root = await this.root();
14749
+ const dirs = new Set([root.id]);
14750
+ const keys = [];
14751
+ const files = new Map();
14752
+ const marked = new Map();
14753
+ let unknown = 0;
14754
+ let sinceTurn = 0;
14755
+ const pending = [];
14756
+ const scan = (dir, path) => {
14757
+ keys.push(dir.key);
14758
+ unknown += unfollowable(dir);
14759
+ for (const [name, entry] of followable(dir)) {
14760
+ if (skip && dir.id === skip.dir && name === skip.name)
14761
+ continue;
14762
+ sinceTurn++;
14763
+ if (entry.moving === true) {
14764
+ let holding = marked.get(entry.id);
14765
+ if (!holding)
14766
+ marked.set(entry.id, (holding = []));
14767
+ holding.push(dir);
14768
+ }
14769
+ if (entry.kind === "file") {
14770
+ if (!files.has(entry.id))
14771
+ files.set(entry.id, dir);
14772
+ }
14773
+ else if (!dirs.has(entry.id)) {
14774
+ dirs.add(entry.id);
14775
+ // No path is ever built from an unusable name.
14776
+ const at = name === null || path === undefined ? undefined : join(path, name);
14777
+ pending.push({ entry, path: at, named: name !== null });
14778
+ }
13546
14779
  }
13547
- else {
13548
- await this.releaseFile(dir, entry);
14780
+ };
14781
+ scan(root, "/");
14782
+ const inFlight = new Set();
14783
+ let failure = null;
14784
+ let next = 0;
14785
+ while (!failure && (next < pending.length || inFlight.size)) {
14786
+ while (next < pending.length && inFlight.size < WALK_CONCURRENCY) {
14787
+ const { entry, path, named } = pending[next++];
14788
+ const task = this.openDirById(entry.id, fromBase64(entry.key), path)
14789
+ .then((child) => scan(child, path), (err) => {
14790
+ // Behind a name no path can reach, an unreadable
14791
+ // record is one more thing whose references are
14792
+ // unknown, and not followed.
14793
+ if ((opts.lenient || !named) && err instanceof VfsCorruptRecordError)
14794
+ unknown++;
14795
+ else
14796
+ failure ??= { err };
14797
+ })
14798
+ .finally(() => inFlight.delete(task));
14799
+ inFlight.add(task);
14800
+ }
14801
+ await Promise.race(inFlight);
14802
+ // Once records are cached nothing here waits: let the rest of the
14803
+ // program run, as a walk does.
14804
+ if (sinceTurn >= YIELD_EVERY) {
14805
+ sinceTurn = 0;
14806
+ await nextTurn();
13549
14807
  }
13550
14808
  }
13551
- this.cache.delete(dir.id);
13552
- await this.deps.store.delete(dirKey(dir.id)).catch(() => { });
14809
+ if (failure)
14810
+ throw failure.err;
14811
+ return { dirs, keys, files, marked, unknown };
13553
14812
  }
13554
14813
  /**
13555
14814
  * Deep-copy an entry. Files reuse their manifest (no bytes move); each
13556
14815
  * directory gets a fresh id and key, so the copy is independent of the
13557
14816
  * original — writing into one must never appear in the other.
14817
+ *
14818
+ * `at` is the entry's SOURCE path, and `depth` how many directories deep
14819
+ * from the root the copy of it will be. With `run.write` false this is the
14820
+ * dry run: every check, no reference taken and no record stored.
13558
14821
  */
13559
- async cloneEntry(entry, seen = new Set()) {
14822
+ async cloneEntry(entry, at, run, ancestors, depth) {
13560
14823
  if (entry.kind === "file") {
13561
14824
  // Same bytes, second owner: take a reference so deleting either one
13562
14825
  // leaves the other readable.
13563
- await this.deps.content.retain(entry.manifest);
14826
+ if (run.write)
14827
+ await this.deps.content.retain(entry.manifest);
13564
14828
  return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
13565
14829
  }
13566
14830
  // The most expensive walker to leave unguarded: it mints a NEW id, key
13567
14831
  // and stored record at every level, so a cyclic tree does not merely
13568
14832
  // hang - it writes records to the store, and to the user's bill, in an
13569
14833
  // unbounded loop.
13570
- if (seen.has(entry.id) || seen.size > MAX_DEPTH) {
13571
- throw new VfsConflictError("VFS: refusing to copy a directory that contains a cycle");
13572
- }
13573
- seen.add(entry.id);
13574
- const source = await this.openDirById(entry.id, fromBase64(entry.key));
14834
+ //
14835
+ // Separate guards, because they catch different shapes. A directory
14836
+ // that is one of its own ANCESTORS is a cycle. Depth is counted per
14837
+ // branch, at the destination, from the root, as for any write - the
14838
+ // old guard compared the total number of directories seen against the
14839
+ // depth limit, and so refused any ordinary tree with more than 64. The budgets, shared by the whole copy, bound a tree
14840
+ // that fans out into the same subtree without ever looping; they are
14841
+ // the walk's ceilings, so a tree too big to walk is not copied into a
14842
+ // second one. And a directory reached in more than MAX_SHARED places
14843
+ // is refused outright, because below the budgets that fan-out still
14844
+ // turns a few records into a vast number of writes. A subtree
14845
+ // reachable twice is copied twice, as it should be.
14846
+ if (hasAncestor(ancestors, entry.id)) {
14847
+ throw new VfsConflictError("refusing to copy a directory that contains a cycle");
14848
+ }
14849
+ if (depth > MAX_DEPTH) {
14850
+ // About the destination, like any write refused for depth: the
14851
+ // source is fine where it is, so it is neither named nor offered
14852
+ // up for deleting.
14853
+ throw new VfsLimitError("depth", MAX_DEPTH, run.to, run.to + at.slice(run.from.length), "writing");
14854
+ }
14855
+ const reached = (run.reached.get(entry.id) ?? 0) + 1;
14856
+ if (reached > MAX_SHARED)
14857
+ throw new VfsLimitError("shared", MAX_SHARED, run.from, at, "copying");
14858
+ run.reached.set(entry.id, reached);
14859
+ const source = await this.openDirById(entry.id, fromBase64(entry.key), at);
14860
+ // Only visible entries: an unusable name or an unreadable entry is not
14861
+ // carried into a fresh record. `readRecord` has set both aside already.
14862
+ const children = Object.entries(source.node.entries);
14863
+ run.entries -= children.length;
14864
+ if (run.entries < 0)
14865
+ throw new VfsLimitError("entries", this.maxEntries, run.from, at, "copying");
14866
+ for (const [name] of children)
14867
+ run.pathChars -= childPathLength(at, name);
14868
+ if (run.pathChars < 0)
14869
+ throw new VfsLimitError("pathChars", this.maxPathChars, run.from, at, "copying");
14870
+ // The dry run, with every record cached, waits on nothing - like a
14871
+ // walk, it lets the rest of the program run every so often.
14872
+ run.sinceTurn += children.length;
14873
+ if (run.sinceTurn >= YIELD_EVERY) {
14874
+ run.sinceTurn = 0;
14875
+ await nextTurn();
14876
+ }
14877
+ const inner = { id: entry.id, up: ancestors };
13575
14878
  const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
13576
- const node = { v: 1, entries: {}, mtime: Date.now() };
13577
- for (const [name, child] of Object.entries(source.node.entries)) {
13578
- // Names are validated on read everywhere else; a copy must not be
13579
- // the way a bad one is carried into a fresh record.
13580
- if (!isSafeName(name)) {
13581
- this.warn(`skipping entry with an unusable name while copying`);
13582
- continue;
13583
- }
13584
- node.entries[name] = await this.cloneEntry(child, seen);
14879
+ const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
14880
+ for (const [name, child] of children) {
14881
+ node.entries[name] = await this.cloneEntry(child, join(at, name), run, inner, depth + 1);
13585
14882
  }
13586
- const open = { id: clone.id, key: fromBase64(clone.key), node };
14883
+ if (!run.write)
14884
+ return clone;
14885
+ const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null) };
13587
14886
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
13588
14887
  this.cache.set(open.id, Promise.resolve(open));
13589
14888
  return clone;
13590
14889
  }
13591
14890
  /**
13592
- * Let go of a file's bytes: the current manifest and every retained
13593
- * version, then the history record itself.
14891
+ * Let go of one file's bytes: its content, every version its history
14892
+ * retains, and the history record itself.
14893
+ *
14894
+ * Each distinct manifest once. `copies` is every entry naming the file -
14895
+ * two after an interrupted move, which may by now hold different
14896
+ * versions - and `restore` puts a version back without taking a second
14897
+ * reference, so its manifest is in the entry and in the history both.
14898
+ * Releasing per mention released one reference twice, and so freed bytes
14899
+ * a copy elsewhere, holding its own reference, still needed.
13594
14900
  */
13595
- async releaseFile(parent, entry) {
13596
- const record = await this.readHistory(parent, entry.id).catch(() => ({ v: 1, versions: [] }));
13597
- const manifests = [entry.manifest, ...record.versions.map((v) => v.manifest)];
13598
- for (const manifest of manifests)
14901
+ async releaseFile(id, copies) {
14902
+ const record = await this.openHistory(id, copies.map(({ dir }) => dir.key)).catch(() => null);
14903
+ const manifests = new Map();
14904
+ const versions = readableHistory(record?.value).record.versions;
14905
+ for (const manifest of [...copies.map(({ entry }) => entry.manifest), ...versions.map((version) => version.manifest)]) {
14906
+ if (!manifests.has(manifest.id))
14907
+ manifests.set(manifest.id, manifest);
14908
+ }
14909
+ for (const manifest of manifests.values())
13599
14910
  await this.deps.content.release(manifest).catch(() => { });
13600
- await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
14911
+ await this.deps.store.delete(historyKey(id)).catch(() => { });
13601
14912
  }
13602
- async readHistory(parent, fileId) {
13603
- const raw = await this.deps.store.get(historyKey(fileId));
13604
- return (await unseal(parent.key, raw)) ?? { v: 1, versions: [] };
14913
+ /**
14914
+ * A file's history record, opened with the first of `keys` that opens
14915
+ * it, and that key. Null when there is none, or none of them does.
14916
+ */
14917
+ async openHistory(id, keys) {
14918
+ const raw = await this.deps.store.get(historyKey(id));
14919
+ if (raw === null || raw === undefined)
14920
+ return null;
14921
+ const opened = await openWithAny(raw, keys);
14922
+ return "problem" in opened ? null : opened;
13605
14923
  }
13606
- /** Push `entry` onto its history record. Returns the new version count. */
13607
- async pushHistory(parent, fileId, entry) {
13608
- const record = await this.readHistory(parent, fileId);
14924
+ /**
14925
+ * Seal a file's history with the key of the directory that holds it now.
14926
+ *
14927
+ * A history record is sealed with its directory's key, so a file moved
14928
+ * to another directory carried a history its new directory could not
14929
+ * open: every read of it, and every write to the file, failed from then
14930
+ * on. Nothing to do when there is no history, when it opens with `to`
14931
+ * already, or when none of the keys opens it.
14932
+ */
14933
+ async rekeyHistory(id, from, to) {
14934
+ const opened = await this.openHistory(id, [to, ...from]);
14935
+ if (!opened || opened.key === to)
14936
+ return;
14937
+ await this.deps.store.put(historyKey(id), await seal(to, readableHistory(opened.value).record));
14938
+ }
14939
+ /**
14940
+ * The history of the file `entry` names as `name` in `parent`, and the
14941
+ * key to seal it with from now on.
14942
+ *
14943
+ * A history is sealed with its directory's key - usually. The two halves
14944
+ * of an interrupted move share one history, sealed with ONE of their
14945
+ * directories' keys, and a file moved between directories by a version
14946
+ * before `rename` re-sealed it still has its old directory's. So when the
14947
+ * directory's own key does not open it, every key in the tree is tried,
14948
+ * the other half's first: a read of the whole tree, and only then. It
14949
+ * need not be read in full, or understood - an entry this version cannot
14950
+ * follow may hide a key, but a key that opens the record is proof
14951
+ * enough. Found, it is re-sealed with this directory's key, so the search
14952
+ * is made once - except for half of a move, which keeps the key that
14953
+ * opened it and so keeps working for both halves.
14954
+ *
14955
+ * No record, or a value that is not a sealed record at all, is an empty
14956
+ * history, as it always was. One that no key opens is thrown on, with a
14957
+ * message rather than a bare decryption error: writing an empty history
14958
+ * over it would lose every version it holds.
14959
+ */
14960
+ async readHistory(parent, name, entry, path) {
14961
+ const raw = await this.deps.store.get(historyKey(entry.id));
14962
+ if (!isObject$1(raw) || typeof raw.iv !== "string" || typeof raw.ct !== "string") {
14963
+ return { record: { v: 1, versions: [] }, key: parent.key };
14964
+ }
14965
+ let opened = await openWithAny(raw, [parent.key]);
14966
+ let found = false;
14967
+ if ("problem" in opened) {
14968
+ const named = await this.references({ dir: parent.id, name }, { lenient: true });
14969
+ const other = named.files.get(entry.id)?.key;
14970
+ const keys = named.keys.filter((key) => key !== parent.key && key !== other);
14971
+ const retry = await openWithAny(raw, other ? [other, ...keys] : keys);
14972
+ if ("problem" in retry) {
14973
+ throw new VfsConflictError(`the version history of ${path} cannot be read: ${opened.problem}. ` +
14974
+ "Nothing has been changed; a copy of the file starts a history of its own");
14975
+ }
14976
+ opened = retry;
14977
+ found = true;
14978
+ }
14979
+ const { record, dropped } = readableHistory(opened.value);
14980
+ if (dropped)
14981
+ this.warn(`ignoring ${dropped} version${dropped === 1 ? "" : "s"} of ${path} this version cannot read`);
14982
+ if (!found || entry.moving === true)
14983
+ return { record, key: opened.key };
14984
+ // Best-effort: failing, the next read searches again.
14985
+ await this.deps.store.put(historyKey(entry.id), await seal(parent.key, record)).catch(() => { });
14986
+ return { record, key: parent.key };
14987
+ }
14988
+ /**
14989
+ * Push `entry` onto its history record. Returns the new version count.
14990
+ * `next` is the manifest the entry will hold once the caller has written
14991
+ * it.
14992
+ */
14993
+ async pushHistory(parent, name, entry, path, next) {
14994
+ const { record, key } = await this.readHistory(parent, name, entry, path);
13609
14995
  record.versions.unshift({ manifest: entry.manifest, size: entry.size, mtime: entry.mtime });
13610
14996
  // Bounded, because this is a single stored value. Unchanged chunks are
13611
14997
  // deduped by the content-addressed shard store, so the cost of keeping
@@ -13614,9 +15000,26 @@ class VfsNamespace {
13614
15000
  record.versions = record.versions.slice(0, this.historyLimit);
13615
15001
  // A version that falls off the end is unreachable — release its shards
13616
15002
  // or the cap bounds the record's size while the storage grows forever.
13617
- for (const v of evicted)
13618
- await this.deps.content.release(v.manifest).catch(() => { });
13619
- await this.deps.store.put(historyKey(fileId), await seal(parent.key, record));
15003
+ //
15004
+ // Unless something still names it. `restore` puts a version back
15005
+ // without a reference of its own, so a file holds ONE reference per
15006
+ // distinct manifest however often its entry and history name it - see
15007
+ // `releaseFile`. Releasing per mention freed the version a restore
15008
+ // was bringing back, when it was the one pushed off the end, and a
15009
+ // version the history still listed, when an older mention of it went.
15010
+ // Half of an interrupted move lets go of nothing here: the other half
15011
+ // shares this history, its entry may hold any of these, and finding it
15012
+ // means reading the whole tree. A leak is the safe way to be wrong.
15013
+ if (entry.moving !== true) {
15014
+ const named = new Set([next.id, ...record.versions.map((version) => version.manifest.id)]);
15015
+ for (const { manifest } of evicted) {
15016
+ if (named.has(manifest.id))
15017
+ continue;
15018
+ named.add(manifest.id); // and each once
15019
+ await this.deps.content.release(manifest).catch(() => { });
15020
+ }
15021
+ }
15022
+ await this.deps.store.put(historyKey(entry.id), await seal(key, record));
13620
15023
  return record.versions.length;
13621
15024
  }
13622
15025
  }
@@ -13625,9 +15028,9 @@ function toStat(path, name, entry) {
13625
15028
  path,
13626
15029
  name,
13627
15030
  kind: entry.kind,
13628
- size: entry.kind === "file" ? entry.size : 0,
13629
- mtime: entry.mtime,
13630
- versions: entry.kind === "file" ? (entry.versions ?? 0) : 0,
15031
+ size: entry.kind === "file" ? asCount(entry.size) : 0,
15032
+ mtime: asTime(entry.mtime),
15033
+ versions: entry.kind === "file" ? asCount(entry.versions) : 0,
13631
15034
  };
13632
15035
  }
13633
15036
  /** Directories first, then alphabetical — what every file explorer does. */
@@ -13818,6 +15221,66 @@ class VcsError extends Error {
13818
15221
  }
13819
15222
  }
13820
15223
 
15224
+ /** What a hash looks like: SHA-256, lowercase hex. Anything else is not an address. */
15225
+ const HASH = /^[0-9a-f]{64}$/;
15226
+ /**
15227
+ * What a branch name looks like - checked when a branch is created, and on
15228
+ * every read of one. HEAD, the branch list and MERGE are not hashed, so
15229
+ * anything holding the seed can put any name in them, and the names are
15230
+ * printed by everything that shows a repository's state.
15231
+ *
15232
+ * At most 200 characters, because a name is also a store key -
15233
+ * `vcs/<slug>/refs/<name>` - and the personal space answers a key past 2048
15234
+ * bytes with a server error: a planted HEAD naming a longer branch turned
15235
+ * every status, log and commit into a bare HttpError.
15236
+ */
15237
+ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/;
15238
+ const isHash = (value) => typeof value === "string" && HASH.test(value);
15239
+ const isBranch = (value) => typeof value === "string" && BRANCH.test(value);
15240
+ /** A JSON object, as opposed to null, an array or a scalar. */
15241
+ function isObject(value) {
15242
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15243
+ }
15244
+ /**
15245
+ * Why a value is not a commit this version can read, or null when it is.
15246
+ *
15247
+ * A commit can be written by anything holding the seed and still match its
15248
+ * hash, so matching proves nothing about its shape. Every reader takes these
15249
+ * fields for granted: parents are walked as an array of addresses, `at` is
15250
+ * sorted on, and message and author are handed to whatever prints them.
15251
+ */
15252
+ function commitProblem(value) {
15253
+ if (!isObject(value) || value.v !== 1)
15254
+ return "it is not a commit this version can read";
15255
+ if (!isHash(value.tree))
15256
+ return "its tree is not an object hash";
15257
+ if (!Array.isArray(value.parents) || !value.parents.every(isHash)) {
15258
+ return "its parents are not a list of object hashes";
15259
+ }
15260
+ if (typeof value.message !== "string" || typeof value.author !== "string")
15261
+ return "its message or author is not text";
15262
+ // A time a Date can hold: `vcs log` prints it with toISOString, which
15263
+ // throws on 1e300, and commits cannot be changed.
15264
+ if (typeof value.at !== "number" || !(Math.abs(value.at) <= MAX_TIME))
15265
+ return "its time is not a time";
15266
+ return null;
15267
+ }
15268
+ /**
15269
+ * Is this a tree entry the walkers can follow?
15270
+ *
15271
+ * Tree objects can be written by anything holding the seed, so a hash check
15272
+ * proves only that the object is what its address says - not that it is a
15273
+ * sensible tree. An unknown kind, a file with no manifest to read or compare,
15274
+ * or a subtree "hash" that is not a hash is skipped; a skipped entry is the same
15275
+ * outcome as someone deleting it.
15276
+ */
15277
+ function readableTreeEntry(value) {
15278
+ if (!isObject(value))
15279
+ return false;
15280
+ if (value.kind === "tree")
15281
+ return typeof value.hash === "string" && HASH.test(value.hash);
15282
+ return value.kind === "file" && isObject(value.manifest) && typeof value.manifest.id === "string";
15283
+ }
13821
15284
  /** A project's repository. One per `/apps/<slug>`. */
13822
15285
  /**
13823
15286
  * Types never worth attempting a three-way merge on.
@@ -13845,27 +15308,36 @@ function looksTextual(bytes) {
13845
15308
  return false;
13846
15309
  }
13847
15310
  }
13848
- /** Compare two path → entry maps into a change list. */
13849
- function comparePaths(before, after) {
13850
- const changes = [];
13851
- for (const [path, entry] of after) {
13852
- const was = before.get(path);
13853
- if (!was)
13854
- changes.push({ path, kind: "added" });
13855
- else if (was.manifest.id !== entry.manifest.id)
13856
- changes.push({ path, kind: "modified" });
13857
- }
13858
- for (const path of before.keys())
13859
- if (!after.has(path))
13860
- changes.push({ path, kind: "removed" });
13861
- return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
13862
- }
15311
+ /** Changes in path order, as `status` and `diff` return them. */
15312
+ const byPath = (a, b) => (a.path < b.path ? -1 : 1);
13863
15313
  class Repo {
13864
15314
  slug;
13865
15315
  deps;
15316
+ maxEntries;
15317
+ maxPathChars;
15318
+ /**
15319
+ * How many directories deep below the project a tree may nest. The
15320
+ * working tree may be written MAX_DEPTH directories below the root, and
15321
+ * the project itself is some of those: a deeper tree would check out into
15322
+ * something no walk will read, and `status` - and every checkout after,
15323
+ * which walks the tree first - would throw from then on.
15324
+ */
15325
+ maxTreeDepth;
13866
15326
  constructor(slug, deps) {
13867
15327
  this.slug = slug;
13868
15328
  this.deps = deps;
15329
+ // Exactly one usable name. The working tree is `/apps/<slug>` and the
15330
+ // repository key is derived from the slug, so `demo/` was a second
15331
+ // repository over the same files - and, because commit() filed the
15332
+ // walk's output under normalised paths and looked it up under the raw
15333
+ // one, a repository whose commits were EMPTY trees. Checking one of
15334
+ // those out deleted the project. Rejected rather than normalised: the
15335
+ // SDK should not guess which project was meant.
15336
+ if (!isSafeName(slug))
15337
+ throw new VcsError(`"${printable(slug, 64)}" is not a project name`);
15338
+ this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
15339
+ this.maxPathChars = deps.limits?.pathChars ?? DEFAULT_MAX_PATH_CHARS;
15340
+ this.maxTreeDepth = MAX_DEPTH - segments(this.root).length;
13869
15341
  }
13870
15342
  get root() {
13871
15343
  return `/apps/${this.slug}`;
@@ -13877,45 +15349,248 @@ class Repo {
13877
15349
  // Content-addressed, so re-writing an identical object is a no-op —
13878
15350
  // worth skipping because an unchanged directory is written on every
13879
15351
  // commit that touches any of its siblings.
15352
+ //
15353
+ // Skipped only when what is there really IS this object. The next
15354
+ // tree's hash is predictable to anyone who can read the project, so a
15355
+ // forgery can be planted at it in advance; trusting mere presence would
15356
+ // make that tree unwritable for good, every later commit of the same
15357
+ // content pointing at the forgery.
13880
15358
  const existing = await this.deps.store.get(objectKey(this.slug, hash));
13881
- if (existing)
15359
+ if (existing && (await this.holds(existing, hash)))
13882
15360
  return { hash, created: false };
13883
15361
  await this.deps.store.put(objectKey(this.slug, hash), await seal(await this.deps.key(), value));
13884
15362
  return { hash, created: true };
13885
15363
  }
15364
+ /** Does this stored record open to an object with this hash? */
15365
+ async holds(raw, hash) {
15366
+ try {
15367
+ const value = await unseal(await this.deps.key(), raw);
15368
+ return value !== null && (await hashObject(value)) === hash;
15369
+ }
15370
+ catch {
15371
+ return false;
15372
+ }
15373
+ }
15374
+ /**
15375
+ * Fetch an object, and prove it is the one that was asked for.
15376
+ *
15377
+ * Content addressing is only a guarantee if the address is checked. Without
15378
+ * this a stored object could claim any hash - including its own, so a tree
15379
+ * could contain itself and `status` would follow it for ever. Every object
15380
+ * this repository has ever written verifies: `put` hashes exactly the value
15381
+ * it seals, `canonical` is independent of key order and drops `undefined`
15382
+ * exactly as JSON does, and hashing has never been versioned.
15383
+ */
13886
15384
  async get(hash) {
15385
+ // Checked before it becomes a storage key: it may have come out of a
15386
+ // tree or commit that something else wrote.
15387
+ if (typeof hash !== "string" || !HASH.test(hash)) {
15388
+ throw new VcsError("a reference in this repository is not an object hash");
15389
+ }
15390
+ const short = hash.slice(0, 8);
13887
15391
  const raw = await this.deps.store.get(objectKey(this.slug, hash));
13888
- const value = raw ? await unseal(await this.deps.key(), raw) : null;
13889
- if (!value)
13890
- throw new VcsError(`object ${hash.slice(0, 8)} is missing from this repository`);
15392
+ if (raw === null || raw === undefined)
15393
+ throw new VcsError(`object ${short} is missing from this repository`);
15394
+ // Opened the way the working tree opens a directory record: a value
15395
+ // that is not an envelope, does not decrypt, or is not JSON is named,
15396
+ // rather than surfacing as a bare DOMException or SyntaxError.
15397
+ let value;
15398
+ try {
15399
+ value = await openSealed(await this.deps.key(), raw);
15400
+ }
15401
+ catch (err) {
15402
+ if (err instanceof SealedRecordError)
15403
+ throw new VcsError(`object ${short} cannot be read: ${err.message}`);
15404
+ throw err;
15405
+ }
15406
+ if (value === null)
15407
+ throw new VcsError(`object ${short} is missing from this repository`);
15408
+ let actual;
15409
+ try {
15410
+ actual = await hashObject(value);
15411
+ }
15412
+ catch (err) {
15413
+ // JSON.parse takes nesting that hashing's recursion cannot.
15414
+ if (err instanceof RangeError)
15415
+ throw new VcsError(`object ${short} cannot be read: it is nested too deeply`);
15416
+ throw err;
15417
+ }
15418
+ if (actual !== hash) {
15419
+ throw new VcsError(`object ${short} does not match its hash: this repository did not write it, or it is corrupt`);
15420
+ }
13891
15421
  return value;
13892
15422
  }
15423
+ /** A commit, checked to be one. See `commitProblem`. */
15424
+ async readCommit(hash) {
15425
+ const value = await this.get(hash);
15426
+ const problem = commitProblem(value);
15427
+ if (problem)
15428
+ throw new VcsError(`object ${hash.slice(0, 8)} is not a commit this version can read: ${problem}`);
15429
+ return value;
15430
+ }
15431
+ /**
15432
+ * Start a traversal of history: a budget shared by every branch of it, and
15433
+ * a cache of the tree objects it has fetched.
15434
+ *
15435
+ * The cache matters for the same reason the budget does. Identical
15436
+ * subtrees are one object, so a tree of honest-but-hostile shape is a
15437
+ * handful of fetches and a vast number of paths; caching keeps it to the
15438
+ * handful, and the budget stops the paths. It caches OBJECTS, never output
15439
+ * - each name a shared subtree appears at is still listed.
15440
+ */
15441
+ treeWalk() {
15442
+ const fetched = new Map();
15443
+ let budget = this.maxEntries;
15444
+ let chars = this.maxPathChars;
15445
+ const refuse = (what) => new VcsError(`stopped reading history: ${what}, which no real project does - ` +
15446
+ "the repository may have been tampered with");
15447
+ return {
15448
+ tree: (hash) => {
15449
+ let hit = fetched.get(hash);
15450
+ if (!hit)
15451
+ fetched.set(hash, (hit = this.readTree(hash)));
15452
+ return hit;
15453
+ },
15454
+ spend: (prefix, names) => {
15455
+ const turn = Math.floor(budget / YIELD_EVERY);
15456
+ for (const name of names) {
15457
+ budget -= 1;
15458
+ chars -= prefix.length + 1 + name.length;
15459
+ }
15460
+ if (budget < 0)
15461
+ throw refuse(`it holds more than ${this.maxEntries} entries`);
15462
+ if (chars < 0)
15463
+ throw refuse(`its paths add up to more than ${this.maxPathChars} characters`);
15464
+ return Math.floor(budget / YIELD_EVERY) !== turn;
15465
+ },
15466
+ descend: (depth) => {
15467
+ if (depth > this.maxTreeDepth)
15468
+ throw refuse(`it is nested more than ${this.maxTreeDepth} directories deep`);
15469
+ },
15470
+ };
15471
+ }
15472
+ /**
15473
+ * A tree object's entries, checked and ready to walk.
15474
+ *
15475
+ * Returned in a null-prototype map, so a name like `constructor` is looked
15476
+ * up as an entry and not found as an inherited property.
15477
+ */
15478
+ async readTree(hash) {
15479
+ const tree = await this.get(hash);
15480
+ if (!isObject(tree) || tree.v !== 1 || !isObject(tree.entries)) {
15481
+ throw new VcsError(`object ${hash.slice(0, 8)} is not a tree this version can read`);
15482
+ }
15483
+ const entries = Object.create(null);
15484
+ let skipped = 0;
15485
+ for (const [name, entry] of Object.entries(tree.entries)) {
15486
+ // The same name rule the working tree uses. A name is about to
15487
+ // become a path - `../x` would be written outside the project on
15488
+ // checkout - and the hash check says nothing about whether it is safe.
15489
+ if (isSafeName(name) && readableTreeEntry(entry))
15490
+ entries[name] = entry;
15491
+ else
15492
+ skipped++;
15493
+ }
15494
+ if (skipped) {
15495
+ const what = `${skipped} unreadable entr${skipped === 1 ? "y" : "ies"}`;
15496
+ console.warn(`[muhkoo/vcs] ignoring ${what} in tree ${hash.slice(0, 8)}`);
15497
+ }
15498
+ return entries;
15499
+ }
13893
15500
  // ---- refs ---------------------------------------------------------------
15501
+ /**
15502
+ * A record that is not an object - HEAD, a branch ref, the branch list,
15503
+ * MERGE - opened. Null when there is none; `problem` when there is one
15504
+ * that does not open. What is inside is the caller's to check.
15505
+ */
15506
+ async openRecord(key) {
15507
+ const raw = await this.deps.store.get(key);
15508
+ if (raw === null || raw === undefined)
15509
+ return null;
15510
+ try {
15511
+ return { value: await openSealed(await this.deps.key(), raw) };
15512
+ }
15513
+ catch (err) {
15514
+ if (err instanceof SealedRecordError)
15515
+ return { problem: err.message };
15516
+ throw err;
15517
+ }
15518
+ }
13894
15519
  async head() {
13895
- const raw = await this.deps.store.get(headKey(this.slug));
13896
- const value = raw ? await unseal(await this.deps.key(), raw) : null;
13897
- return value ?? { branch: DEFAULT_BRANCH };
15520
+ const record = await this.openRecord(headKey(this.slug));
15521
+ if (!record)
15522
+ return { branch: DEFAULT_BRANCH };
15523
+ if ("value" in record) {
15524
+ const { value } = record;
15525
+ if (isObject(value) && !Object.hasOwn(value, "detached") && isBranch(value.branch)) {
15526
+ return { branch: value.branch };
15527
+ }
15528
+ if (isObject(value) && !Object.hasOwn(value, "branch") && isHash(value.detached)) {
15529
+ return { detached: value.detached };
15530
+ }
15531
+ }
15532
+ // Checking out a branch or a commit with discardChanges writes a new
15533
+ // HEAD without reading this one.
15534
+ throw new VcsError(`HEAD cannot be read${"problem" in record ? `: ${record.problem}` : ""}; ` +
15535
+ "check out a branch or a commit, discarding changes, to replace it");
13898
15536
  }
13899
15537
  async setHead(head) {
13900
15538
  await this.deps.store.put(headKey(this.slug), await seal(await this.deps.key(), head));
13901
15539
  }
13902
15540
  async readRef(name) {
13903
- const raw = await this.deps.store.get(refKey(this.slug, name));
13904
- return raw ? await unseal(await this.deps.key(), raw).then((v) => v?.commit ?? null) : null;
15541
+ const ref = await this.refState(name);
15542
+ if (ref !== "unreadable")
15543
+ return ref;
15544
+ throw new VcsError(`branch "${printable(name, 64)}" cannot be read: it does not name a commit; ` +
15545
+ "create the branch again, at a commit, to replace it");
15546
+ }
15547
+ /** A branch's commit, null when there is no such branch, or "unreadable". */
15548
+ async refState(name) {
15549
+ const record = await this.openRecord(refKey(this.slug, name));
15550
+ if (!record)
15551
+ return null;
15552
+ if ("value" in record && isObject(record.value) && isHash(record.value.commit))
15553
+ return record.value.commit;
15554
+ return "unreadable";
13905
15555
  }
13906
15556
  async writeRef(name, commit) {
13907
15557
  await this.deps.store.put(refKey(this.slug, name), await seal(await this.deps.key(), { commit }));
13908
15558
  // The personal space has no prefix listing, so branches are tracked in
13909
15559
  // an index. Kept append-only-ish: losing a name here loses the branch.
13910
- const names = new Set(await this.branches());
15560
+ // An index that cannot be read at all is replaced rather than left to
15561
+ // block every branch from now on; its names were lost already.
15562
+ const index = await this.readIndex();
15563
+ if (index === "unreadable")
15564
+ console.warn("[muhkoo/vcs] replacing a branch list this version cannot read");
15565
+ const names = new Set(index === "unreadable" ? [] : index);
13911
15566
  names.add(name);
13912
15567
  await this.deps.store.put(refsIndexKey(this.slug), await seal(await this.deps.key(), { names: [...names].sort() }));
13913
15568
  }
13914
15569
  /** Every branch name in this repository. */
13915
15570
  async branches() {
13916
- const raw = await this.deps.store.get(refsIndexKey(this.slug));
13917
- const value = raw ? await unseal(await this.deps.key(), raw) : null;
13918
- return value?.names ?? [];
15571
+ const index = await this.readIndex();
15572
+ if (index === "unreadable") {
15573
+ throw new VcsError("the list of branches cannot be read; creating a branch replaces it");
15574
+ }
15575
+ return index;
15576
+ }
15577
+ /**
15578
+ * The branch list, less any name a branch could not have been created
15579
+ * with - left out with a warning, like an unreadable entry in a tree, so
15580
+ * one planted name does not hide every real one.
15581
+ */
15582
+ async readIndex() {
15583
+ const record = await this.openRecord(refsIndexKey(this.slug));
15584
+ if (!record)
15585
+ return [];
15586
+ if (!("value" in record) || !isObject(record.value) || !Array.isArray(record.value.names))
15587
+ return "unreadable";
15588
+ const names = record.value.names.filter(isBranch);
15589
+ const skipped = record.value.names.length - names.length;
15590
+ if (skipped) {
15591
+ console.warn(`[muhkoo/vcs] ignoring ${skipped} branch name${skipped === 1 ? "" : "s"} this version would not accept`);
15592
+ }
15593
+ return names;
13919
15594
  }
13920
15595
  /** The commit HEAD resolves to, or null in an empty repository. */
13921
15596
  async current() {
@@ -13942,7 +15617,7 @@ class Repo {
13942
15617
  const pending = parents ? null : await this.pendingMerge();
13943
15618
  const resolved = parents ?? (pending ? [parent, pending.theirs] : parent ? [parent] : []);
13944
15619
  if (!parents && !pending && parent) {
13945
- const previous = await this.get(parent);
15620
+ const previous = await this.readCommit(parent);
13946
15621
  // An empty commit is almost always a mistake — the user believes
13947
15622
  // they saved something they did not.
13948
15623
  if (previous.tree === tree)
@@ -13972,38 +15647,80 @@ class Repo {
13972
15647
  }
13973
15648
  /** The other side of a merge that stopped for conflicts, if any. */
13974
15649
  async pendingMerge() {
13975
- const raw = await this.deps.store.get(mergeKey(this.slug));
13976
- return raw ? await unseal(await this.deps.key(), raw) : null;
15650
+ const record = await this.openRecord(mergeKey(this.slug));
15651
+ if (!record)
15652
+ return null;
15653
+ if ("value" in record && isObject(record.value) && isHash(record.value.theirs) && isBranch(record.value.name)) {
15654
+ return { theirs: record.value.theirs, name: record.value.name };
15655
+ }
15656
+ throw new VcsError(`the merge in progress cannot be read${"problem" in record ? `: ${record.problem}` : ""}; ` +
15657
+ "abort the merge to clear it");
15658
+ }
15659
+ /**
15660
+ * Give up on a merge that stopped for conflicts: the next commit is an
15661
+ * ordinary one, with one parent. The working tree is left as it is,
15662
+ * conflict markers and all. To put it back, switch to the current branch
15663
+ * with `discardChanges` — not `checkout`, which always leaves HEAD
15664
+ * detached, so later commits would stop moving the branch.
15665
+ *
15666
+ * Also the way out when the record of the merge cannot be read, which
15667
+ * would otherwise stop every commit.
15668
+ */
15669
+ async abortMerge() {
15670
+ await this.deps.store.delete(mergeKey(this.slug));
13977
15671
  }
13978
- /** Write a directory and everything under it, returning the tree's hash. */
15672
+ /**
15673
+ * Write a directory and everything under it, returning the tree's hash.
15674
+ *
15675
+ * Reads the working tree through ONE `walkEntries`, rather than listing
15676
+ * each directory by path and recursing. The recursion had no guard at all:
15677
+ * a directory record that names itself - which anything holding the seed
15678
+ * can plant - sent `commit` down /loop/loop/loop/... for ever. The walk has
15679
+ * the cycle guard, the budget and the depth ceiling, so a commit records
15680
+ * exactly the tree `status` compares it against.
15681
+ */
13979
15682
  async writeTree(path) {
13980
- const entries = {};
13981
- for (const entry of await this.deps.vfs.list(path)) {
13982
- if (entry.kind === "dir") {
13983
- entries[entry.name] = { kind: "tree", hash: await this.writeTree(entry.path) };
13984
- }
13985
- else {
13986
- const stat = await this.deps.vfs.statManifest(entry.path);
13987
- entries[entry.name] = { kind: "file", manifest: stat.manifest, size: entry.size };
15683
+ // Each file's manifest comes with the walk. Looking every file up
15684
+ // again by path, from the root down, cost a planted deep tree 30s of
15685
+ // CPU per commit on top of a walk of under a second.
15686
+ const byParent = new Map();
15687
+ await this.deps.vfs.eachEntry(path, (stat, manifest) => {
15688
+ const parent = dirname(stat.path);
15689
+ let siblings = byParent.get(parent);
15690
+ if (!siblings)
15691
+ byParent.set(parent, (siblings = []));
15692
+ siblings.push({ stat, manifest });
15693
+ });
15694
+ const write = async (dir) => {
15695
+ // No prototype: a file named `__proto__` would otherwise set the
15696
+ // map's prototype instead of becoming an entry, and be left out of
15697
+ // the commit without a word.
15698
+ const entries = Object.create(null);
15699
+ for (const { stat, manifest } of byParent.get(dir) ?? []) {
15700
+ if (!manifest)
15701
+ entries[stat.name] = { kind: "tree", hash: await write(stat.path) };
15702
+ else
15703
+ entries[stat.name] = { kind: "file", manifest, size: stat.size };
13988
15704
  }
13989
- }
13990
- const { hash, created } = await this.put({ v: 1, entries });
13991
- // A NEW tree means this directory's contents are newly recorded, so the
13992
- // repository takes its own reference to their content. Without it, a
13993
- // later working-tree delete frees shards the commit still needs and the
13994
- // history becomes unreadable.
13995
- //
13996
- // Retaining per new tree rather than per changed FILE slightly
13997
- // over-retains — an unchanged file in a directory whose sibling changed
13998
- // gets a second reference. That errs towards keeping content, which is
13999
- // the correct direction to err in.
14000
- if (created) {
14001
- for (const entry of Object.values(entries)) {
14002
- if (entry.kind === "file")
14003
- await this.deps.vfs.retainManifest(entry.manifest);
15705
+ const { hash, created } = await this.put({ v: 1, entries });
15706
+ // A NEW tree means this directory's contents are newly recorded, so the
15707
+ // repository takes its own reference to their content. Without it, a
15708
+ // later working-tree delete frees shards the commit still needs and the
15709
+ // history becomes unreadable.
15710
+ //
15711
+ // Retaining per new tree rather than per changed FILE slightly
15712
+ // over-retains — an unchanged file in a directory whose sibling changed
15713
+ // gets a second reference. That errs towards keeping content, which is
15714
+ // the correct direction to err in.
15715
+ if (created) {
15716
+ for (const entry of Object.values(entries)) {
15717
+ if (entry.kind === "file")
15718
+ await this.deps.vfs.retainManifest(entry.manifest);
15719
+ }
14004
15720
  }
14005
- }
14006
- return hash;
15721
+ return hash;
15722
+ };
15723
+ return write(path);
14007
15724
  }
14008
15725
  // ---- history ------------------------------------------------------------
14009
15726
  /** Commits reachable from HEAD, newest first. */
@@ -14021,7 +15738,7 @@ class Repo {
14021
15738
  if (seen.has(hash))
14022
15739
  continue;
14023
15740
  seen.add(hash);
14024
- const commit = await this.get(hash);
15741
+ const commit = await this.readCommit(hash);
14025
15742
  out.push({
14026
15743
  hash,
14027
15744
  message: commit.message,
@@ -14042,28 +15759,34 @@ class Repo {
14042
15759
  * content-addressing the trees.
14043
15760
  */
14044
15761
  async diff(fromHash, toHash) {
14045
- const from = fromHash ? (await this.get(fromHash)).tree : null;
14046
- const to = (await this.get(toHash)).tree;
15762
+ const from = fromHash ? (await this.readCommit(fromHash)).tree : null;
15763
+ const to = (await this.readCommit(toHash)).tree;
14047
15764
  const changes = [];
14048
- await this.diffTrees(from, to, "", changes);
14049
- return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
15765
+ await this.diffTrees(from, to, "", changes, this.treeWalk(), 0);
15766
+ return changes.sort(byPath);
14050
15767
  }
14051
- async diffTrees(fromHash, toHash, prefix, out) {
15768
+ async diffTrees(fromHash, toHash, prefix, out, walk, depth) {
14052
15769
  if (fromHash === toHash)
14053
15770
  return; // identical subtrees: nothing below can differ
14054
- const from = fromHash ? await this.get(fromHash) : { entries: {} };
14055
- const to = toHash ? await this.get(toHash) : { entries: {} };
14056
- for (const name of new Set([...Object.keys(from.entries), ...Object.keys(to.entries)])) {
14057
- const a = from.entries[name];
14058
- const b = to.entries[name];
15771
+ if (depth)
15772
+ walk.descend(depth);
15773
+ const none = Object.create(null);
15774
+ const from = fromHash ? await walk.tree(fromHash) : none;
15775
+ const to = toHash ? await walk.tree(toHash) : none;
15776
+ const names = new Set([...Object.keys(from), ...Object.keys(to)]);
15777
+ if (walk.spend(prefix, names))
15778
+ await nextTurn();
15779
+ for (const name of names) {
15780
+ const a = from[name];
15781
+ const b = to[name];
14059
15782
  const path = `${prefix}/${name}`;
14060
15783
  if (a && b && a.kind === "tree" && b.kind === "tree") {
14061
- await this.diffTrees(a.hash, b.hash, path, out);
15784
+ await this.diffTrees(a.hash, b.hash, path, out, walk, depth + 1);
14062
15785
  }
14063
15786
  else if (a?.kind === "tree" || b?.kind === "tree") {
14064
15787
  // A directory replaced by a file (or the reverse) — recurse both
14065
15788
  // sides so every affected path is reported, not just the name.
14066
- await this.diffTrees(a?.kind === "tree" ? a.hash : null, b?.kind === "tree" ? b.hash : null, path, out);
15789
+ await this.diffTrees(a?.kind === "tree" ? a.hash : null, b?.kind === "tree" ? b.hash : null, path, out, walk, depth + 1);
14067
15790
  if (a?.kind === "file")
14068
15791
  out.push({ path, kind: "removed" });
14069
15792
  if (b?.kind === "file")
@@ -14098,17 +15821,29 @@ class Repo {
14098
15821
  async status() {
14099
15822
  const head = await this.current();
14100
15823
  const before = head ? await this.filesOf(head) : new Map();
14101
- const after = await this.workingFiles();
14102
- return comparePaths(before, after);
14103
- }
14104
- /** The working tree as a path → entry map, mirroring `filesOf`. */
14105
- async workingFiles() {
14106
- const out = new Map();
14107
- for (const abs of await this.deps.vfs.walk(this.root)) {
14108
- const stat = await this.deps.vfs.statManifest(abs);
14109
- out.set(abs.slice(this.root.length), { kind: "file", manifest: stat.manifest, size: stat.size });
14110
- }
14111
- return out;
15824
+ // The working tree is compared as it is walked, and a path found on
15825
+ // both sides leaves `before` as it goes, so all that is held at once
15826
+ // is the commit's map and the changes. Walking the whole tree into a
15827
+ // list, mapping that into a second map and diffing the two held every
15828
+ // path three times over: a hostile HEAD and working tree, each inside
15829
+ // both budgets, needed about 600MB.
15830
+ const changes = [];
15831
+ await this.deps.vfs.eachEntry(this.root, ({ path: abs }, manifest) => {
15832
+ if (!manifest)
15833
+ return; // a directory
15834
+ const path = abs.slice(this.root.length);
15835
+ const was = before.get(path);
15836
+ if (!was) {
15837
+ changes.push({ path, kind: "added" });
15838
+ return;
15839
+ }
15840
+ if (was.manifest.id !== manifest.id)
15841
+ changes.push({ path, kind: "modified" });
15842
+ before.delete(path);
15843
+ });
15844
+ for (const path of before.keys())
15845
+ changes.push({ path, kind: "removed" });
15846
+ return changes.sort(byPath);
14112
15847
  }
14113
15848
  /**
14114
15849
  * Turn what a person typed into a commit hash.
@@ -14134,7 +15869,7 @@ class Repo {
14134
15869
  for (const step of steps.match(/\^|~\d+/g) ?? []) {
14135
15870
  const back = step === "^" ? 1 : Number(step.slice(1));
14136
15871
  for (let i = 0; i < back; i++) {
14137
- const parents = (await this.get(hash)).parents;
15872
+ const parents = (await this.readCommit(hash)).parents;
14138
15873
  if (!parents.length)
14139
15874
  throw new VcsError(`"${rev}" reaches past the first commit`);
14140
15875
  hash = parents[0]; // first parent: the branch you were on
@@ -14148,7 +15883,9 @@ class Repo {
14148
15883
  return null;
14149
15884
  const keys = await this.deps.store.list();
14150
15885
  const wanted = objectKey(this.slug, prefix);
14151
- const hits = keys.filter((k) => k.startsWith(wanted));
15886
+ // Only keys that could be objects: a stray key under the prefix would
15887
+ // otherwise make every abbreviation of it "ambiguous".
15888
+ const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
14152
15889
  // Ambiguous is an error, not a coin toss — acting on the wrong commit
14153
15890
  // is exactly the kind of mistake version control exists to prevent.
14154
15891
  if (hits.length > 1)
@@ -14158,7 +15895,7 @@ class Repo {
14158
15895
  const hash = hits[0].slice(objectKey(this.slug, "").length);
14159
15896
  // Objects include trees; only a commit is a valid revision.
14160
15897
  const object = await this.get(hash);
14161
- return object && "tree" in object ? hash : null;
15898
+ return isObject(object) && "tree" in object ? hash : null;
14162
15899
  }
14163
15900
  /**
14164
15901
  * Put one file back to the way it was in a commit.
@@ -14185,16 +15922,37 @@ class Repo {
14185
15922
  }
14186
15923
  /** Make the working tree match a commit, leaving HEAD alone. */
14187
15924
  async materialise(commitHash) {
14188
- const commit = await this.get(commitHash);
14189
- const wanted = new Map();
14190
- await this.collect(commit.tree, "", wanted);
15925
+ // Collected in full BEFORE anything is touched, so a tree that cannot
15926
+ // be read throws with the working tree exactly as it was.
15927
+ const wanted = await this.filesOf(commitHash);
15928
+ // The working tree, read once. The walk reads ahead, and leaves every
15929
+ // record cached for the check below - which, run first, opened each
15930
+ // directory by path one round trip at a time, and in a fresh process
15931
+ // asked to discard changes (so with no status read before it) doubled
15932
+ // the checkout.
15933
+ const present = await this.deps.vfs.walkEntries(this.root);
15934
+ // Every directory the commit needs, checked the same way. mkdir
15935
+ // refuses a name held by an entry this version cannot read; found only
15936
+ // when the write came, that refusal landed after the deletions below -
15937
+ // files gone, HEAD unchanged, and a message about a directory that
15938
+ // listings said was not there.
15939
+ await this.deps.vfs.assertCreatable(new Set([...wanted.keys()].map((path) => dirname(this.abs(path)))));
14191
15940
  // Remove what the commit does not have BEFORE writing what it does —
14192
15941
  // otherwise a file replaced by a directory of the same name collides.
14193
- for (const abs of await this.deps.vfs.walk(this.root)) {
14194
- const path = abs.slice(this.root.length);
15942
+ for (const entry of present) {
14195
15943
  // keepContent: history still references these manifests.
14196
- if (!wanted.has(path))
14197
- await this.deps.vfs.delete(abs, { keepContent: true }).catch(() => { });
15944
+ if (entry.kind === "file" && !wanted.has(entry.path.slice(this.root.length))) {
15945
+ await this.deps.vfs.delete(entry.path, { keepContent: true }).catch(() => { });
15946
+ }
15947
+ }
15948
+ // And the reverse: a directory where the commit has a FILE. Nothing in
15949
+ // it is wanted - a path cannot be both - so the loop above has emptied
15950
+ // it of files, but it is still there, and the file cannot be written
15951
+ // over it.
15952
+ for (const entry of present) {
15953
+ if (entry.kind === "dir" && wanted.has(entry.path.slice(this.root.length))) {
15954
+ await this.deps.vfs.delete(entry.path, { recursive: true, keepContent: true });
15955
+ }
14198
15956
  }
14199
15957
  for (const [path, entry] of wanted) {
14200
15958
  await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
@@ -14204,22 +15962,35 @@ class Repo {
14204
15962
  abs(path) {
14205
15963
  return `${this.root}${path}`;
14206
15964
  }
14207
- async collect(treeHash, prefix, out) {
14208
- const tree = await this.get(treeHash);
14209
- for (const [name, entry] of Object.entries(tree.entries)) {
15965
+ async collect(treeHash, prefix, out, walk, depth = 0) {
15966
+ const entries = await walk.tree(treeHash);
15967
+ const names = Object.keys(entries);
15968
+ if (walk.spend(prefix, names))
15969
+ await nextTurn();
15970
+ for (const name of names) {
15971
+ const entry = entries[name];
14210
15972
  const path = `${prefix}/${name}`;
14211
- if (entry.kind === "tree")
14212
- await this.collect(entry.hash, path, out);
14213
- else
15973
+ if (entry.kind === "tree") {
15974
+ walk.descend(depth + 1);
15975
+ await this.collect(entry.hash, path, out, walk, depth + 1);
15976
+ }
15977
+ else {
14214
15978
  out.set(path, entry);
15979
+ }
14215
15980
  }
14216
15981
  }
14217
15982
  // ---- branches -----------------------------------------------------------
14218
15983
  /** Create a branch at a commit (HEAD by default). Does not switch to it. */
14219
15984
  async branch(name, at) {
14220
- if (!/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(name))
14221
- throw new VcsError(`"${name}" is not a usable branch name`);
14222
- if (await this.readRef(name))
15985
+ if (!isBranch(name))
15986
+ throw new VcsError(`"${printable(name, 64)}" is not a usable branch name`);
15987
+ // A ref that cannot be read is replaced, as a branch list that cannot
15988
+ // be read is: every command that uses the branch reads it first, so
15989
+ // this is the only way that name ever works again.
15990
+ const existing = await this.refState(name);
15991
+ if (existing === "unreadable")
15992
+ console.warn(`[muhkoo/vcs] replacing branch "${name}", which cannot be read`);
15993
+ else if (existing)
14223
15994
  throw new VcsError(`branch "${name}" already exists`);
14224
15995
  const commit = at ?? (await this.current());
14225
15996
  if (!commit)
@@ -14301,14 +16072,28 @@ class Repo {
14301
16072
  this.filesOf(theirs),
14302
16073
  ]);
14303
16074
  const conflicts = [];
14304
- for (const path of new Set([...ourFiles.keys(), ...theirFiles.keys(), ...baseFiles.keys()])) {
14305
- const b = baseFiles.get(path);
14306
- const o = ourFiles.get(path);
14307
- const t = theirFiles.get(path);
14308
- const conflict = await this.settlePath(path, b, o, t);
16075
+ let sinceTurn = 0;
16076
+ const settle = async (path) => {
16077
+ const conflict = await this.settlePath(path, baseFiles.get(path), ourFiles.get(path), theirFiles.get(path));
14309
16078
  if (conflict)
14310
16079
  conflicts.push(conflict);
14311
- }
16080
+ // A path both sides agree on waits on nothing, so this loop is
16081
+ // otherwise one chain of continuations: over a second at the
16082
+ // default budgets, with nothing else let in.
16083
+ if (++sinceTurn === YIELD_EVERY) {
16084
+ sinceTurn = 0;
16085
+ await nextTurn();
16086
+ }
16087
+ };
16088
+ // Every path either side has, once - ours, then theirs - without
16089
+ // building a set of them: three sides of half a million paths each is
16090
+ // already most of what a merge holds. A path only the base has was
16091
+ // deleted on both sides, which settles to nothing.
16092
+ for (const path of ourFiles.keys())
16093
+ await settle(path);
16094
+ for (const path of theirFiles.keys())
16095
+ if (!ourFiles.has(path))
16096
+ await settle(path);
14312
16097
  if (conflicts.length) {
14313
16098
  // Record what to merge with, so finishing is just: fix the files,
14314
16099
  // then commit. Losing this would silently produce a commit with one
@@ -14384,7 +16169,7 @@ class Repo {
14384
16169
  if (ancestors.has(hash))
14385
16170
  continue;
14386
16171
  ancestors.add(hash);
14387
- queue.push(...(await this.get(hash)).parents);
16172
+ queue.push(...(await this.readCommit(hash)).parents);
14388
16173
  }
14389
16174
  };
14390
16175
  await collect(a);
@@ -14397,15 +16182,15 @@ class Repo {
14397
16182
  seen.add(hash);
14398
16183
  if (ancestors.has(hash))
14399
16184
  return hash;
14400
- queue.push(...(await this.get(hash)).parents);
16185
+ queue.push(...(await this.readCommit(hash)).parents);
14401
16186
  }
14402
16187
  return null;
14403
16188
  }
14404
16189
  /** Every file in a commit, by path. */
14405
16190
  async filesOf(commitHash) {
14406
- const commit = await this.get(commitHash);
16191
+ const commit = await this.readCommit(commitHash);
14407
16192
  const out = new Map();
14408
- await this.collect(commit.tree, "", out);
16193
+ await this.collect(commit.tree, "", out, this.treeWalk());
14409
16194
  return out;
14410
16195
  }
14411
16196
  }
@@ -14436,6 +16221,7 @@ class VcsNamespace {
14436
16221
  return deriveRepoKey(seed, slug);
14437
16222
  },
14438
16223
  author: this.deps.author,
16224
+ limits: this.deps.limits,
14439
16225
  });
14440
16226
  this.repos.set(slug, repo);
14441
16227
  }
@@ -16349,5 +18135,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
16349
18135
  default: loadRsWasm
16350
18136
  });
16351
18137
 
16352
- export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
18138
+ export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
16353
18139
  //# sourceMappingURL=index.js.map