@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.
- package/README.md +53 -4
- package/dist/browser/index.js +2244 -458
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +377 -14
- package/dist/server/index.js +2244 -458
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
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
|
-
*
|
|
12915
|
+
* `unseal` for a record that must not be guessed at.
|
|
12682
12916
|
*
|
|
12683
|
-
*
|
|
12684
|
-
*
|
|
12685
|
-
*
|
|
12686
|
-
*
|
|
12687
|
-
*
|
|
12688
|
-
|
|
12689
|
-
|
|
12690
|
-
|
|
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
|
|
12712
|
-
const
|
|
12713
|
-
|
|
12714
|
-
|
|
12715
|
-
|
|
12716
|
-
|
|
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
|
-
*
|
|
12965
|
+
* Glob matching for VFS paths.
|
|
12720
12966
|
*
|
|
12721
|
-
*
|
|
12722
|
-
*
|
|
12723
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12874
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12882
|
-
*
|
|
12883
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
12888
|
-
*
|
|
12889
|
-
*
|
|
12890
|
-
*
|
|
12891
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12896
|
-
*
|
|
12897
|
-
*
|
|
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
|
-
|
|
12900
|
-
if (
|
|
12901
|
-
return
|
|
12902
|
-
|
|
12903
|
-
|
|
12904
|
-
|
|
12905
|
-
|
|
12906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
13714
|
+
* One directory at a time, depth-first, with the records it is about to
|
|
13715
|
+
* need fetched ahead of it.
|
|
13070
13716
|
*
|
|
13071
|
-
*
|
|
13072
|
-
*
|
|
13073
|
-
*
|
|
13074
|
-
*
|
|
13075
|
-
*
|
|
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
|
-
*
|
|
13078
|
-
*
|
|
13079
|
-
*
|
|
13080
|
-
*
|
|
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
|
|
13083
|
-
const
|
|
13084
|
-
|
|
13085
|
-
|
|
13086
|
-
|
|
13087
|
-
|
|
13088
|
-
|
|
13089
|
-
|
|
13090
|
-
|
|
13091
|
-
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13095
|
-
|
|
13096
|
-
|
|
13097
|
-
|
|
13098
|
-
|
|
13099
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
13990
|
+
let versions = asCount(prior?.versions);
|
|
13226
13991
|
if (prior)
|
|
13227
|
-
versions = await this.pushHistory(parent,
|
|
13228
|
-
const entry = {
|
|
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
|
|
13238
|
-
*
|
|
13239
|
-
*
|
|
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
|
|
13248
|
-
|
|
13249
|
-
|
|
13250
|
-
|
|
13251
|
-
|
|
13252
|
-
|
|
13253
|
-
|
|
13254
|
-
|
|
13255
|
-
|
|
13256
|
-
|
|
13257
|
-
|
|
13258
|
-
|
|
13259
|
-
|
|
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
|
-
|
|
13262
|
-
|
|
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
|
|
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
|
|
14143
|
+
if (entryIn(dstParent.node, dstName))
|
|
13294
14144
|
throw new VfsConflictError(`${dst} already exists`);
|
|
13295
|
-
|
|
13296
|
-
//
|
|
13297
|
-
//
|
|
13298
|
-
const
|
|
13299
|
-
|
|
13300
|
-
|
|
13301
|
-
|
|
13302
|
-
|
|
13303
|
-
|
|
13304
|
-
|
|
13305
|
-
|
|
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
|
|
14235
|
+
if (entryIn(dstParent.node, dstName))
|
|
13337
14236
|
throw new VfsConflictError(`${dst} already exists`);
|
|
13338
|
-
|
|
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
|
-
|
|
13372
|
-
|
|
13373
|
-
|
|
13374
|
-
|
|
13375
|
-
|
|
13376
|
-
|
|
13377
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
|
|
13383
|
-
|
|
13384
|
-
|
|
13385
|
-
|
|
13386
|
-
|
|
13387
|
-
|
|
13388
|
-
|
|
13389
|
-
|
|
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;
|
|
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
|
|
13411
|
-
const
|
|
13412
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
14405
|
+
const entry = entryIn(dir.node, name);
|
|
13485
14406
|
if (!entry || entry.kind !== "dir")
|
|
13486
14407
|
return null;
|
|
13487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13509
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
13536
|
-
|
|
13537
|
-
|
|
13538
|
-
|
|
13539
|
-
|
|
13540
|
-
|
|
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
|
-
|
|
13543
|
-
|
|
13544
|
-
|
|
13545
|
-
|
|
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
|
-
|
|
13548
|
-
|
|
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
|
-
|
|
13552
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
13571
|
-
|
|
13572
|
-
|
|
13573
|
-
|
|
13574
|
-
|
|
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:
|
|
13577
|
-
for (const [name, child] of
|
|
13578
|
-
|
|
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
|
-
|
|
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
|
|
13593
|
-
*
|
|
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(
|
|
13596
|
-
const record = await this.
|
|
13597
|
-
const manifests =
|
|
13598
|
-
|
|
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(
|
|
14911
|
+
await this.deps.store.delete(historyKey(id)).catch(() => { });
|
|
13601
14912
|
}
|
|
13602
|
-
|
|
13603
|
-
|
|
13604
|
-
|
|
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
|
-
/**
|
|
13607
|
-
|
|
13608
|
-
|
|
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
|
-
|
|
13618
|
-
|
|
13619
|
-
|
|
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
|
|
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
|
-
/**
|
|
13849
|
-
|
|
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
|
-
|
|
13889
|
-
|
|
13890
|
-
|
|
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
|
|
13896
|
-
|
|
13897
|
-
|
|
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
|
|
13904
|
-
|
|
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
|
-
|
|
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
|
|
13917
|
-
|
|
13918
|
-
|
|
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.
|
|
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
|
|
13976
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
13981
|
-
|
|
13982
|
-
|
|
13983
|
-
|
|
13984
|
-
|
|
13985
|
-
|
|
13986
|
-
|
|
13987
|
-
|
|
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
|
-
|
|
13991
|
-
|
|
13992
|
-
|
|
13993
|
-
|
|
13994
|
-
|
|
13995
|
-
|
|
13996
|
-
|
|
13997
|
-
|
|
13998
|
-
|
|
13999
|
-
|
|
14000
|
-
|
|
14001
|
-
|
|
14002
|
-
|
|
14003
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
14046
|
-
const to = (await this.
|
|
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(
|
|
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
|
-
|
|
14055
|
-
|
|
14056
|
-
|
|
14057
|
-
|
|
14058
|
-
|
|
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
|
-
|
|
14102
|
-
|
|
14103
|
-
|
|
14104
|
-
|
|
14105
|
-
|
|
14106
|
-
|
|
14107
|
-
|
|
14108
|
-
|
|
14109
|
-
|
|
14110
|
-
|
|
14111
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
14189
|
-
|
|
14190
|
-
await this.
|
|
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
|
|
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(
|
|
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
|
|
14209
|
-
|
|
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
|
-
|
|
14213
|
-
|
|
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 (
|
|
14221
|
-
throw new VcsError(`"${name}" is not a usable branch name`);
|
|
14222
|
-
|
|
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
|
-
|
|
14305
|
-
|
|
14306
|
-
const
|
|
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.
|
|
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.
|
|
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.
|
|
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
|