From 0ec77ab5e8ff32a2c10674e457b21d6868551ac7 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Wed, 4 Nov 2026 16:05:48 +0100 Subject: [PATCH] fix(solid): name an instance whose folder does not decode as written An instance registration with no title is named after its folder, and that name was decoded with decodeURIComponent, which throws on a lone percent sign, a non-hex escape or bytes that are not UTF-8, such as a Latin-1 folder name another app wrote as caf%E9. The address check accepts such addresses, so one registration like that in a type index made the whole instance list fail, and the workspace could not open until the registration was removed by hand. Adding such a folder by its address failed the same way. lastPathSegment now returns the segment as written when it does not decode, and listInstances leaves out a registration that cannot be read as an instance instead of failing the list with it. Refs: GHSA-4mmj-5fv6-9jjf --- docs/data-model.md | 6 +++ .../solid/src/mappers/instanceMapper.test.ts | 8 ++++ .../solid/src/solidInstanceRepository.test.ts | 39 +++++++++++++++++-- packages/solid/src/solidInstanceRepository.ts | 9 ++++- packages/solid/src/urls.test.ts | 8 ++++ packages/solid/src/urls.ts | 14 ++++++- 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 63f6e8d71..3be8996a1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -86,6 +86,12 @@ flowchart LR `HTTPS://Pod.example:443/kortlekar-å` is `https://pod.example/kortlekar-%C3%A5/`. Text that's no http(s) address isn't an instance to attach (`notAnInstanceNoMeta`). + An instance with no title is named after its folder, with its + percent-encoding undone; a name whose escapes don't decode, such as + `caf%E9`, is shown as written (`lastPathSegment` in + [urls.ts](../packages/solid/src/urls.ts)). A registration that still + can't be read as an instance is left out, and the rest of the list + still loads. - **Each class of an instance's data** has a registration of its own, titled with the instance's name, for other apps ([examples below](#registrations)): diff --git a/packages/solid/src/mappers/instanceMapper.test.ts b/packages/solid/src/mappers/instanceMapper.test.ts index b08aed50d..e13c79faa 100644 --- a/packages/solid/src/mappers/instanceMapper.test.ts +++ b/packages/solid/src/mappers/instanceMapper.test.ts @@ -31,6 +31,14 @@ describe("toInstance", () => { name: "main", }); }); + + it.each([ + ["https://pod.example/solid-memo/caf%E9/", "caf%E9"], + ["https://pod.example/x%/", "x%"], + ["https://pod.example/%ZZ/", "%ZZ"], + ])("names an untitled %s by its segment as written when it does not decode", (url, name) => { + expect(toInstance({ containerUrl: url, title: null })).toEqual({ url, name }); + }); }); const META = "https://pod.example/solid-memo/main/meta.ttl#it"; diff --git a/packages/solid/src/solidInstanceRepository.test.ts b/packages/solid/src/solidInstanceRepository.test.ts index 017466ade..9df04e62b 100644 --- a/packages/solid/src/solidInstanceRepository.test.ts +++ b/packages/solid/src/solidInstanceRepository.test.ts @@ -115,6 +115,25 @@ describe("listInstances", () => { ]); }); + it("lists the others when one registration does not map", async () => { + vi.mocked(locateTypeIndexes).mockResolvedValue({ + fetch: indexFetch, + inPlaces: () => true, + privateIndexUrl: PRIVATE_INDEX, + publicIndexUrl: null, + }); + vi.mocked(readInstanceRegistrations).mockResolvedValueOnce([ + { containerUrl: CONTAINER, title: "Main" }, + { containerUrl: "not an address", title: null }, + { containerUrl: "https://alice.example/x%E9/", title: null }, + ]); + + await expect(makeRepository().listInstances(WEBID)).resolves.toEqual([ + { url: CONTAINER, name: "Main" }, + { url: "https://alice.example/x%E9/", name: "x%E9" }, + ]); + }); + it("skips missing indexes and tolerates unreadable ones", async () => { vi.mocked(locateTypeIndexes).mockResolvedValue({ fetch: indexFetch, @@ -508,14 +527,14 @@ describe("createInstance", () => { }); describe("attachInstance", () => { - function metaDataset(withTitle: boolean) { - let thing = buildThing(createThing({ url: `${CONTAINER}meta.ttl#it` })) + function metaDataset(withTitle: boolean, container = CONTAINER) { + let thing = buildThing(createThing({ url: `${container}meta.ttl#it` })) .addIri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type", SM.Instance) .addDatetime(DCTERMS.created, new Date("2026-09-21T10:00:00.000Z")); if (withTitle) { thing = thing.addStringNoLocale(DCTERMS.title, "Attached"); } - return setThing(datasetFrom(`${CONTAINER}meta.ttl`), thing.build()); + return setThing(datasetFrom(`${container}meta.ttl`), thing.build()); } it("registers an existing instance, and nothing else of it, and returns its name", async () => { @@ -558,6 +577,20 @@ describe("attachInstance", () => { ).resolves.toEqual({ url: CONTAINER, name: "main" }); }); + it("names an untitled instance by its folder as written when the folder does not decode", async () => { + const container = "https://alice.example/x%E9/"; + vi.mocked(readDocument).mockResolvedValue(readOf(metaDataset(false, container))); + vi.mocked(ensureTypeIndex).mockResolvedValue({ url: PRIVATE_INDEX, fetch: indexFetch }); + + await expect( + makeRepository().attachInstance({ + webId: WEBID, + instanceUrl: container, + registrationTarget: "private", + }), + ).resolves.toEqual({ url: container, name: "x%E9" }); + }); + it("rejects a container without a readable meta.ttl", async () => { vi.mocked(readDocument).mockRejectedValue(new Error("404")); diff --git a/packages/solid/src/solidInstanceRepository.ts b/packages/solid/src/solidInstanceRepository.ts index bb72ad659..52a928a61 100644 --- a/packages/solid/src/solidInstanceRepository.ts +++ b/packages/solid/src/solidInstanceRepository.ts @@ -89,7 +89,14 @@ export function createSolidInstanceRepository({ if (!byUrl.has(url)) byUrl.set(url, registration); } } - return [...byUrl.values()].map(toInstance); + // One registration that does not map is left out, not the list. + return [...byUrl.values()].flatMap((registration) => { + try { + return [toInstance(registration)]; + } catch { + return []; + } + }); }, async getRegistrationOptions(webId) { diff --git a/packages/solid/src/urls.test.ts b/packages/solid/src/urls.test.ts index 8ca486068..7f570d5ac 100644 --- a/packages/solid/src/urls.test.ts +++ b/packages/solid/src/urls.test.ts @@ -24,6 +24,14 @@ describe("lastPathSegment", () => { expect(lastPathSegment("https://pod.example/mem%C3%B3/")).toBe("memó"); }); + it.each([ + ["https://pod.example/solid-memo/caf%E9/", "caf%E9"], + ["https://pod.example/x%/", "x%"], + ["https://pod.example/%ZZ/", "%ZZ"], + ])("keeps %s's last segment as written when it does not decode", (url, segment) => { + expect(lastPathSegment(url)).toBe(segment); + }); + it("falls back to the input for a root URL", () => { expect(lastPathSegment("https://pod.example/")).toBe("https://pod.example/"); }); diff --git a/packages/solid/src/urls.ts b/packages/solid/src/urls.ts index e896adb77..f5936b840 100644 --- a/packages/solid/src/urls.ts +++ b/packages/solid/src/urls.ts @@ -4,9 +4,19 @@ export { ensureTrailingSlash } from "@solid-memo/domain/instanceLayout"; -/** Last path segment of a URL, e.g. "japanese" for ".../solid-memo/japanese/". */ +/** + * Last path segment of a URL, e.g. "japanese" for ".../solid-memo/japanese/", + * with its percent-encoding undone. A segment whose escapes do not decode + * (a lone `%`, `%ZZ`, or bytes that are not UTF-8 such as `%E9`) is + * returned as written: an address a pod holds may carry any of them. + */ export function lastPathSegment(url: string): string { const segments = new URL(url).pathname.split("/").filter((s) => s.length > 0); const last = segments[segments.length - 1]; - return last === undefined ? url : decodeURIComponent(last); + if (last === undefined) return url; + try { + return decodeURIComponent(last); + } catch { + return last; + } }