From 174bb7cd1778d2d15b433f22da52d9f6f820beea Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Wed, 11 Nov 2026 23:58:15 +0100 Subject: [PATCH] fix(solid): never count a user's storage as an instance's own folder An older deck document that does not say it is the deck's is taken as the deck's in the instance's own folder: the next write states it, the data check does not list it, and a removal deletes it without reading it. A pod's or a storage's root is never such a folder, as it holds the user's other data too. But the guard knew a storage's root only when the server typed it, and a server that holds many users' pods on one origin may leave their roots untyped. An instance kept at the root of the user's pod there took every older document in the pod for the deck's, though the user's profile names that folder as a storage. The instance's folder is now not its own when it is one of the user's storages or a folder above one, whatever the case of the host, its default port or a trailing slash, as well as when the server shows it as a storage's root. When the user's storages can't be found, a write fails and can be retried, and the data check and a removal read the document and take it for unclaimed unless it says it is the deck's. Refs: GHSA-pq2f-cr8r-9jqc --- docs/data-model.md | 12 +++-- packages/solid/src/deckPlace.test.ts | 79 ++++++++++++++++++++++++++++ packages/solid/src/deckPlace.ts | 28 +++++++--- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 3c0f9bdd..d04cc2f8 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -499,9 +499,15 @@ No document Solid Memo names has any. and `isDraftDocument` in [deckPlace.ts](../packages/solid/src/deckPlace.ts)). - **An older document that doesn't say so** (from an earlier version, a - repair or a copy) is the deck's in the instance's own folder, unless - that folder is the root of a pod or a storage (typed `pim:Storage`). - There the next write states it. One that names any other catalog's + repair or a copy) is the deck's in the instance's own folder. That + folder isn't the instance's own when it's the root of a pod or a + storage, or a folder above one. Such a root is one of the user's + storages (as above), which a server may leave untyped, or a root the + server shows (typed `pim:Storage`, or named by a Linked Web Storage + server). In the instance's own folder the next write states it. When + the profile can't be read, a write fails and can be retried. The data + check and a removal then read the document, and take it for unclaimed + unless it says it's the deck's. One that names any other catalog's entry (such as a nested instance's) isn't the deck's: the app never adds a second owner on its own, and refuses the write (`deckDocumentUnclaimed`). The data check doesn't read a document in diff --git a/packages/solid/src/deckPlace.test.ts b/packages/solid/src/deckPlace.test.ts index 7bea5d95..e50812ec 100644 --- a/packages/solid/src/deckPlace.test.ts +++ b/packages/solid/src/deckPlace.test.ts @@ -1074,6 +1074,85 @@ describe("an instance at the pod's root, laid out flat", () => { ).resolves.toEqual([]); }); + describe("on a server that holds the pods of many on one origin and types none of their roots", () => { + const pod = `${ROOT}alice/`; + const SECRET = `<#entry> "secret" .`; + const at = (instance: string, document = DIARY) => ({ + url: `${instance}catalog.ttl#deck-1`, + title: { en: "Spanish" }, + cardsDocumentUrl: document, + reviewsDocumentUrl: document, + }); + + /** A guard over a server that types no folder and holds `documents` (address to Turtle), each request kept in `asked`. */ + function untyped( + userStorages: () => Promise, + documents: Record = { [DIARY]: SECRET }, + ) { + const asked: string[] = []; + const fetch = (async (input, init) => { + const method = init?.method ?? "GET"; + asked.push(`${method} ${String(input)}`); + const body = documents[String(input)]; + if (method === "HEAD" || body === undefined) return new Response(null, { status: 200 }); + return new Response(body, { headers: { "Content-Type": "text/turtle", ETag: '"e1"' } }); + }) as typeof globalThis.fetch; + return { guard: createDeckDocumentGuard(fetch, userStorages), asked }; + } + + it("never marks an older document as a deck's by itself at a pod's root the user's storages name", async () => { + const diary = await datasetOfTurtle(DIARY, SECRET); + // However the storage is spelled: host case, default port, no trailing slash. + for (const storage of [pod, "https://POD.example:443/alice"]) { + const { guard, asked } = untyped(async () => [storage]); + await expect(guard.standing(at(pod), DIARY, diary)).resolves.toBe("unclaimed"); + await expect(guard.writable(at(pod), DIARY, diary)).rejects.toMatchObject({ + code: "deckDocumentUnclaimed", + }); + await expect(guard.removal(at(pod), DIARY)).resolves.toBe("unclaimed"); + await expect(guard.unclaimed(at(pod))).resolves.toEqual([DIARY]); + // The user's storages decide it: the folder is never asked. + expect(asked.filter((one) => one.startsWith("HEAD"))).toEqual([]); + } + }); + + it("never marks one as a deck's in a folder above the user's storage either", async () => { + const diary = await datasetOfTurtle(DIARY, SECRET); + const { guard } = untyped(async () => [pod]); + const above = `${ROOT}pods/`; + const inside = `${above}alice/private/diary.ttl`; + const read = await datasetOfTurtle(inside, `<#entry> "x" .`); + const nested = untyped(async () => [`${above}alice/`]); + await expect(nested.guard.standing(at(above, inside), inside, read)).resolves.toBe( + "unclaimed", + ); + // An instance in its own folder in the user's pod still marks an older document as the deck's. + const own = `${pod}memo/`; + const mine = `${own}decks/deck-1.ttl`; + const older = await datasetOfTurtle(mine, cardTurtle("card-1", "uno")); + await expect(guard.standing(at(own, mine), mine, older)).resolves.toBe("adoptable"); + await expect(guard.removal(at(own, mine), mine)).resolves.toBe("deletable"); + await expect(guard.unclaimed(at(own, mine))).resolves.toEqual([]); + await expect(guard.standing(at(pod), DIARY, diary)).resolves.toBe("unclaimed"); + }); + + it("leaves an older document no deck's when the user's storages cannot be found, and refuses a write for now", async () => { + const own = `${pod}memo/`; + const mine = `${own}decks/deck-1.ttl`; + const older = await datasetOfTurtle(mine, cardTurtle("card-1", "uno")); + const { guard } = untyped(() => Promise.reject(new Error("profile unreadable")), { + [mine]: cardTurtle("card-1", "uno"), + }); + await expect(guard.standing(at(own, mine), mine, older)).resolves.toBe("unclaimed"); + await expect(guard.writable(at(own, mine), mine, older)).rejects.toMatchObject({ + code: "cannotCheck", + vars: { url: own, status: 0 }, + }); + await expect(guard.removal(at(own, mine), mine)).resolves.toBe("unclaimed"); + await expect(guard.unclaimed(at(own, mine))).resolves.toEqual([mine]); + }); + }); + it("asks again a folder whose storage description can't be read", async () => { const p = await pod(); let heads = 0; diff --git a/packages/solid/src/deckPlace.ts b/packages/solid/src/deckPlace.ts index f0c65141..343d56f3 100644 --- a/packages/solid/src/deckPlace.ts +++ b/packages/solid/src/deckPlace.ts @@ -177,10 +177,25 @@ export function createDeckDocumentGuard( /** * Whether the folder is an instance's own: not a pod's root (`/`), nor - * a storage's (isStorageRoot), which hold the user's other data too. + * a storage's root or a folder above one, which hold the user's other + * data too. A storage's root is one of the user's storages + * (userStorages), which a server that holds many users' pods on one + * origin may leave untyped, or one the server shows (isStorageRoot). + * A storage list the pod does not answer for leaves it not shown to + * be: `lenient`, so the instance's documents are read and kept as + * they are; otherwise cannotCheck, so that a write may be tried again. */ - async function isDedicatedFolder(folder: string): Promise { - return new URL(folder).pathname !== "/" && !(await isStorageRoot(folder)); + async function isDedicatedFolder(folder: string, lenient: boolean): Promise { + if (new URL(folder).pathname === "/") return false; + let storages: readonly string[]; + try { + storages = await userStorages(); + } catch { + if (lenient) return false; + throw new AppError("cannotCheck", { url: folder, status: 0 }); + } + if (storages.some((storage) => isInFolder(ensureTrailingSlash(storage), folder))) return false; + return !(await isStorageRoot(folder)); } /** @@ -279,7 +294,7 @@ export function createDeckDocumentGuard( if (dataset !== null && ownsDocument(partOfOf(dataset, url), deck.url)) return "owned"; if (dataset === null) return "adoptable"; if (deckBackLinksOf(dataset, url).length > 0) return "unclaimed"; - return (await isDedicatedFolder(instance)) ? "adoptable" : "unclaimed"; + return (await isDedicatedFolder(instance, lenient)) ? "adoptable" : "unclaimed"; } /** @@ -299,7 +314,7 @@ export function createDeckDocumentGuard( const found: string[] = []; for (const url of [deck.cardsDocumentUrl, deck.reviewsDocumentUrl]) { if (found.some((other) => isSameUrl(other, url))) continue; - if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance))) continue; + if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance, true))) continue; if (await unclaimedAsRead([deck], url)) found.push(url); } return found; @@ -383,7 +398,8 @@ export function createDeckDocumentGuard( async removal(deck: DeckPlace, url: string): Promise<"deletable" | "unclaimed" | "unreadable"> { const instance = instanceOf(deck); if (isDraftDocument(url)) return "unclaimed"; - if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance))) return "deletable"; + if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance, true))) + return "deletable"; const dataset = await readToRemove(deck, url); if (dataset === UNREADABLE) return "unreadable"; return (await standing(deck, url, dataset)) === "unclaimed" ? "unclaimed" : "deletable";