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