diff --git a/docs/guest-mode.md b/docs/guest-mode.md index 620d7462..df25b953 100644 --- a/docs/guest-mode.md +++ b/docs/guest-mode.md @@ -80,7 +80,9 @@ brings one in, for a guest and a logged-in user alike: address in it, such as a card's picture or what it's about (`releaseLinksGuestPod`); - so is a release made a [draft](studio.md#drafts), from a file or as - a next version; + a next version. So is a next version whose series or publisher, as + the documents they're described in say, links to it, such as by the + earlier versions the series lists (`dcat:hasVersion`); - so is a [deck file](studio.md#import-and-export) with such an address (`deckFileLinksGuestPod`). The deck's, its cards' and their documents' own addresses may name it: the import gives them new diff --git a/docs/studio.md b/docs/studio.md index fcde4fcb..612d6933 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -1606,7 +1606,12 @@ anywhere else is shown by no editor and checked by no rule, but would be published with the draft, so such a release is refused too (`releaseForeignPart`). So is one that links to the guest's pod by an IRI (`releaseLinksGuestPod`): a guest's draft of it couldn't be kept -([guest-mode.md](guest-mode.md#what-comes-from-outside)). Its text may +([guest-mode.md](guest-mode.md#what-comes-from-outside)). For a next +version, the draft as it's made is held to this too. It takes what the +documents its series and publisher are described in say, such as the +earlier versions the series lists (`dcat:hasVersion`). It's refused, +with nothing written, when it links to the guest's pod by an IRI other +than its own URL or its own subjects' (`#id`). Its text may name any address. Of a release's other statements, the draft keeps those of the release, diff --git a/packages/application/src/releaseDrafts.test.ts b/packages/application/src/releaseDrafts.test.ts index 21a7ee75..2ae54689 100644 --- a/packages/application/src/releaseDrafts.test.ts +++ b/packages/application/src/releaseDrafts.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { AppError } from "@solid-memo/domain/appError"; -import { GUEST_INSTANCE_URL } from "@solid-memo/domain/guest"; +import { GUEST_INSTANCE_URL, GUEST_ORIGIN, GUEST_WEBID } from "@solid-memo/domain/guest"; import type { Card, Deck } from "@solid-memo/domain/deck"; import { draftSummaryOf, @@ -460,6 +460,57 @@ describe("createReleaseDraft", () => { expect(repository.create).toHaveBeenCalledOnce(); }); + it("refuses a next version whose release's linked documents link to the guest's pod, writing nothing (GHSA-vrq3-87m3-6rgg)", async () => { + const { useCases, repository, created } = setUp(); + const OUTSIDE = "https://elsewhere.example/decks/solid/v1.ttl"; + const SERIES = "https://elsewhere.example/decks/index.ttl#solid"; + const outside = rebaseDraft(courseDraft(), OUTSIDE); + // The release itself names nothing at the guest's pod: its series is described in another document. + const release = { + ...outside, + root: { ...outside.root, inSeries: SERIES, isVersionOf: SERIES }, + triples: outside.triples.filter((triple) => !triple.subject.endsWith("#series")), + }; + const iri = (value: string) => ({ kind: "iri" as const, value }); + const series: DraftTriple = { + subject: SERIES, + predicate: RDF_TYPE, + object: iri("http://www.w3.org/ns/dcat#DatasetSeries"), + }; + const linking: DraftTriple[] = [ + series, + { + subject: SERIES, + predicate: "http://www.w3.org/ns/dcat#theme", + object: iri(`${GUEST_ORIGIN}x`), + }, + { + subject: SERIES, + predicate: "http://purl.org/dc/terms/publisher", + object: iri("HTTPS://GUEST.solid-memo.invalid:443/me"), + }, + ]; + const refused = new AppError("releaseLinksGuestPod", { url: OUTSIDE }); + for (const instance of [GUEST_INSTANCE_URL, INSTANCE]) { + repository.readRelease.mockResolvedValueOnce(release); + repository.readLinked.mockResolvedValueOnce(linking); + await expect( + useCases.createReleaseDraft(instance, { kind: "nextVersionOf", url: OUTSIDE }, GUEST_WEBID), + ).rejects.toEqual(refused); + } + expect(repository.create).not.toHaveBeenCalled(); + // A guest's next version names its own place at the guest's pod, which a copy of it moves. + repository.readRelease.mockResolvedValueOnce(release); + repository.readLinked.mockResolvedValueOnce([series]); + await useCases.createReleaseDraft( + GUEST_INSTANCE_URL, + { kind: "nextVersionOf", url: OUTSIDE }, + GUEST_WEBID, + ); + expect(created().url).toBe(draftUrlOf(GUEST_INSTANCE_URL, "solid", 2)); + expect(created().triples).toContainEqual(series); + }); + it("refuses a release with a card outside it, writing nothing, but not one of its own its records do not hold", async () => { const { useCases, repository } = setUp(); const typed = (subject: string, type: string): DraftTriple => ({ diff --git a/packages/application/src/releaseDrafts.ts b/packages/application/src/releaseDrafts.ts index 67f92868..80c7c781 100644 --- a/packages/application/src/releaseDrafts.ts +++ b/packages/application/src/releaseDrafts.ts @@ -6,7 +6,7 @@ import { AppError } from "@solid-memo/domain/appError"; import type { Deck } from "@solid-memo/domain/deck"; import type { KeptDocuments } from "@solid-memo/domain/instance"; import { catalogUrlOf, isDocumentIn } from "@solid-memo/domain/instanceLayout"; -import { draftAddressesGuest, isGuestUrl } from "@solid-memo/domain/guest"; +import { draftAddressesGuest, isGuestPodIri, isGuestUrl } from "@solid-memo/domain/guest"; import { DECK_FILE_ACCEPT, deckFileFormatOf } from "@solid-memo/domain/deckFile"; import { shown, tidiedStated, type LangText } from "@solid-memo/domain/langText"; import { deckToDraft } from "@solid-memo/domain/release/deckToDraft"; @@ -269,6 +269,23 @@ function draftable(read: ReleaseDraft): ReleaseDraft { return ownStatements(release); } +/** + * Whether a draft links to the guest's pod by an IRI other than its own + * or one of its subjects' (`#id`), the only ones a copy of it + * moves (copyReleaseDraft): a guest's draft that does could not be kept + * (GHSA-hc3p-fpwj-fvjp). A next version takes statements from the + * documents its release links to, which the release's own check + * (draftable) does not see (GHSA-vrq3-87m3-6rgg). + */ +function linksGuestPodBeyond(draft: ReleaseDraft): boolean { + let links = false; + rebaseDraft(draft, draft.url, (iri) => { + links ||= isGuestPodIri(iri) && iri !== draft.url && !iri.startsWith(`${draft.url}#`); + return iri; + }); + return links; +} + /** * Whether a statement types the release an agent or distribution, or * names it: it can have no record of those, so the Studio would show no @@ -632,12 +649,17 @@ export function createReleaseDraftUseCases({ draft: await create( instanceUrl, { title: release.root.title ?? {}, ...(name === undefined ? {} : { name }), version }, - (url) => - nextVersionDraft( + (url) => { + const draft = nextVersionDraft( inItsPlace(release, url), url, untaken(linked, takenBy(release, url)), - ), + ); + // What the linked documents state is checked as the draft has it, as the release was (draftable). + if (linksGuestPodBeyond(draft)) + throw new AppError("releaseLinksGuestPod", { url: release.url }); + return draft; + }, ), }; }