@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/browser/index.js
CHANGED
|
@@ -5327,7 +5327,7 @@ function base64UrlDecode(s) {
|
|
|
5327
5327
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
5328
5328
|
* `package.json` `version`.
|
|
5329
5329
|
*/
|
|
5330
|
-
const VERSION = "0.
|
|
5330
|
+
const VERSION = "0.13.0-alpha.0";
|
|
5331
5331
|
|
|
5332
5332
|
/**
|
|
5333
5333
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -9294,12 +9294,7 @@ class Space {
|
|
|
9294
9294
|
const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
|
|
9295
9295
|
if (hashes.length === 0)
|
|
9296
9296
|
return;
|
|
9297
|
-
|
|
9298
|
-
// bytes apart. Without it the reference ledger keys on content, so the
|
|
9299
|
-
// second copy takes no reference and releasing either frees shards the
|
|
9300
|
-
// other still needs — silent data loss. Sent on BOTH commit and release,
|
|
9301
|
-
// because a release has to reproduce the key its commit took.
|
|
9302
|
-
const body = JSON.stringify({ hashes, bytes: manifest.size, manifestId: manifest.id });
|
|
9297
|
+
const body = JSON.stringify({ hashes, bytes: manifest.size });
|
|
9303
9298
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
9304
9299
|
try {
|
|
9305
9300
|
const res = await this.deps.fetch(`${this.deps.httpBaseUrl}/api/storage/${action}`, {
|
|
@@ -12035,13 +12030,40 @@ function isUnder(path, dir) {
|
|
|
12035
12030
|
* directory on the next lookup — a file literally named `..` could never be
|
|
12036
12031
|
* addressed again.
|
|
12037
12032
|
*/
|
|
12033
|
+
/** The longest a single entry name may be. Matches the common filesystem limit. */
|
|
12034
|
+
const MAX_NAME_BYTES = 255;
|
|
12035
|
+
/**
|
|
12036
|
+
* Is this a name we are willing to store, and to hand back as a path segment?
|
|
12037
|
+
*
|
|
12038
|
+
* This is the READ-side check as much as the write-side one, and the read side
|
|
12039
|
+
* is the one that matters: a directory record is sealed with a key that any
|
|
12040
|
+
* holder of the master seed can derive, so its contents are not necessarily
|
|
12041
|
+
* something this SDK wrote. A name arriving from a record is input.
|
|
12042
|
+
*
|
|
12043
|
+
* What each rule is for:
|
|
12044
|
+
* - `/` and backslash would become path structure. Backslash is a separator on
|
|
12045
|
+
* Windows, which makes it traversal there and an odd filename elsewhere.
|
|
12046
|
+
* - `.` and `..` navigate rather than name.
|
|
12047
|
+
* - NUL terminates a path in every syscall that takes one.
|
|
12048
|
+
* - Other control characters and bidi overrides let a name print as something
|
|
12049
|
+
* other than what it is - the classic way a file passes review.
|
|
12050
|
+
* - A length cap, because nothing else imposes one.
|
|
12051
|
+
*/
|
|
12052
|
+
function isSafeName(name) {
|
|
12053
|
+
if (!name || name === "." || name === "..")
|
|
12054
|
+
return false;
|
|
12055
|
+
if (name.includes("/") || name.includes(String.fromCharCode(92)))
|
|
12056
|
+
return false;
|
|
12057
|
+
const CONTROL_OR_BIDI = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]");
|
|
12058
|
+
if (CONTROL_OR_BIDI.test(name))
|
|
12059
|
+
return false;
|
|
12060
|
+
return new TextEncoder().encode(name).length <= MAX_NAME_BYTES;
|
|
12061
|
+
}
|
|
12038
12062
|
function assertValidName(name) {
|
|
12039
12063
|
if (!name)
|
|
12040
12064
|
throw new Error("VFS: name cannot be empty");
|
|
12041
|
-
if (name
|
|
12042
|
-
throw new Error(`VFS:
|
|
12043
|
-
if (name.includes("/"))
|
|
12044
|
-
throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
|
|
12065
|
+
if (!isSafeName(name))
|
|
12066
|
+
throw new Error(`VFS: ${JSON.stringify(name)} is not a usable name`);
|
|
12045
12067
|
}
|
|
12046
12068
|
/**
|
|
12047
12069
|
* MIME type for a filename.
|
|
@@ -12153,6 +12175,14 @@ function escapeLiteral(s) {
|
|
|
12153
12175
|
* cost storage until a future sweep, which is the trade this shape makes in
|
|
12154
12176
|
* exchange for single-record writes.
|
|
12155
12177
|
*/
|
|
12178
|
+
/**
|
|
12179
|
+
* How deep a tree may be before we stop walking it.
|
|
12180
|
+
*
|
|
12181
|
+
* Well past anything a real project needs, and low enough that a malformed or
|
|
12182
|
+
* hostile record cannot spend the caller's memory before the cycle guard sees a
|
|
12183
|
+
* repeat.
|
|
12184
|
+
*/
|
|
12185
|
+
const MAX_DEPTH = 64;
|
|
12156
12186
|
const DEFAULT_HISTORY_LIMIT = 20;
|
|
12157
12187
|
class VfsNamespace {
|
|
12158
12188
|
deps;
|
|
@@ -12281,9 +12311,29 @@ class VfsNamespace {
|
|
|
12281
12311
|
const dir = await this.openDir(path);
|
|
12282
12312
|
const base = this.resolve(path);
|
|
12283
12313
|
return Object.entries(dir.node.entries)
|
|
12314
|
+
// Validate the name HERE, where it is read.
|
|
12315
|
+
//
|
|
12316
|
+
// A directory record is sealed under a key derivable from the master
|
|
12317
|
+
// seed, so its contents are not necessarily something this SDK
|
|
12318
|
+
// wrote, and the write-side check never sees them. An unusable name
|
|
12319
|
+
// is dropped rather than thrown on: one bad entry must not make the
|
|
12320
|
+
// whole directory unlistable, and callers walk this output straight
|
|
12321
|
+
// into filesystem paths.
|
|
12322
|
+
.filter(([name]) => {
|
|
12323
|
+
if (isSafeName(name))
|
|
12324
|
+
return true;
|
|
12325
|
+
this.warn(`ignoring entry with an unusable name in ${base}`);
|
|
12326
|
+
return false;
|
|
12327
|
+
})
|
|
12284
12328
|
.map(([name, entry]) => toStat(join(base, name), name, entry))
|
|
12285
12329
|
.sort(byDirsThenName);
|
|
12286
12330
|
}
|
|
12331
|
+
warn(message) {
|
|
12332
|
+
// Kept quiet by default in a browser console that the user cannot act
|
|
12333
|
+
// on, but never silent: a dropped entry is a real difference between
|
|
12334
|
+
// what is stored and what is shown.
|
|
12335
|
+
console.warn(`[muhkoo/vfs] ${message}`);
|
|
12336
|
+
}
|
|
12287
12337
|
/**
|
|
12288
12338
|
* Every file path under a prefix, depth-first.
|
|
12289
12339
|
*
|
|
@@ -12292,15 +12342,32 @@ class VfsNamespace {
|
|
|
12292
12342
|
*/
|
|
12293
12343
|
async walk(path = ".") {
|
|
12294
12344
|
const out = [];
|
|
12295
|
-
|
|
12345
|
+
// Guard against a tree that is not one.
|
|
12346
|
+
//
|
|
12347
|
+
// `visit` recurses on a PATH, re-resolved from the root each time, so a
|
|
12348
|
+
// record whose entry points back at an ancestor - or, before names were
|
|
12349
|
+
// validated, an entry simply named ".." - recurses forever and grows
|
|
12350
|
+
// `out` without bound. Nothing in the format prevents that, and `mount`
|
|
12351
|
+
// calls this on every sync pass.
|
|
12352
|
+
const seen = new Set();
|
|
12353
|
+
const visit = async (dirPath, depth) => {
|
|
12354
|
+
if (depth > MAX_DEPTH) {
|
|
12355
|
+
this.warn(`stopping at ${dirPath}: deeper than ${MAX_DEPTH} levels`);
|
|
12356
|
+
return;
|
|
12357
|
+
}
|
|
12358
|
+
if (seen.has(dirPath)) {
|
|
12359
|
+
this.warn(`stopping at ${dirPath}: already visited (the tree contains a cycle)`);
|
|
12360
|
+
return;
|
|
12361
|
+
}
|
|
12362
|
+
seen.add(dirPath);
|
|
12296
12363
|
for (const entry of await this.list(dirPath)) {
|
|
12297
12364
|
if (entry.kind === "dir")
|
|
12298
|
-
await visit(entry.path);
|
|
12365
|
+
await visit(entry.path, depth + 1);
|
|
12299
12366
|
else
|
|
12300
12367
|
out.push(entry.path);
|
|
12301
12368
|
}
|
|
12302
12369
|
};
|
|
12303
|
-
await visit(this.resolve(path));
|
|
12370
|
+
await visit(this.resolve(path), 0);
|
|
12304
12371
|
return out;
|
|
12305
12372
|
}
|
|
12306
12373
|
async readFile(path) {
|
|
@@ -12573,6 +12640,12 @@ class VfsNamespace {
|
|
|
12573
12640
|
const root = await this.root();
|
|
12574
12641
|
const reachable = new Set();
|
|
12575
12642
|
const visit = async (dir) => {
|
|
12643
|
+
// `reachable` was already the visited set - it just was not being
|
|
12644
|
+
// TESTED before recursing, so a cycle looped forever. Here the
|
|
12645
|
+
// consequence would have been worse than a hang: sweep decides what
|
|
12646
|
+
// to delete, and it never got as far as deciding.
|
|
12647
|
+
if (reachable.has(dirKey(dir.id)))
|
|
12648
|
+
return;
|
|
12576
12649
|
reachable.add(dirKey(dir.id));
|
|
12577
12650
|
for (const entry of Object.values(dir.node.entries)) {
|
|
12578
12651
|
if (entry.kind === "dir") {
|
|
@@ -12730,10 +12803,16 @@ class VfsNamespace {
|
|
|
12730
12803
|
});
|
|
12731
12804
|
}
|
|
12732
12805
|
/** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
|
|
12733
|
-
async purge(dir) {
|
|
12806
|
+
async purge(dir, seen = new Set()) {
|
|
12807
|
+
// A record that references an ancestor would otherwise recurse forever:
|
|
12808
|
+
// the id is only removed from the cache AFTER the loop, so re-entering
|
|
12809
|
+
// it hits the same cached record every time.
|
|
12810
|
+
if (seen.has(dir.id))
|
|
12811
|
+
return;
|
|
12812
|
+
seen.add(dir.id);
|
|
12734
12813
|
for (const entry of Object.values(dir.node.entries)) {
|
|
12735
12814
|
if (entry.kind === "dir") {
|
|
12736
|
-
await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
|
|
12815
|
+
await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)), seen);
|
|
12737
12816
|
}
|
|
12738
12817
|
else {
|
|
12739
12818
|
await this.releaseFile(dir, entry);
|
|
@@ -12747,18 +12826,32 @@ class VfsNamespace {
|
|
|
12747
12826
|
* directory gets a fresh id and key, so the copy is independent of the
|
|
12748
12827
|
* original — writing into one must never appear in the other.
|
|
12749
12828
|
*/
|
|
12750
|
-
async cloneEntry(entry) {
|
|
12829
|
+
async cloneEntry(entry, seen = new Set()) {
|
|
12751
12830
|
if (entry.kind === "file") {
|
|
12752
12831
|
// Same bytes, second owner: take a reference so deleting either one
|
|
12753
12832
|
// leaves the other readable.
|
|
12754
12833
|
await this.deps.content.retain(entry.manifest);
|
|
12755
12834
|
return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
|
|
12756
12835
|
}
|
|
12836
|
+
// The most expensive walker to leave unguarded: it mints a NEW id, key
|
|
12837
|
+
// and stored record at every level, so a cyclic tree does not merely
|
|
12838
|
+
// hang - it writes records to the store, and to the user's bill, in an
|
|
12839
|
+
// unbounded loop.
|
|
12840
|
+
if (seen.has(entry.id) || seen.size > MAX_DEPTH) {
|
|
12841
|
+
throw new VfsConflictError("VFS: refusing to copy a directory that contains a cycle");
|
|
12842
|
+
}
|
|
12843
|
+
seen.add(entry.id);
|
|
12757
12844
|
const source = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12758
12845
|
const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
12759
12846
|
const node = { v: 1, entries: {}, mtime: Date.now() };
|
|
12760
12847
|
for (const [name, child] of Object.entries(source.node.entries)) {
|
|
12761
|
-
|
|
12848
|
+
// Names are validated on read everywhere else; a copy must not be
|
|
12849
|
+
// the way a bad one is carried into a fresh record.
|
|
12850
|
+
if (!isSafeName(name)) {
|
|
12851
|
+
this.warn(`skipping entry with an unusable name while copying`);
|
|
12852
|
+
continue;
|
|
12853
|
+
}
|
|
12854
|
+
node.entries[name] = await this.cloneEntry(child, seen);
|
|
12762
12855
|
}
|
|
12763
12856
|
const open = { id: clone.id, key: fromBase64(clone.key), node };
|
|
12764
12857
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|