Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/guest-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' (`<draft>#id`). Its text may
name any address.

Of a release's other statements, the draft keeps those of the release,
Expand Down
53 changes: 52 additions & 1 deletion packages/application/src/releaseDrafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 => ({
Expand Down
30 changes: 26 additions & 4 deletions packages/application/src/releaseDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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' (`<draft>#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
Expand Down Expand Up @@ -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;
},
),
};
}
Expand Down
Loading