mirror of
https://github.com/HeyPuter/browser.js.git
synced 2026-11-13 18:33:58 -10:00
[core] rewrite the worker, OPFS and BroadcastChannel interceptors (#108)
* [core] rewrite the worker, OPFS and BroadcastChannel interceptors Moves the worker surface off the hand-rolled traps and onto the IDL interceptors from the base PR. - `shared/broadcastchannel.ts` is new: the channel name was not scoped per origin, so two sites proxied through scramjet could see each other's messages. - `shared/opfs.ts` scopes the origin-private file system per site origin rather than per proxy origin. - `worker/importScripts.ts` and `shared/worker.ts` resolve script URLs against the site's origin. Tests: `opfs` and `broadcastchannel-scope`, the latter covering the cross-origin delivery the unscoped name allowed. * review and remove comments ---------
This commit is contained in:
1 parent
310e211bc7
commit
060552a44c
6 files changed
+603
-84
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { ScramjetClient } from "@client/index";
|
||||
import { Constructor, Type } from "@client/webidl";
|
||||
import { String_indexOf, String_substring } from "@/shared/snapshot";
|
||||
|
||||
export const enabled = (client: ScramjetClient, self: Self) =>
|
||||
"BroadcastChannel" in self;
|
||||
|
||||
/**
|
||||
* https://html.spec.whatwg.org/multipage/web-messaging.html#broadcastchannel
|
||||
*/
|
||||
export default function (client: ScramjetClient) {
|
||||
client.Intercept(class extends BroadcastChannel {
|
||||
@Constructor("DOMString")
|
||||
static konstructor(name: string) {
|
||||
return new this(`${client.scopeOrigin}@${name}`);
|
||||
}
|
||||
|
||||
@Type("DOMString")
|
||||
get name(): string {
|
||||
const name = super.name;
|
||||
|
||||
// split on the *first* "@": an origin cannot contain one, so the
|
||||
// first is always the separator and a name the page put an "@" in -
|
||||
// including one that looks like it is already scoped - comes back
|
||||
// intact. A name with no "@" at all is not one of ours and is
|
||||
// answered as-is rather than mangled.
|
||||
return String_substring(name, String_indexOf(name, "@") + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,42 @@
|
||||
import { ScramjetClient } from "@client/index";
|
||||
import { Object_defineProperty } from "@/shared/snapshot";
|
||||
import { encodeURIComponent } from "@/shared/snapshot";
|
||||
import { Arguments, Returns, Type } from "@client/webidl";
|
||||
|
||||
export const enabled = (client: ScramjetClient, self: Self) =>
|
||||
"StorageManager" in self && "FileSystemHandle" in self;
|
||||
|
||||
export default function (client: ScramjetClient) {
|
||||
client.Proxy("StorageManager.prototype.getDirectory", {
|
||||
apply(ctx) {
|
||||
const rootPromise = ctx.call();
|
||||
ctx.return(
|
||||
(async () => {
|
||||
const root = await rootPromise;
|
||||
const directory = await root.getDirectoryHandle(
|
||||
`${client.url.origin.replace(/\/|\s|\./g, "-")}`,
|
||||
{
|
||||
create: true,
|
||||
}
|
||||
);
|
||||
Object_defineProperty(directory, "name", {
|
||||
value: "",
|
||||
writable: false,
|
||||
});
|
||||
const scopeName = () => encodeURIComponent(client.scopeOrigin);
|
||||
|
||||
return directory;
|
||||
})()
|
||||
);
|
||||
},
|
||||
client.Intercept(class extends StorageManager {
|
||||
@Arguments()
|
||||
@Returns("Promise<FileSystemDirectoryHandle>")
|
||||
async getDirectory(): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await super.getDirectory();
|
||||
// through the native rather than off the handle. `FileSystemDirectory
|
||||
// Handle.prototype.getDirectoryHandle` is page-writable, and it is
|
||||
// the only thing between the page and the *unscoped* root it was
|
||||
// just handed: replacing it with `function () { return this; }` made
|
||||
// `getDirectory()` resolve to the real origin root, and from there
|
||||
// every other proxied site's tree is a `for await` away
|
||||
const directory = await new client.native.FileSystemDirectoryHandle(
|
||||
root
|
||||
).getDirectoryHandle(scopeName(), { create: true });
|
||||
client.box.scopedOpfsRoots.add(directory);
|
||||
|
||||
return directory;
|
||||
}
|
||||
});
|
||||
|
||||
client.Intercept(class extends FileSystemHandle {
|
||||
@Type("USVString")
|
||||
// eslint-disable-next-line scramjet-core/intercept-brand-check -- membership is itself the brand check: impossible to forge
|
||||
get name(): string {
|
||||
// the scoped directory stands in for the origin's root, and a root's
|
||||
// name is the empty string.
|
||||
if (client.box.scopedOpfsRoots.has(this)) return "";
|
||||
|
||||
return super.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,68 +1,108 @@
|
||||
import { ScramjetClient } from "@client/index";
|
||||
import { Arguments, Constructor, Returns, Type } from "@client/webidl";
|
||||
import { readWorkerOptions, readWorkletOptions } from "@client/helpers";
|
||||
import { String_indexOf, String_substring } from "@/shared/snapshot";
|
||||
|
||||
export default function (client: ScramjetClient, _self: GlobalThis) {
|
||||
client.Proxy("Worker", {
|
||||
construct(ctx) {
|
||||
ctx.args[0] = client.rewriteUrl(ctx.args[0], {
|
||||
destination: "worker",
|
||||
isModule: ctx.args[1]?.type === "module",
|
||||
});
|
||||
export default function (client: ScramjetClient, self: Self) {
|
||||
const scoped = (name: string) => `${client.scopeOrigin}@${name}`;
|
||||
|
||||
const worker = ctx.call();
|
||||
// const conn = new BareMuxConnection();
|
||||
/**
|
||||
* The `credentials` signal `rewriteUrl` stamps onto the proxy URL, which the
|
||||
* service worker reads back for `Sec-Fetch-Storage-Access`. Only "include"
|
||||
* is carried: `fetch/headers.ts` treats an absent value as the default the
|
||||
* destination implies, and for these three that default is what the other
|
||||
* two enum members mean.
|
||||
*/
|
||||
const credentialsOption = (credentials: RequestCredentials) =>
|
||||
credentials === "include" ? "include" : undefined;
|
||||
|
||||
// (async () => {
|
||||
// const port = await conn.getInnerPort();
|
||||
// new client.native.Worker(worker).postMessage(
|
||||
// {
|
||||
// $scramjet$type: "baremuxinit",
|
||||
// port,
|
||||
// },
|
||||
// [port]
|
||||
// );
|
||||
// })();
|
||||
},
|
||||
// A shared worker is the one scoped name the page can read back from the
|
||||
// inside: `SharedWorkerGlobalScope.name` is the string the constructor was
|
||||
// given, so without this a site reads `https://site.example@myworker`.
|
||||
if ("SharedWorkerGlobalScope" in self) {
|
||||
client.Intercept(class extends SharedWorkerGlobalScope {
|
||||
@Type("DOMString")
|
||||
get name(): string {
|
||||
const name = super.name;
|
||||
|
||||
// the origin cannot contain an "@", so the first one is the
|
||||
// separator and a name the page put an "@" in survives intact
|
||||
return String_substring(name, String_indexOf(name, "@") + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
client.Intercept(class extends Worker {
|
||||
@Constructor("USVString", "optional WorkerOptions")
|
||||
static konstructor(scriptURL: string, options?: WorkerOptions) {
|
||||
const init = readWorkerOptions(options);
|
||||
|
||||
return new this(
|
||||
client.rewriteUrl(scriptURL, {
|
||||
destination: "worker",
|
||||
isModule: init.type === "module",
|
||||
credentials: credentialsOption(init.credentials),
|
||||
}),
|
||||
init
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// sharedworkers can only be constructed from window
|
||||
client.Proxy("SharedWorker", {
|
||||
construct(ctx) {
|
||||
const isModule =
|
||||
typeof ctx.args[1] === "object" && ctx.args[1]?.type === "module";
|
||||
|
||||
ctx.args[0] = client.rewriteUrl(ctx.args[0], {
|
||||
destination: "sharedworker",
|
||||
isModule,
|
||||
});
|
||||
|
||||
if (ctx.args[1] && typeof ctx.args[1] === "string")
|
||||
ctx.args[1] = `${client.url.origin}@${ctx.args[1]}`;
|
||||
|
||||
if (ctx.args[1] && typeof ctx.args[1] === "object") {
|
||||
if (ctx.args[1].name) {
|
||||
ctx.args[1].name = `${client.url.origin}@${ctx.args[1].name}`;
|
||||
if ("SharedWorker" in self)
|
||||
client.Intercept(class extends SharedWorker {
|
||||
@Constructor("USVString", "optional (DOMString or WorkerOptions)")
|
||||
static konstructor(scriptURL: string, options?: string | WorkerOptions) {
|
||||
if (typeof options === "string") {
|
||||
return new this(
|
||||
client.rewriteUrl(scriptURL, { destination: "sharedworker" }),
|
||||
scoped(options)
|
||||
);
|
||||
}
|
||||
|
||||
const init = readWorkerOptions(options);
|
||||
|
||||
return new this(
|
||||
client.rewriteUrl(scriptURL, {
|
||||
destination: "sharedworker",
|
||||
isModule: init.type === "module",
|
||||
credentials: credentialsOption(init.credentials),
|
||||
}),
|
||||
{ ...init, name: scoped(init.name!) }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const worker = ctx.call();
|
||||
// const conn = new BareMuxConnection();
|
||||
if ("Worklet" in self)
|
||||
client.Intercept(class extends Worklet {
|
||||
@Arguments("USVString", "optional WorkletOptions")
|
||||
@Returns("Promise<undefined>")
|
||||
async addModule(
|
||||
moduleURL: string,
|
||||
options?: WorkletOptions
|
||||
): Promise<void> {
|
||||
const init = readWorkletOptions(options);
|
||||
|
||||
// (async () => {
|
||||
// const port = await conn.getInnerPort();
|
||||
// new client.native.MessagePort(worker.port).postMessage(
|
||||
// {
|
||||
// $scramjet$type: "baremuxinit",
|
||||
// port,
|
||||
// },
|
||||
// [port]
|
||||
// );
|
||||
// })();
|
||||
},
|
||||
});
|
||||
|
||||
client.Proxy("Worklet.prototype.addModule", {
|
||||
apply(ctx) {
|
||||
if (ctx.args[0]) ctx.args[0] = client.rewriteUrl(ctx.args[0]);
|
||||
},
|
||||
});
|
||||
// a worklet's script is always a module script - there is no
|
||||
// classic form to opt into - so the URL has to say so, or the
|
||||
// service worker treats it as a classic script and neither the
|
||||
// request's mode and credentials nor the rewrite it eventually
|
||||
// gets are the ones a module is owed.
|
||||
//
|
||||
// `destination` is deliberately left to the browser, which
|
||||
// reports `audioworklet` / `paintworklet`. Naming `script` here
|
||||
// would route the body through the module rewriter, and the
|
||||
// result references the `$scramjet$*` helpers - which a
|
||||
// `WorkletGlobalScope` does not have and cannot be given, so it
|
||||
// would turn a worklet that merely leaks URLs into one that
|
||||
// throws on load.
|
||||
return super.addModule(
|
||||
client.rewriteUrl(moduleURL, {
|
||||
isModule: true,
|
||||
credentials: credentialsOption(init.credentials),
|
||||
}),
|
||||
init
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,54 @@
|
||||
import { ScramjetClient } from "@client/index";
|
||||
import { GlobalScope, ScramjetClient } from "@client/index";
|
||||
import { String } from "@/shared/snapshot";
|
||||
import { Arguments, Returns, idlUSVString } from "@client/webidl";
|
||||
|
||||
export default function (client: ScramjetClient) {
|
||||
client.Proxy("importScripts", {
|
||||
apply(ctx) {
|
||||
for (const i in ctx.args) {
|
||||
const url = String(ctx.args[i]);
|
||||
ctx.args[i] = client.rewriteUrl(url);
|
||||
/**
|
||||
* The string one `urls` entry contributes.
|
||||
*
|
||||
* The spec's first step is the get trusted type compliant string algorithm,
|
||||
* which reads a TrustedScriptURL's data out of an internal slot rather than
|
||||
* through its `toString` - which a page can replace. On an engine that has
|
||||
* trusted types the declared union has already sorted the two cases out: a
|
||||
* string arrived converted to a USVString, and a non-string is a real
|
||||
* TrustedScriptURL. On an engine that has none there is nothing to
|
||||
* disambiguate against, so the union degrades to a passthrough and the
|
||||
* USVString conversion has to happen here instead.
|
||||
*/
|
||||
const scriptURLData = (url: TrustedScriptURL | string): string => {
|
||||
if (typeof url === "string") return url;
|
||||
if (client.box.ctors.TrustedScriptURL) {
|
||||
return String(new client.native.TrustedScriptURL(url).toString());
|
||||
}
|
||||
|
||||
return idlUSVString(url);
|
||||
};
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/workers.html#dom-workerglobalscope-importscripts
|
||||
client.Intercept(class extends GlobalScope {
|
||||
@Arguments("(TrustedScriptURL or USVString)... urls")
|
||||
@Returns("undefined")
|
||||
static importScripts(...urls: (TrustedScriptURL | string)[]): void {
|
||||
// every URL is resolved before any of them is fetched, which is the
|
||||
// spec's own order: it builds the whole urlRecords list, throwing a
|
||||
// "SyntaxError" DOMException for the first failure, and only then
|
||||
// starts fetching. rewriting inside the fetch loop would import the
|
||||
// first few scripts and *then* throw
|
||||
const rewritten: string[] = [];
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
// a URL that does not parse comes back untouched, so the native
|
||||
// still throws the SyntaxError, and about the page's own string
|
||||
rewritten[i] = client.rewriteUrl(scriptURLData(urls[i]));
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: the NetworkError for a script that would not load says "The
|
||||
// script at '<url>' failed to load", and by then the URL is the
|
||||
// rewritten one. It cannot be repaired here for the reason above,
|
||||
// and a DOMException's `message` is readonly, so it wants to be
|
||||
// fixed where the failure is produced rather than where it surfaces.
|
||||
// (The SyntaxError for an unparseable URL is already clean: such a
|
||||
// URL reaches the native exactly as the page wrote it.)
|
||||
new client.native.window(this).importScripts(...rewritten);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { basicTest, multiFrameTest } from "../../testcommon.ts";
|
||||
|
||||
/* eslint-disable quotes -- browser snippets are clearer as template literals */
|
||||
|
||||
// A BroadcastChannel is matched on the tuple (storage key, name), and every
|
||||
// proxied document shares the one real storage key - so the name is the only
|
||||
// thing keeping two sites' channels apart, and it has to carry the origin.
|
||||
//
|
||||
// https://html.spec.whatwg.org/multipage/web-messaging.html#broadcastchannel
|
||||
//
|
||||
// `llm/broadcastchannel.ts` covers the envelope and the delivery semantics.
|
||||
// This file covers only the scoping: that the prefix is invisible to the page,
|
||||
// and that it lands on the origin the *site* has rather than the one the
|
||||
// browser sees.
|
||||
//
|
||||
// The about:blank cases are the sharp edge. Such a document has no origin of
|
||||
// its own - it inherits its creator's - while its own URL serializes to the
|
||||
// opaque "null". Scoping on `client.url.origin` would therefore file every
|
||||
// about:blank frame on every site under one shared "null" namespace, which is
|
||||
// the same cross-site channel one level down. `ScramjetClient.siteOrigin`
|
||||
// answers with the creator's origin, recorded once when the client was
|
||||
// constructed, and these hold that in place.
|
||||
|
||||
const LOAD = `const load = (f) => new Promise((r) => { f.onload = r; setTimeout(r, 1000); });`;
|
||||
|
||||
export default [
|
||||
// The prefix is an implementation detail and must not be observable. An
|
||||
// origin cannot contain "@", which is what lets the un-scoping split on the
|
||||
// first one and leave a name the page put an "@" in intact.
|
||||
basicTest({
|
||||
name: "bcscope-name-round-trips",
|
||||
js: `
|
||||
for (const name of ["plain", "with@at", "@leading", "trailing@", "a@b@c", ""]) {
|
||||
const bc = new BroadcastChannel(name);
|
||||
assertEqual(bc.name, name, "name round-trips: " + JSON.stringify(name));
|
||||
bc.close();
|
||||
}
|
||||
|
||||
// and a name that looks like a scoped one must not be un-scoped twice
|
||||
const spoof = new BroadcastChannel(location.origin + "@spoof");
|
||||
assertEqual(
|
||||
spoof.name,
|
||||
location.origin + "@spoof",
|
||||
"a name that already looks scoped survives"
|
||||
);
|
||||
spoof.close();
|
||||
`,
|
||||
}),
|
||||
|
||||
// Two channels of the same name in one document still find each other -
|
||||
// the scoping must be a prefix both sides agree on, not a per-instance one.
|
||||
basicTest({
|
||||
name: "bcscope-same-document-still-connects",
|
||||
js: `
|
||||
const name = "bcscope-same-doc-" + Math.random();
|
||||
const a = new BroadcastChannel(name);
|
||||
const b = new BroadcastChannel(name);
|
||||
b.addEventListener("message", (event) => {
|
||||
assertEqual(event.data, "hello", "the message arrives unwrapped");
|
||||
a.close();
|
||||
b.close();
|
||||
pass();
|
||||
});
|
||||
a.postMessage("hello");
|
||||
`,
|
||||
autoPass: false,
|
||||
}),
|
||||
|
||||
// An about:blank frame inherits its embedder's origin, so its channels are
|
||||
// its embedder's channels. The channel is constructed through the child's
|
||||
// own interface object, so it is the child's client doing the scoping.
|
||||
basicTest({
|
||||
name: "bcscope-about-blank-inherits-embedder",
|
||||
js: `
|
||||
${LOAD}
|
||||
const name = "bcscope-inherit-" + Math.random();
|
||||
const mine = new BroadcastChannel(name);
|
||||
mine.addEventListener("message", (event) => {
|
||||
assertEqual(event.data, "from-about-blank", "the message crossed into the embedder");
|
||||
mine.close();
|
||||
pass();
|
||||
});
|
||||
|
||||
const f = document.createElement("iframe");
|
||||
document.body.appendChild(f);
|
||||
await load(f);
|
||||
const theirs = new f.contentWindow.BroadcastChannel(name);
|
||||
theirs.postMessage("from-about-blank");
|
||||
theirs.close();
|
||||
`,
|
||||
autoPass: false,
|
||||
}),
|
||||
|
||||
// Inheritance is recorded once per client, from its creator, rather than
|
||||
// walked at read time - so a chain of about:blank documents has to resolve
|
||||
// through each link's own captured answer. Two deep is enough to tell the
|
||||
// two designs apart: the inner frame's creator is itself a document with no
|
||||
// origin of its own.
|
||||
basicTest({
|
||||
name: "bcscope-nested-about-blank-inherits",
|
||||
js: `
|
||||
${LOAD}
|
||||
const name = "bcscope-nested-" + Math.random();
|
||||
const mine = new BroadcastChannel(name);
|
||||
mine.addEventListener("message", (event) => {
|
||||
assertEqual(event.data, "from-two-deep", "the message reached the top document");
|
||||
mine.close();
|
||||
pass();
|
||||
});
|
||||
|
||||
const outer = document.createElement("iframe");
|
||||
document.body.appendChild(outer);
|
||||
await load(outer);
|
||||
|
||||
const inner = outer.contentDocument.createElement("iframe");
|
||||
outer.contentDocument.body.appendChild(inner);
|
||||
await load(inner);
|
||||
|
||||
assertEqual(
|
||||
inner.contentWindow.location.href,
|
||||
"about:blank",
|
||||
"the inner frame really is about:blank too"
|
||||
);
|
||||
const theirs = new inner.contentWindow.BroadcastChannel(name);
|
||||
theirs.postMessage("from-two-deep");
|
||||
theirs.close();
|
||||
`,
|
||||
autoPass: false,
|
||||
}),
|
||||
|
||||
// The direction `llm/broadcastchannel.ts` doesn't cover: the embedder
|
||||
// posts, and the cross-origin child must not hear it.
|
||||
multiFrameTest({
|
||||
name: "bcscope-cross-origin-child-cannot-receive",
|
||||
root: {
|
||||
js: () => `
|
||||
setTimeout(() => {
|
||||
const bc = new BroadcastChannel("bcscope-xorigin-down");
|
||||
bc.postMessage("should-not-cross");
|
||||
bc.close();
|
||||
}, 100);
|
||||
`,
|
||||
subframes: [
|
||||
{
|
||||
originid: "cross",
|
||||
id: "listener",
|
||||
js: () => `
|
||||
const bc = new BroadcastChannel("bcscope-xorigin-down");
|
||||
let leaked = false;
|
||||
bc.addEventListener("message", () => {
|
||||
leaked = true;
|
||||
});
|
||||
setTimeout(() => {
|
||||
bc.close();
|
||||
if (leaked) {
|
||||
fail("a message from another origin reached this frame");
|
||||
} else {
|
||||
pass();
|
||||
}
|
||||
}, 600);
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
// The case the inheritance walk exists for: an about:blank frame inside
|
||||
// site A and one inside site B. Neither has an origin of its own, so
|
||||
// scoping on their own URLs files both under "null" and reopens exactly the
|
||||
// channel this is all meant to close - one level further down, where it is
|
||||
// much less likely to be noticed.
|
||||
multiFrameTest({
|
||||
name: "bcscope-about-blank-frames-do-not-cross-origins",
|
||||
root: {
|
||||
js: () => `
|
||||
${LOAD}
|
||||
// multiFrameTest serves a frame's script raw rather than through
|
||||
// runTest, so the async scope and the failure path are explicit
|
||||
(async () => {
|
||||
const f = document.createElement("iframe");
|
||||
document.body.appendChild(f);
|
||||
await load(f);
|
||||
|
||||
const bc = new f.contentWindow.BroadcastChannel("bcscope-blank-xorigin");
|
||||
let leaked = false;
|
||||
bc.addEventListener("message", () => {
|
||||
leaked = true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
bc.close();
|
||||
if (leaked) {
|
||||
fail(
|
||||
"an about:blank frame heard another site's about:blank frame"
|
||||
);
|
||||
} else {
|
||||
pass();
|
||||
}
|
||||
}, 900);
|
||||
})().catch((error) => fail(error && error.message));
|
||||
`,
|
||||
subframes: [
|
||||
{
|
||||
originid: "cross",
|
||||
id: "blanksender",
|
||||
js: () => `
|
||||
${LOAD}
|
||||
(async () => {
|
||||
const f = document.createElement("iframe");
|
||||
document.body.appendChild(f);
|
||||
await load(f);
|
||||
|
||||
// after the root has had time to install its listener
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const bc = new f.contentWindow.BroadcastChannel(
|
||||
"bcscope-blank-xorigin"
|
||||
);
|
||||
bc.postMessage("should-not-cross");
|
||||
bc.close();
|
||||
})().catch((error) => fail(error && error.message));
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,167 @@
|
||||
import { basicTest, serverTest } from "../../testcommon.ts";
|
||||
|
||||
/* eslint-disable quotes -- browser snippets are clearer as template literals */
|
||||
|
||||
// OPFS is per-origin storage, and under a proxy every site shares the proxy's
|
||||
// one origin. Scramjet hands each site a subdirectory of the real root instead,
|
||||
// which has to be indistinguishable from the root itself:
|
||||
//
|
||||
// - its `name` is `""`, and `name` is a *prototype* getter, so the stand-in
|
||||
// cannot carry an own `name` without becoming detectable
|
||||
// - the directory it picks has to be injective in the origin, or two sites
|
||||
// share a tree
|
||||
//
|
||||
// The handle is also structured-cloneable, so which realm reads `name` off it
|
||||
// need not be the realm that asked for it.
|
||||
|
||||
const differential = (name: string, js: string) =>
|
||||
basicTest({
|
||||
name: `opfs-${name}`,
|
||||
js: `
|
||||
const snapshot = async () => {
|
||||
try {
|
||||
return { value: await (${js}) };
|
||||
} catch (error) {
|
||||
return { error: error && error.name, message: error && error.message };
|
||||
}
|
||||
};
|
||||
assertConsistent(${JSON.stringify(name)}, await snapshot());
|
||||
`,
|
||||
});
|
||||
|
||||
// --- the handle can outlive the realm that asked for it ---------------------
|
||||
|
||||
const crossRealm = serverTest({
|
||||
name: "opfs-scoped-root-name-across-realms",
|
||||
autoPass: true,
|
||||
js: `
|
||||
const load = (f) => new Promise((r) => { f.onload = r; setTimeout(r, 4000); });
|
||||
const f = document.createElement("iframe");
|
||||
f.src = "/frame.html";
|
||||
document.body.appendChild(f);
|
||||
await load(f);
|
||||
|
||||
// asked for in the frame's realm...
|
||||
const root = await f.contentWindow.navigator.storage.getDirectory();
|
||||
assertEqual(root.name, "", "read back in the frame's own realm");
|
||||
|
||||
// ...and read through this realm's getter, which is a different client
|
||||
const outerGetter = Object.getOwnPropertyDescriptor(FileSystemHandle.prototype, "name").get;
|
||||
assertEqual(outerGetter.call(root), "", "read through the parent realm's getter");
|
||||
|
||||
// and the reverse direction
|
||||
const ours = await navigator.storage.getDirectory();
|
||||
const innerGetter = Object.getOwnPropertyDescriptor(
|
||||
f.contentWindow.FileSystemHandle.prototype, "name"
|
||||
).get;
|
||||
assertEqual(innerGetter.call(ours), "", "our root read through the frame's getter");
|
||||
`,
|
||||
start: async (server) => {
|
||||
server.on("request", (req, res) => {
|
||||
if (res.headersSent) return;
|
||||
const path = (req.url || "/").split("?")[0];
|
||||
if (path === "/" || path === "/script.js") return;
|
||||
if (path === "/frame.html") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end("<!DOCTYPE html><html><body>frame</body></html>");
|
||||
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("nf");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
// --- the stand-in root has to look like a root --------------------------
|
||||
|
||||
differential(
|
||||
"root-name",
|
||||
`navigator.storage.getDirectory().then((r) => r.name)`
|
||||
),
|
||||
|
||||
differential(
|
||||
"root-name-is-not-an-own-property",
|
||||
`navigator.storage.getDirectory().then((r) =>
|
||||
Object.getOwnPropertyDescriptor(r, "name") === null ||
|
||||
Object.getOwnPropertyDescriptor(r, "name") === undefined
|
||||
)`
|
||||
),
|
||||
|
||||
differential(
|
||||
"root-name-comes-off-the-prototype",
|
||||
`navigator.storage.getDirectory().then((r) => {
|
||||
// deleting an own data property would expose it; there is nothing to delete
|
||||
const before = r.name;
|
||||
delete r.name;
|
||||
return before + "|" + r.name + "|" + Object.keys(r).length;
|
||||
})`
|
||||
),
|
||||
|
||||
differential(
|
||||
"root-kind-and-prototype",
|
||||
`navigator.storage.getDirectory().then((r) =>
|
||||
r.kind + "|" +
|
||||
(Object.getPrototypeOf(r) === FileSystemDirectoryHandle.prototype) + "|" +
|
||||
(r instanceof FileSystemHandle)
|
||||
)`
|
||||
),
|
||||
|
||||
// --- a name getter still has to reject a forgery ------------------------
|
||||
|
||||
differential(
|
||||
"name-getter-brand-checks",
|
||||
`Promise.resolve().then(() => {
|
||||
const fake = Object.create(FileSystemHandle.prototype);
|
||||
try {
|
||||
fake.name;
|
||||
return "no throw";
|
||||
} catch (e) {
|
||||
return e.constructor.name;
|
||||
}
|
||||
})`
|
||||
),
|
||||
|
||||
// --- the scoped directory has to be stable and usable -------------------
|
||||
|
||||
basicTest({
|
||||
name: "opfs-round-trip-persists-across-calls",
|
||||
js: `
|
||||
const a = await navigator.storage.getDirectory();
|
||||
const fh = await a.getFileHandle("probe.txt", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.write("stored");
|
||||
await w.close();
|
||||
|
||||
// a second getDirectory() has to land in the same tree, or the scope
|
||||
// name is not a stable function of the origin
|
||||
const b = await navigator.storage.getDirectory();
|
||||
const again = await b.getFileHandle("probe.txt");
|
||||
assertEqual(await (await again.getFile()).text(), "stored", "the file survives a second getDirectory()");
|
||||
|
||||
const names = [];
|
||||
for await (const key of b.keys()) names.push(key);
|
||||
assertEqual(names.join(","), "probe.txt", "the scoped root lists only this site's entries");
|
||||
|
||||
await b.removeEntry("probe.txt");
|
||||
`,
|
||||
}),
|
||||
|
||||
basicTest({
|
||||
name: "opfs-resolve-treats-the-scoped-root-as-the-root",
|
||||
js: `
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle("sub", { create: true });
|
||||
const fh = await dir.getFileHandle("f.txt", { create: true });
|
||||
try {
|
||||
assertEqual((await root.resolve(fh)).join("/"), "sub/f.txt", "resolve is relative to the stand-in root");
|
||||
assertEqual((await root.resolve(root)).length, 0, "the root resolves to itself as an empty path");
|
||||
} finally {
|
||||
await root.removeEntry("sub", { recursive: true });
|
||||
}
|
||||
`,
|
||||
}),
|
||||
|
||||
crossRealm,
|
||||
];
|
||||
Reference in new issue
Block a user