diff --git a/toybox/labels.js b/toybox/labels.js index 2887b56..a448a1f 100644 --- a/toybox/labels.js +++ b/toybox/labels.js @@ -51,23 +51,3 @@ export const checkUrl = (labels, url) => { } }; -/** - * Do `a` and `b` give some key different values? Flower refuses to register - * a target that doesn't conflict with every other: lookups stay unambiguous. - * - * @param {Record} a - * @param {Record} b - */ -export const conflict = (a, b) => Object.entries(a).some(([k, v]) => k in b && b[k] !== v); - -/** - * Does `request` carry every label of `mount`, with the same values? - * - * Stricter than Flower's match (no conflict): an ingress client holds no - * constrained handle, so a label it omits must not match, or a secret - * label would protect nothing. - * - * @param {Record} mount - * @param {Record} request - */ -export const carries = (mount, request) => Object.entries(mount).every(([k, v]) => request[k] === v); diff --git a/toybox/mounts-sql.js b/toybox/mounts-sql.js new file mode 100644 index 0000000..e86de2a --- /dev/null +++ b/toybox/mounts-sql.js @@ -0,0 +1,53 @@ +/** + * @file The ingress's mounts, in SQL: which vat serves which label set. + * + * Labels sit one per row, so a lookup can use an index; `mount.labels` + * keeps the whole set as JSON. Request and mount labels arrive as JSON + * objects, split into rows by `json_each`. + */ + +export const MOUNT_SCHEMA = [ + "CREATE TABLE IF NOT EXISTS mount (vat TEXT PRIMARY KEY, labels TEXT)", + "CREATE TABLE IF NOT EXISTS mount_label (vat TEXT, key TEXT, value TEXT, PRIMARY KEY (vat, key))", + "CREATE INDEX IF NOT EXISTS mount_label_kv ON mount_label (key, value)", + // mounts made before mount_label existed + `INSERT OR IGNORE INTO mount_label + SELECT m.vat, j.key, j.value FROM mount m, json_each(m.labels) j + WHERE NOT EXISTS (SELECT 1 FROM mount_label l WHERE l.vat = m.vat)`, +]; + +/** + * Another mount (than ?2's) whose labels conflict with none of ?1's. + * Flower's registration rule: every mount must conflict with every other. + */ +export const OVERLAP = ` + SELECT 1 FROM mount m WHERE m.vat != ?2 AND NOT EXISTS ( + SELECT 1 FROM mount_label l JOIN json_each(?1) r ON r.key = l.key + WHERE l.vat = m.vat AND l.value != r.value) + LIMIT 1`; + +export const MOUNT = "INSERT OR REPLACE INTO mount VALUES (?1, ?2)"; +export const MOUNT_LABELS = "INSERT INTO mount_label SELECT ?1, key, value FROM json_each(?2)"; +export const UNMOUNT = "DELETE FROM mount WHERE vat = ?1"; +export const UNMOUNT_LABELS = "DELETE FROM mount_label WHERE vat = ?1"; + +/** + * The mount whose every label ?1 (the request's labels) carries, with the + * same value: the labels that match, counted per vat, against how many + * the vat has. A mount with no labels matches any request. + * + * Stricter than Flower's match (no conflict): an ingress client holds no + * constrained handle, so a label it omits must not match, or a secret + * label would protect nothing. + */ +export const LOOKUP = ` + SELECT m.vat, m.labels FROM mount m JOIN ( + SELECT l.vat, count(*) AS hits + FROM json_each(?1) r JOIN mount_label l ON l.key = r.key AND l.value = r.value + GROUP BY l.vat + ) h ON h.vat = m.vat + WHERE h.hits = (SELECT count(*) FROM mount_label WHERE vat = m.vat) + UNION ALL + SELECT m.vat, m.labels FROM mount m + WHERE NOT EXISTS (SELECT 1 FROM mount_label l WHERE l.vat = m.vat) + LIMIT 1`; diff --git a/toybox/supervisor.js b/toybox/supervisor.js index f78f175..2294776 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -4,7 +4,8 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { RpcTarget, newWebSocketRpcSession, newWorkersRpcResponse } from "capnweb"; import { E, Far } from "@endo/far"; import { makeClient, swissnumFromBytes } from "@endo/ocapn"; -import { carries, conflict, mergeLabels, urlLabels } from "./labels.js"; +import { mergeLabels, urlLabels } from "./labels.js"; +import { LOOKUP, MOUNT, MOUNT_LABELS, MOUNT_SCHEMA, OVERLAP, UNMOUNT, UNMOUNT_LABELS } from "./mounts-sql.js"; import { locationOf, makeRpcNetLayer } from "./netlayer.js"; import VAT_RUNTIME from "vat-runtime.txt"; @@ -282,14 +283,7 @@ export class Router extends WorkerEntrypoint { export class Directory extends DurableObject { constructor(ctx, env) { super(ctx, env); - ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS mount (vat TEXT PRIMARY KEY, labels TEXT)"); - } - - #mounts() { - return this.ctx.storage.sql - .exec("SELECT vat, labels FROM mount") - .toArray() - .map(({ vat, labels }) => ({ vat, labels: JSON.parse(labels) })); + for (const statement of MOUNT_SCHEMA) ctx.storage.sql.exec(statement); } /** @@ -299,22 +293,28 @@ export class Directory extends DurableObject { * @param {Record | null} labels */ mount(vat, labels) { - if (labels) { - const overlap = this.#mounts().find((m) => m.vat !== vat && !conflict(m.labels, labels)); - if (overlap) throw new Error("mount overlaps another: add a label whose value tells them apart"); - this.ctx.storage.sql.exec("INSERT OR REPLACE INTO mount VALUES (?, ?)", vat, JSON.stringify(labels)); - } else { - this.ctx.storage.sql.exec("DELETE FROM mount WHERE vat = ?", vat); - } + const { sql } = this.ctx.storage; + this.ctx.storage.transactionSync(() => { + sql.exec(UNMOUNT_LABELS, vat); + if (!labels) return void sql.exec(UNMOUNT, vat); + const json = JSON.stringify(labels); + if (sql.exec(OVERLAP, json, vat).toArray().length) { + throw new Error("mount overlaps another: add a label whose value tells them apart"); + } + sql.exec(MOUNT, vat, json); + sql.exec(MOUNT_LABELS, vat, json); + }); } /** - * The mount whose labels `request` carries, if any. + * The mount whose labels `request` carries, if any. Registration keeps + * mounts pairwise conflicting, so at most one can match. * * @param {Record} request */ lookup(request) { - return this.#mounts().find((m) => carries(m.labels, request)) ?? null; + const [row] = this.ctx.storage.sql.exec(LOOKUP, JSON.stringify(request)).toArray(); + return row ? { vat: row.vat, labels: JSON.parse(row.labels) } : null; } } diff --git a/toybox/test/mounts-sql.test.js b/toybox/test/mounts-sql.test.js new file mode 100644 index 0000000..0376e80 --- /dev/null +++ b/toybox/test/mounts-sql.test.js @@ -0,0 +1,53 @@ +/** @file The ingress's mount SQL, on node:sqlite: lookup, overlap, and mounts stored before mount_label. */ +import { DatabaseSync } from "node:sqlite"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { LOOKUP, MOUNT, MOUNT_LABELS, MOUNT_SCHEMA, OVERLAP } from "../mounts-sql.js"; + +const makeDb = () => { + const db = new DatabaseSync(":memory:"); + for (const statement of MOUNT_SCHEMA) db.exec(statement); + const mount = (vat, labels) => { + const json = JSON.stringify(labels); + db.prepare(MOUNT).run(vat, json); + db.prepare(MOUNT_LABELS).run(vat, json); + }; + const lookup = (request) => db.prepare(LOOKUP).get(JSON.stringify(request))?.vat ?? null; + const overlaps = (vat, labels) => !!db.prepare(OVERLAP).get(JSON.stringify(labels), vat); + return { db, mount, lookup, overlaps }; +}; + +test("a request reaches the mount whose every label it carries", () => { + const { mount, lookup } = makeDb(); + mount("a", { vat: "alice", key: "s3cret" }); + mount("b", { vat: "bob" }); + assert.equal(lookup({ vat: "alice", key: "s3cret", page: "2" }), "a"); + assert.equal(lookup({ vat: "alice" }), null); + assert.equal(lookup({ vat: "alice", key: "wrong" }), null); + assert.equal(lookup({ vat: "bob" }), "b"); + assert.equal(lookup({}), null); +}); + +test("a mount with no labels matches any request", () => { + const { mount, lookup } = makeDb(); + mount("only", {}); + assert.equal(lookup({ anything: "x" }), "only"); +}); + +test("overlap: a mount must conflict with every other", () => { + const { mount, overlaps } = makeDb(); + mount("a", { vat: "alice" }); + assert.equal(overlaps("b", { vat: "alice", n: "2" }), true); + assert.equal(overlaps("b", { app: "x" }), true); + assert.equal(overlaps("b", { vat: "bob" }), false); + assert.equal(overlaps("a", { vat: "alice", n: "2" }), false, "a vat's own mount doesn't count"); +}); + +test("mounts stored before mount_label are filled in when the schema runs", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE mount (vat TEXT PRIMARY KEY, labels TEXT)"); + db.prepare("INSERT INTO mount VALUES (?, ?)").run("old", JSON.stringify({ vat: "old" })); + for (const statement of MOUNT_SCHEMA) db.exec(statement); + assert.equal(db.prepare(LOOKUP).get(JSON.stringify({ vat: "old" }))?.vat, "old"); + assert.equal(db.prepare(LOOKUP).get(JSON.stringify({ vat: "other" })), undefined); +});