From dedc6b48279919262d343dd94037c0dbc868599c Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 22:05:54 +0100 Subject: [PATCH] fix(domain): list only a release's own versions in its series A release file made a draft kept its own series as the file stated it, with every version the series listed and what the file stated of each, and kept the version the file followed, with what the file stated of it. No Studio screen shows a series' versions, so publishing the draft put them in the creator's release unseen, where learners read them, with their notes, as earlier or later versions of the creator's series. Each next version carried them on, and one stated at the address of the next version kept that version from being published there. A file's own series now lists no version but the release, as a first import starts a series of the creator's own, and keeps a first version when the file describes the series. Nor does the release follow a version, as the one the file follows is not the creator's, so with nothing to compare it with, all of the draft is new. A release made a draft as the base of its next version keeps in its series only the release, the version it follows and the earlier versions in its folder. The next version copies only those earlier versions from an index too. Whichever folder the release is in, the draft keeps nothing stated of its default place in the instance, or of a later version there, where it and its next versions are published. What only a dropped statement led to is dropped with it. Refs: GHSA-jp29-vf56-5g4g --- docs/deck-library.md | 9 +- docs/studio.md | 27 +++- .../application/src/releaseDrafts.test.ts | 152 ++++++++++++++++++ packages/application/src/releaseDrafts.ts | 47 ++++-- .../domain/src/release/releasePlace.test.ts | 41 +++++ packages/domain/src/release/releasePlace.ts | 42 +++++ .../domain/src/release/releaseToDraft.test.ts | 39 +++++ packages/domain/src/release/releaseToDraft.ts | 72 ++++++++- .../domain/src/release/releaseVersion.test.ts | 94 ++++++++++- packages/domain/src/release/releaseVersion.ts | 41 ++++- .../src/solidReleaseDraftRepository.test.ts | 47 ++++++ 11 files changed, 582 insertions(+), 29 deletions(-) diff --git a/docs/deck-library.md b/docs/deck-library.md index de94bb2cf..3f86d6eb6 100644 --- a/docs/deck-library.md +++ b/docs/deck-library.md @@ -842,12 +842,15 @@ Such a release is made from a draft in the creator's pod but a next version's draft copies the series, the publisher and the earlier versions from the index (`readLinked`, `linkedDescription`). It copies only what a release needs: what an index says of a series, - and a publisher's class and name. A release downloaded for `decks/` - from a next version carries those statements. + and a publisher's class and name. Of the earlier versions, it copies + only those in the release's folder, and the version it follows. A + release downloaded for `decks/` from a next version carries those + statements. - A `decks/` release made a draft and published again comes back as it was, but for what publishing sets (a test of its own, [testing.md](testing.md#strategy-per-layer)). Imported from a file in - the Studio, it starts a series of its own instead of the index's. + the Studio, it starts a series of its own instead of the index's, + and follows no version. ## In the app diff --git a/docs/studio.md b/docs/studio.md index 1547e0578..005a4346c 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -1577,17 +1577,38 @@ A new draft starts from release only claims: on another origin, or in another pod on the same server. The release check reports it (`seriesNotPrevious`), and publishing makes the new release no version of it. + - The series lists only the release's own versions + (`withOwnVersions`): the release, the version it follows, + and earlier versions in its folder (`/v.ttl`). Any other + version the series lists is dropped. - **a release saved as a file**, Turtle or JSON-LD, as it is (`releaseToDraft` in [releaseToDraft.ts](../packages/domain/src/release/releaseToDraft.ts)), - but for its series (`withOwnSeries`). A series that is one of the - file's own subjects (`#id`) moves with it. One anywhere + but for its series and the version it follows (`withOwnSeries`). It + follows none (no `dcat:prev` or `dcat:previousVersion`): the + version the file follows isn't the creator's, and what only that + link leads to is dropped too. With nothing to compare it with, all + of it is new, and there's no **Changes** tab. A series that is one + of the file's own subjects (`#id`) moves with it. One anywhere else, a library's index or another release, belongs to other releases, and the draft, once published, would be their newest version. The draft starts a series of its own (`#series`, or `#series-2`, … when that id is taken) in its place, as `dcat:inSeries` and `dcat:isVersionOf`, and what the file states of the other series - is dropped. The release screen shows the series and the version. + is dropped. Either way, the series lists no version but the release: + the file's other versions aren't the creator's. What only those + versions lead to is dropped too. A series the file describes keeps + a first version: the release, when the file names no other. The + release screen shows the series and the version. + +In both cases, the draft keeps nothing the release states of its +default place (`unplaced` in `releaseDrafts.ts`). That's the +instance's `releases//v.ttl`, by the draft's name, with M its +version or higher: where the draft and its next versions are published +unless the creator picks another folder. That place is the draft's +wherever the release is, in the library, in another pod or in a file. +Publishing the draft or a next version of it there would otherwise be +refused (`releaseForeignStatement`). Either way, the draft doesn't keep a statement that one of its documents is `dcterms:isPartOf` anything, which to a deck would say the diff --git a/packages/application/src/releaseDrafts.test.ts b/packages/application/src/releaseDrafts.test.ts index 2ae546890..1a683f48e 100644 --- a/packages/application/src/releaseDrafts.test.ts +++ b/packages/application/src/releaseDrafts.test.ts @@ -396,6 +396,158 @@ describe("createReleaseDraft", () => { expect(named).not.toContain("_:b0"); }); + it("keeps no version a release's own series lists but its own, nor what only those lead to (GHSA-jp29-vf56-5g4g)", async () => { + const { useCases, repository, created } = setUp(); + const DCAT = "http://www.w3.org/ns/dcat#"; + const SERIES = `${V1}#series`; + // The address its next version is published at, and one elsewhere, each listed with notes of the file's choosing. + const NEXT = `${INSTANCE}releases/solid/v2.ttl`; + const ELSEWHERE = "https://attacker.example/x/v2.ttl"; + const notes = (subject: string): DraftTriple => ({ + subject, + predicate: "http://www.w3.org/ns/adms#versionNotes", + object: { kind: "literal", value: "Planted", language: "", datatype: XSD_STRING }, + }); + const crafted: ReleaseDraft = { + ...v1, + triples: [ + ...v1.triples, + { subject: SERIES, predicate: `${DCAT}hasVersion`, object: { kind: "iri", value: V1 } }, + { subject: SERIES, predicate: `${DCAT}hasVersion`, object: { kind: "iri", value: NEXT } }, + { + subject: SERIES, + predicate: `${DCAT}hasVersion`, + object: { kind: "iri", value: ELSEWHERE }, + }, + { subject: SERIES, predicate: `${DCAT}first`, object: { kind: "iri", value: ELSEWHERE } }, + notes(NEXT), + notes(`${NEXT}#x`), + notes(ELSEWHERE), + { + subject: ELSEWHERE, + predicate: "http://purl.org/dc/terms/publisher", + object: { kind: "blank", value: "b0" }, + }, + notes("_:b0"), + { + subject: `${V1}#q-a-1a`, + predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso", + object: { kind: "iri", value: `${NEXT}#x` }, + }, + ], + }; + const planted = (draft: ReleaseDraft) => + draft.triples.filter( + (triple) => + [NEXT, `${NEXT}#x`, ELSEWHERE, "_:b0"].includes(triple.subject) || + (triple.subject.endsWith("#series") && [NEXT, ELSEWHERE].includes(triple.object.value)), + ); + repository.parseRelease.mockResolvedValueOnce(crafted); + await useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID); + const imported = created(); + expect(planted(imported)).toEqual([]); + expect(imported.triples.filter((triple) => triple.predicate === `${DCAT}hasVersion`)).toEqual([ + { + subject: `${imported.url}#series`, + predicate: `${DCAT}hasVersion`, + object: { kind: "iri", value: imported.url }, + }, + ]); + // A release published with them carries none into its next version, which its own place takes. + repository.readRelease.mockResolvedValueOnce(crafted); + await useCases.createReleaseDraft(INSTANCE, { kind: "nextVersionOf", url: V1 }, WEB_ID); + const next = created(); + expect(planted(next)).toEqual([]); + expect(next.triples).toContainEqual({ + subject: SERIES, + predicate: `${DCAT}hasVersion`, + object: { kind: "iri", value: V1 }, + }); + expect(next.triples).toContainEqual({ + ...crafted.triples.at(-1)!, + subject: `${next.url}#q-a-1a`, + }); + }); + + it("keeps nothing a release states of where its draft is published, wherever the release is (GHSA-jp29-vf56-5g4g)", async () => { + const { useCases, repository, created } = setUp(); + const notes = (subject: string): DraftTriple => ({ + subject, + predicate: "http://www.w3.org/ns/adms#versionNotes", + object: { kind: "literal", value: "Planted", language: "", datatype: XSD_STRING }, + }); + /** The course at `url`, stating what it may of the instance's `releases/solid/v.ttl` and later. */ + const stating = (url: string, from: number): ReleaseDraft => { + const at = (version: number) => `${INSTANCE}releases/solid/v${version}.ttl`; + const release = rebaseDraft(courseDraft(), url); + return { + ...release, + triples: [ + ...release.triples, + { + subject: `${url}#q-a-1a`, + predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso", + object: { kind: "iri", value: `${at(from)}#x` }, + }, + notes(at(from)), + notes(`${at(from)}#x`), + notes(at(from + 1)), + { + subject: at(from), + predicate: "http://purl.org/dc/terms/publisher", + object: { kind: "blank", value: "b0" }, + }, + notes("_:b0"), + { + subject: `${url}#q-a-1a`, + predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso", + object: { kind: "iri", value: at(from - 1) }, + }, + notes(at(from - 1)), + ], + }; + }; + const placed = (draft: ReleaseDraft) => + draft.triples.filter( + ({ subject }) => + (subject.startsWith(`${INSTANCE}releases/solid/`) && + !subject.includes("/v0.ttl") && + !subject.endsWith("/v1.ttl")) || + subject === "_:b0", + ); + // The next version of a release in another pod is published in the instance's releases/ all the same. + repository.readRelease.mockResolvedValueOnce( + stating("https://elsewhere.example/releases/solid/v1.ttl", 2), + ); + await useCases.createReleaseDraft( + INSTANCE, + { kind: "nextVersionOf", url: "https://elsewhere.example/releases/solid/v1.ttl" }, + WEB_ID, + ); + const next = created(); + expect(next.url).toBe(`${INSTANCE}drafts/solid/v2/release.ttl`); + expect(placed(next)).toEqual([]); + // What it states of an address before its place, and a link to its place, are kept. + expect(next.triples).toContainEqual(notes(`${INSTANCE}releases/solid/v1.ttl`)); + expect(next.triples).toContainEqual({ + subject: `${next.url}#q-a-1a`, + predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso", + object: { kind: "iri", value: `${INSTANCE}releases/solid/v2.ttl#x` }, + }); + // So is a file's, imported as version 1, published at the instance's releases/solid/v1.ttl. + repository.parseRelease.mockResolvedValueOnce( + stating("https://elsewhere.example/x/solid/v1.ttl", 1), + ); + await useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID); + const imported = created(); + expect(imported.url).toBe(`${INSTANCE}drafts/solid/v1/release.ttl`); + expect( + imported.triples.filter( + ({ subject }) => subject.startsWith(`${INSTANCE}releases/solid/v`) || subject === "_:b0", + ), + ).toEqual([notes(`${INSTANCE}releases/solid/v0.ttl`)]); + }); + it("refuses a release that names a subject by an id a draft cannot keep, writing nothing", async () => { const { useCases, repository } = setUp(); const odd = { diff --git a/packages/application/src/releaseDrafts.ts b/packages/application/src/releaseDrafts.ts index 80c7c7816..5863ad133 100644 --- a/packages/application/src/releaseDrafts.ts +++ b/packages/application/src/releaseDrafts.ts @@ -58,13 +58,13 @@ import { type ReleaseCheck, } from "@solid-memo/domain/release/releaseCheck"; import { FOAF_NAME, type ReleaseModel } from "@solid-memo/domain/release/releaseModel"; -import { releasePlaceOf } from "@solid-memo/domain/release/releasePlace"; +import { isDraftsPlaceOnward, releasePlaceOf } from "@solid-memo/domain/release/releasePlace"; import { rebaseDraft, releaseToDraft, withOwnSeries, } from "@solid-memo/domain/release/releaseToDraft"; -import { nextVersionDraft } from "@solid-memo/domain/release/releaseVersion"; +import { nextVersionDraft, withOwnVersions } from "@solid-memo/domain/release/releaseVersion"; import type { DeckLibrary, DeckRepository, @@ -100,7 +100,7 @@ export type NewDraft = * otherwise. */ | { kind: "nextVersionOf"; url: string } - /** A release in a Turtle or JSON-LD file the user picks, with only its own statements and in a series of its own (ownStatements, withOwnSeries, releaseToDraft). */ + /** A release in a Turtle or JSON-LD file the user picks, with only its own statements, in a series of its own and following no version (ownStatements, withOwnSeries, releaseToDraft). */ | { kind: "fromFile" }; /** A draft made, and for one made from a copy of a release, that release: to start the next version of instead. */ @@ -253,8 +253,10 @@ function placeTaken(error: unknown, url: string): boolean { * outside it (releaseForeignPart): no editor or check of a draft would * see that, but its release would publish it (GHSA-67w8-qjpm-jhwp). Nor * one that links to the guest's pod by an IRI (releaseLinksGuestPod): a - * guest's draft of it could not be kept (GHSA-hc3p-fpwj-fvjp). Of its - * other statements, it keeps only its own (ownStatements). + * guest's draft of it could not be kept (GHSA-hc3p-fpwj-fvjp). Its + * series lists only its own versions (withOwnVersions), and of its other + * statements, it keeps only its own (ownStatements): nothing of a + * version it no longer lists (GHSA-jp29-vf56-5g4g). */ function draftable(read: ReleaseDraft): ReleaseDraft { if (draftAddressesGuest(read)) throw new AppError("releaseLinksGuestPod", { url: read.url }); @@ -266,7 +268,7 @@ function draftable(read: ReleaseDraft): ReleaseDraft { }; const foreign = strayPartsOf(release).find((stray) => idIn(release, stray) === null); if (foreign !== undefined) throw new AppError("releaseForeignPart", { part: foreign }); - return ownStatements(release); + return ownStatements(withOwnVersions(release)); } /** @@ -398,15 +400,36 @@ function untaken(triples: readonly DraftTriple[], taken: (iri: string) => boolea ); } +/** + * The statements, but those of an address where the draft at `url` or a + * next version of it is published unless the creator chooses another + * folder (isDraftsPlaceOnward), other than the source's own, which move + * to the draft's. Publishing the draft there would be refused + * (releaseForeignStatement), or its next version would be, and published + * elsewhere, it would say of that release what no draft of it does + * (GHSA-jp29-vf56-5g4g). The place is the draft's, wherever its source + * is: the instance's releases/, by the draft's name. + */ +function unplaced( + triples: readonly DraftTriple[], + source: ReleaseDraft, + url: string, +): DraftTriple[] { + const theirs = (iri: string) => + isDraftsPlaceOnward(url, iri) && iri !== source.url && idIn(source, iri) === null; + return untaken(triples, takenBy(source, url)).filter(({ subject }) => !theirs(subject)); +} + /** * The release, or draft, to be made the draft at `url`, before it is * moved there: without a statement of or naming an address in the new - * draft's container (takenBy), and what only such a statement led to - * (ownStatements). Before the move, the draft's own address is one of - * these too: a statement of it is not one of the source's. + * draft's container (takenBy), or of where it is published (unplaced), + * and what only such a statement led to (ownStatements). Before the + * move, the draft's own address is one of these too: a statement of it + * is not one of the source's. */ function inItsPlace(source: ReleaseDraft, url: string): ReleaseDraft { - const triples = untaken(source.triples, takenBy(source, url)); + const triples = unplaced(source.triples, source, url); return triples.length === source.triples.length ? source : ownStatements({ ...source, triples }); } @@ -653,7 +676,7 @@ export function createReleaseDraftUseCases({ const draft = nextVersionDraft( inItsPlace(release, url), url, - untaken(linked, takenBy(release, url)), + unplaced(linked, release, url), ); // What the linked documents state is checked as the draft has it, as the release was (draftable). if (linksGuestPodBeyond(draft)) @@ -666,7 +689,7 @@ export function createReleaseDraftUseCases({ case "fromFile": { const file = await fileExchange.open(DECK_FILE_ACCEPT); if (file === null) return null; - // In a series of its own, unless the file's is one of its subjects (GHSA-38m7-8r5j-q79f). + // In a series of its own, unless the file's is one of its subjects (GHSA-38m7-8r5j-q79f), following no version (GHSA-jp29-vf56-5g4g). const release = draftable( withOwnSeries( await releaseDraftRepository.parseRelease( diff --git a/packages/domain/src/release/releasePlace.test.ts b/packages/domain/src/release/releasePlace.test.ts index 2beaa6a6a..7902b9fea 100644 --- a/packages/domain/src/release/releasePlace.test.ts +++ b/packages/domain/src/release/releasePlace.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { + isDraftsPlaceOnward, + isEarlierVersionBeside, isPublishedReleaseUrl, isReleaseContainer, releaseFileName, @@ -77,3 +79,42 @@ describe("isPublishedReleaseUrl", () => { } }); }); + +describe("a version beside a release", () => { + const V2 = "https://pod.example/shared/capitals/v2.ttl"; + + it("is earlier at a release's address in its folder, of a lower version", () => { + expect(isEarlierVersionBeside(V2, "https://pod.example/shared/capitals/v1.ttl")).toBe(true); + for (const iri of [ + V2, + "https://pod.example/shared/capitals/v3.ttl", + "https://pod.example/shared/capitals/v1.ttl#x", + "https://pod.example/other/capitals/v1.ttl", + "https://pod.example/shared/capitals/one.ttl", + ]) + expect(isEarlierVersionBeside(V2, iri)).toBe(false); + expect(isEarlierVersionBeside("https://pod.example/shared/capitals.ttl", V2)).toBe(false); + }); +}); + +describe("isDraftsPlaceOnward", () => { + const DRAFT = "https://pod.example/memo/drafts/capitals/v2/release.ttl"; + const at = (path: string) => `https://pod.example/memo/releases/capitals/${path}`; + + it("is where the draft is published by default, a later version there, or a subject of one", () => { + for (const iri of [at("v2.ttl"), at("v2.ttl#x"), at("v3.ttl"), at("v10.ttl#series")]) + expect(isDraftsPlaceOnward(DRAFT, iri)).toBe(true); + for (const iri of [ + at("v1.ttl"), + at("v1.ttl#x"), + at("v02.ttl"), + at("v3.ttl/x"), + at("other.ttl"), + "https://pod.example/memo/releases/other/v3.ttl", + "https://elsewhere.example/memo/releases/capitals/v3.ttl", + "_:b0", + ]) + expect(isDraftsPlaceOnward(DRAFT, iri)).toBe(false); + expect(isDraftsPlaceOnward(at("v1.ttl"), at("v2.ttl"))).toBe(false); + }); +}); diff --git a/packages/domain/src/release/releasePlace.ts b/packages/domain/src/release/releasePlace.ts index cf862cfbe..154f31380 100644 --- a/packages/domain/src/release/releasePlace.ts +++ b/packages/domain/src/release/releasePlace.ts @@ -8,6 +8,7 @@ import { isCanonicalUrl, isPlainHttpUrl, } from "../instanceLayout.ts"; +import { draftPlaceOf } from "./draftLayout.ts"; /** * Where a creator publishes a release in a pod (docs/studio.md, @@ -69,6 +70,47 @@ export function isPublishedReleaseUrl(url: string): boolean { return username === "" && password === "" && releasePlaceOf(url) !== null; } +/** + * The version an address names beside the release at `url`, in its + * folder (`/v.ttl`, or a fragment of it): null when + * either is named otherwise than a release is, or the address is in + * another folder. + */ +function versionBeside(url: string, iri: string): number | null { + const document = iri.split("#", 1)[0]!; + const folder = (of: string) => of.slice(0, of.lastIndexOf("/") + 1); + if (releasePlaceOf(url) === null || folder(document) !== folder(url)) return null; + return releasePlaceOf(document)?.version ?? null; +} + +/** + * Whether an address is an earlier version of the release at `url`, as + * its series' versions are published: a release's address + * (isPublishedReleaseUrl) in its folder, of a lower version. + */ +export function isEarlierVersionBeside(url: string, iri: string): boolean { + const version = versionBeside(url, iri); + return isPublishedReleaseUrl(iri) && version !== null && version < releasePlaceOf(url)!.version; +} + +/** + * Whether an address is where the draft at `draftUrl` is published + * unless the creator chooses another folder (releaseUrlIn the instance's + * releases/, by the draft's name and version), a later version there, + * or one of their subjects (`#id`): where it and each next version of + * it are published. False for an address no draft has (draftPlaceOf). + */ +export function isDraftsPlaceOnward(draftUrl: string, iri: string): boolean { + const place = draftPlaceOf(draftUrl); + if (place === null) return false; + const folder = `${releasesContainerOf(place.instanceUrl)}${place.name}/`; + const document = iri.split("#", 1)[0]!; + const version = document.startsWith(folder) + ? /^v([1-9][0-9]*)\.ttl$/.exec(document.slice(folder.length)) + : null; + return version !== null && Number(version[1]) >= place.version; +} + /** The name of a release saved as a file: `-v.ttl`, which the library keeps as `decks//v.ttl`. */ export function releaseFileName(name: string, version: number): string { return `${name}-v${version}.ttl`; diff --git a/packages/domain/src/release/releaseToDraft.test.ts b/packages/domain/src/release/releaseToDraft.test.ts index 578cd6094..7f340249e 100644 --- a/packages/domain/src/release/releaseToDraft.test.ts +++ b/packages/domain/src/release/releaseToDraft.test.ts @@ -129,6 +129,45 @@ describe("withOwnSeries", () => { expect(versionOnly.isVersionOf).toBeUndefined(); }); + it("gives the release's own series no version but the release (GHSA-jp29-vf56-5g4g)", () => { + const own = `${RELEASE}#series`; + const PLANTED = "https://attacker.example/x/v2.ttl"; + const kept = link(own, `${DCAT}hasVersion`, RELEASE); + const planted = [ + link(own, `${DCAT}hasVersion`, PLANTED), + link(own, `${DCAT}first`, PLANTED), + { subject: own, predicate: `${DCAT}last`, object: { kind: "blank" as const, value: "b9" } }, + ]; + const versions = withOwnSeries( + inSeries({ inSeries: own, isVersionOf: own }, [kept, ...planted]), + ); + expect(versions.triples).toContainEqual(kept); + for (const triple of planted) expect(versions.triples).not.toContainEqual(triple); + // A series the file describes keeps a first version: the release, as one the file names would be. + expect(versions.triples).toContainEqual(link(own, `${DCAT}first`, RELEASE)); + const named = inSeries({ inSeries: own }, [link(own, `${DCAT}first`, RELEASE)]); + expect(withOwnSeries(named)).toBe(named); + // A series elsewhere gives way to the release's own, which lists nothing of the file's either. + const foreign = withOwnSeries( + inSeries({ inSeries: SERIES, isVersionOf: own }, [link(own, `${DCAT}hasVersion`, PLANTED)]), + ); + expect(foreign.triples).toEqual(release().triples); + }); + + it("makes the release follow no version, nor its series list that one (GHSA-jp29-vf56-5g4g)", () => { + const own = `${RELEASE}#series`; + const BEFORE = "https://pod.example/solid-memo/main/releases/solid/v0.ttl"; + const listed = link(own, `${DCAT}hasVersion`, BEFORE); + const { root, triples } = withOwnSeries({ + ...inSeries({ inSeries: own }, [listed]), + root: { ...inSeries({ inSeries: own }).root, prev: BEFORE, previousVersion: BEFORE }, + }); + expect(root.prev).toBeUndefined(); + expect(root.previousVersion).toBeUndefined(); + expect(root.inSeries).toBe(own); + expect(triples).not.toContainEqual(listed); + }); + it("keeps what a release named as its own series states of itself", () => { const kept = link(RELEASE, "https://other.example/keep", RELEASE); const itself = withOwnSeries(inSeries({ inSeries: RELEASE }, [kept])); diff --git a/packages/domain/src/release/releaseToDraft.ts b/packages/domain/src/release/releaseToDraft.ts index 7936fbf64..715b04b88 100644 --- a/packages/domain/src/release/releaseToDraft.ts +++ b/packages/domain/src/release/releaseToDraft.ts @@ -21,6 +21,7 @@ import { type DraftTriple, type ReleaseDraft, } from "./releaseDraft.ts"; +import { DCAT_NS } from "./releaseModel.ts"; /** * A release as a draft at another address (docs/studio.md, Drafts): the @@ -48,9 +49,51 @@ export function releaseToDraft(release: ReleaseDraft, url: string): ReleaseDraft * file's own, `#series` (or `#series-2`, … when that id is taken), as * `dcat:inSeries` and as `dcat:isVersionOf` when that names no subject * of the file either, and what the file states of it is dropped - * (GHSA-38m7-8r5j-q79f). + * (GHSA-38m7-8r5j-q79f). Either way the series has no version but the + * release (withVersionsOnly): the file's other versions are none of the + * creator's, and published, the release would list them, with what the + * file states of them, as its series' (GHSA-jp29-vf56-5g4g). Nor does + * the release follow one (asFirst). */ export function withOwnSeries(release: ReleaseDraft): ReleaseDraft { + return withFirst(withVersionsOnly(inOwnSeries(asFirst(release)), (iri) => iri === release.url)); +} + +/** + * The release following no version (`dcat:prev`, + * `dcat:previousVersion`): the version a file follows is none of the + * creator's, and published, the release would list it, with what the + * file states of it, as an earlier version of its series + * (GHSA-jp29-vf56-5g4g). What only that link led to goes with it + * (ownStatements, in the application). + */ +function asFirst(release: ReleaseDraft): ReleaseDraft { + const { prev, previousVersion, ...root } = release.root; + return prev === undefined && previousVersion === undefined ? release : { ...release, root }; +} + +/** + * The release as the first version of the series it is in, when the + * file describes that series but names no first version of it any more + * (withOwnSeries): publishing keeps what a described series states, and + * a series has a first version. + */ +function withFirst(release: ReleaseDraft): ReleaseDraft { + const series = release.root.inSeries; + const first = `${DCAT_NS}first`; + const stated = release.triples.filter((triple) => triple.subject === series); + if (series === undefined || stated.length === 0 || stated.some((t) => t.predicate === first)) + return release; + const link: DraftTriple = { + subject: series, + predicate: first, + object: { kind: "iri", value: release.url }, + }; + return { ...release, triples: [...release.triples, link] }; +} + +/** The release in a series of its own, or the one it names when that is one of its subjects (withOwnSeries). */ +function inOwnSeries(release: ReleaseDraft): ReleaseDraft { const { inSeries, isVersionOf, ...rest } = release.root; const foreign = (iri: string | undefined): iri is string => iri !== undefined && idIn(release, iri) === null; @@ -72,6 +115,33 @@ export function withOwnSeries(release: ReleaseDraft): ReleaseDraft { }; } +/** How a series names its versions (DCAT): each a link from the series to one. */ +const VERSION_LINKS = new Set( + ["first", "last", "hasVersion", "hasCurrentVersion", "prev", "previousVersion"].map( + (name) => `${DCAT_NS}${name}`, + ), +); + +/** + * The release without the links of the series it is in + * (`dcat:inSeries`, `dcat:isVersionOf`) to versions `isVersion` doesn't + * say are its series': what is stated of those is no longer the + * release's own, and goes with them (ownStatements, in the application). + */ +export function withVersionsOnly( + release: ReleaseDraft, + isVersion: (iri: string) => boolean, +): ReleaseDraft { + const series = new Set([release.root.inSeries, release.root.isVersionOf]); + const triples = release.triples.filter( + ({ subject, predicate, object }) => + !series.has(subject) || + !VERSION_LINKS.has(predicate) || + (object.kind === "iri" && isVersion(object.value)), + ); + return triples.length === release.triples.length ? release : { ...release, triples }; +} + /** `id`, or `id-2`, `id-3`, … the first that no subject of the draft has. */ function freeId(draft: ReleaseDraft, id: string): string { const used = idsInUse(draft); diff --git a/packages/domain/src/release/releaseVersion.test.ts b/packages/domain/src/release/releaseVersion.test.ts index 655c4714d..f4c7688a5 100644 --- a/packages/domain/src/release/releaseVersion.test.ts +++ b/packages/domain/src/release/releaseVersion.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { courseDraft, DRAFT, link } from "../testing/releaseDraft.ts"; import { rebaseDraft } from "./releaseToDraft.ts"; -import { linkedDescription, nextVersionDraft } from "./releaseVersion.ts"; +import { linkedDescription, nextVersionDraft, withOwnVersions } from "./releaseVersion.ts"; const V1 = "https://pod.example/solid-memo/main/releases/solid/v1.ttl"; const DCAT = "http://www.w3.org/ns/dcat#"; @@ -93,8 +93,10 @@ describe("nextVersionDraft", () => { publisher: `${INDEX}#pub`, }, }; - const library = { ...release, triples: v1().triples.slice(0, 4) }; - const OLD = "https://solid-memo.com/decks/solid/v0.ttl"; + // Released in the library, beside the version before it. + const LIB = "https://solid-memo.com/decks/solid/v2.ttl"; + const library = rebaseDraft({ ...release, triples: v1().triples.slice(0, 4) }, LIB); + const OLD = "https://solid-memo.com/decks/solid/v1.ttl"; const text = (subject: string, predicate: string, value: string) => ({ subject, predicate, @@ -104,7 +106,7 @@ describe("nextVersionDraft", () => { link(`${INDEX}#solid`, RDF_TYPE, `${DCAT}DatasetSeries`), link(`${INDEX}#solid`, `${DCTERMS}publisher`, `${INDEX}#lib`), link(`${INDEX}#solid`, `${DCAT}hasVersion`, OLD), - link(`${INDEX}#solid`, `${DCAT}hasVersion`, V1), + link(`${INDEX}#solid`, `${DCAT}hasVersion`, LIB), ]; const agents = [ link(`${INDEX}#pub`, RDF_TYPE, "http://xmlns.com/foaf/0.1/Agent"), @@ -115,7 +117,7 @@ describe("nextVersionDraft", () => { link(`${INDEX}#other`, RDF_TYPE, `${DCAT}DatasetSeries`), link(OLD, RDF_TYPE, "https://solid-memo.com/ns/vocab/v1.ttl#Deck"), link(OLD, `${DCTERMS}creator`, `${INDEX}#someone`), - link(V1, `${DCTERMS}creator`, `${INDEX}#someone`), + link(LIB, `${DCTERMS}creator`, `${INDEX}#someone`), // Of the series, what an index says of one; of a publisher, its class and name, nothing more a profile says of its person. link(`${INDEX}#solid`, "http://www.w3.org/ns/prov#wasAttributedTo", `${INDEX}#someone`), link(`${INDEX}#pub`, "http://xmlns.com/foaf/0.1/mbox", "mailto:pub@example.org"), @@ -129,7 +131,7 @@ describe("nextVersionDraft", () => { ...series, ...agents, ...older, - link(V1, RDF_TYPE, `${DCAT}Dataset`), + link(LIB, RDF_TYPE, `${DCAT}Dataset`), ]), ); expect(draft.triples).not.toContainEqual(elsewhere[0]); @@ -185,3 +187,83 @@ describe("nextVersionDraft", () => { ).toBe("1"); }); }); + +describe("withOwnVersions", () => { + const at = (version: number) => V1.replace("v1.ttl", `v${version}.ttl`); + const V3 = at(3); + const SERIES = `${V3}#series`; + const ELSEWHERE = "https://elsewhere.example/x/v2.ttl"; + const notes = (subject: string) => ({ + subject, + predicate: "http://www.w3.org/ns/adms#versionNotes", + object: { kind: "literal" as const, value: "Notes", language: "", datatype: "" }, + }); + /** Release 3 at V3, after version 2, its series listing versions 1 to 3, and what `more` adds. */ + const v3 = (more: ReturnType["triples"] = []) => { + const release = rebaseDraft(courseDraft(), V3); + return { + ...release, + root: { ...release.root, version: "3", prev: at(2), previousVersion: at(2) }, + triples: [ + ...release.triples, + link(SERIES, `${DCAT}first`, at(1)), + link(SERIES, `${DCAT}hasVersion`, at(1)), + link(SERIES, `${DCAT}hasVersion`, at(2)), + link(SERIES, `${DCAT}hasVersion`, V3), + notes(at(1)), + ...more, + ], + }; + }; + + it("keeps the release's own versions: itself, the one it follows and the earlier ones beside it", () => { + const own = v3(); + expect(withOwnVersions(own)).toBe(own); + const away = { ...v3(), root: { ...v3().root, prev: ELSEWHERE, previousVersion: undefined } }; + const followed = { + ...away, + triples: [...away.triples, link(SERIES, `${DCAT}hasVersion`, ELSEWHERE)], + }; + expect(withOwnVersions(followed)).toBe(followed); + }); + + it("lists no other version in its series (GHSA-jp29-vf56-5g4g)", () => { + const planted = [ + link(SERIES, `${DCAT}hasVersion`, ELSEWHERE), + link(SERIES, `${DCAT}hasVersion`, at(4)), + link(SERIES, `${DCAT}hasVersion`, V1.replace("v1.ttl", "v0.ttl")), + link(SERIES, `${DCAT}first`, ELSEWHERE), + { + subject: SERIES, + predicate: `${DCAT}last`, + object: { kind: "blank" as const, value: "b0" }, + }, + notes(ELSEWHERE), + ]; + // What only a dropped link led to is the application's to drop (ownStatements). + expect(withOwnVersions(v3(planted)).triples).toEqual([...v3().triples, notes(ELSEWHERE)]); + const next = nextVersionDraft(v3(planted), DRAFT); + expect(next.triples.filter((triple) => triple.object.value === ELSEWHERE)).toEqual([]); + expect(next.triples.filter((triple) => triple.object.value === at(4))).toEqual([]); + }); + + it("describes no version a series described elsewhere lists that isn't the release's own", () => { + const INDEX = "https://index.example/decks/index.ttl"; + const release = { + ...v3(), + root: { ...v3().root, inSeries: `${INDEX}#s`, isVersionOf: undefined }, + }; + const index = [ + link(`${INDEX}#s`, `${DCAT}hasVersion`, at(1)), + link(`${INDEX}#s`, `${DCAT}hasVersion`, ELSEWHERE), + link(at(1), RDF_TYPE, `${DCAT}Dataset`), + link(ELSEWHERE, RDF_TYPE, `${DCAT}Dataset`), + ]; + // Version 1 the release describes itself; the other is described by no one. + expect(linkedDescription(release, index)).toEqual(index.slice(0, 2)); + const next = nextVersionDraft(release, DRAFT, index); + expect(next.triples).toContainEqual(index[0]); + expect(next.triples).not.toContainEqual(index[1]); + expect(next.triples).not.toContainEqual(index[3]); + }); +}); diff --git a/packages/domain/src/release/releaseVersion.ts b/packages/domain/src/release/releaseVersion.ts index bbe951b15..69b6bf35a 100644 --- a/packages/domain/src/release/releaseVersion.ts +++ b/packages/domain/src/release/releaseVersion.ts @@ -5,7 +5,8 @@ import { SM } from "@solid-memo/vocab/vocab.generated"; import { DCAT_NS, DCTERMS_NS, type ReleaseText } from "./releaseModel.ts"; import { publishedIdsOf, RDF_TYPE, type DraftTriple, type ReleaseDraft } from "./releaseDraft.ts"; -import { moved, rebaseDraft } from "./releaseToDraft.ts"; +import { isEarlierVersionBeside } from "./releasePlace.ts"; +import { moved, rebaseDraft, withVersionsOnly } from "./releaseToDraft.ts"; const LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"; const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"; @@ -29,13 +30,18 @@ const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"; * chapter, step and distractor, which the draft may retire but never * drop or give to another, and how it was made, its activities, which * the draft keeps as they are. + * + * Its series lists only the release's own versions (withOwnVersions). */ export function nextVersionDraft( read: ReleaseDraft, url: string, linked: readonly DraftTriple[] = [], ): ReleaseDraft { - const release = { ...read, triples: [...read.triples, ...linkedDescription(read, linked)] }; + const release = withOwnVersions({ + ...read, + triples: [...read.triples, ...linkedDescription(read, linked)], + }); const series = new Set( [release.root.inSeries, release.root.isVersionOf].filter( (iri): iri is string => iri !== undefined, @@ -70,6 +76,29 @@ export function nextVersionDraft( }; } +/** + * The release with only its own versions in its series: a link of the + * series to a version (withVersionsOnly) names the release, the version + * it follows (`dcat:prev`, `dcat:previousVersion`) or an earlier version + * in its folder (ownVersionOf). A release made from a file could + * otherwise list other addresses, with notes of its choosing, as its + * series' versions, which learners read as the creator's, and carry them + * into every next version (GHSA-jp29-vf56-5g4g). What it states of where + * its draft is published is the application's to drop (inItsPlace). + */ +export function withOwnVersions(release: ReleaseDraft): ReleaseDraft { + return withVersionsOnly(release, ownVersionOf(release)); +} + +/** Whether an address is one of the release's own versions, as withOwnVersions says. */ +function ownVersionOf({ url, root }: ReleaseDraft): (iri: string) => boolean { + return (iri) => + iri === url || + iri === root.prev || + iri === root.previousVersion || + isEarlierVersionBeside(url, iri); +} + /** What an earlier version is described by: its class, title, description and version. */ const EARLIER_VERSION = new Set([ RDF_TYPE, @@ -104,7 +133,8 @@ const AGENT = new Set([RDF_TYPE, "http://xmlns.com/foaf/0.1/name"]); * (SERIES); each publisher, the release's and the series', by its class * and name, nothing more of what a profile may say of its person; each * version the series lists but the release, as a dcat:Dataset with its - * title, description and version. + * title, description and version, when it is one of the release's own + * (withOwnVersions). */ export function linkedDescription( release: ReleaseDraft, @@ -130,8 +160,11 @@ export function linkedDescription( (iri): iri is string => iri !== undefined && !own.has(iri), ), ); + const ownVersion = ownVersionOf(release); const versions = new Set( - of(series, `${DCAT_NS}hasVersion`).filter((iri) => iri !== release.url && !own.has(iri)), + of(series, `${DCAT_NS}hasVersion`).filter( + (iri) => iri !== release.url && !own.has(iri) && ownVersion(iri), + ), ); return linked.filter( (triple) => diff --git a/packages/solid/src/solidReleaseDraftRepository.test.ts b/packages/solid/src/solidReleaseDraftRepository.test.ts index a78b81976..be24d5827 100644 --- a/packages/solid/src/solidReleaseDraftRepository.test.ts +++ b/packages/solid/src/solidReleaseDraftRepository.test.ts @@ -1285,6 +1285,53 @@ describe("assemble", () => { expect(newestRelease(copied, [release])).toBe(copied); }); + it("publishes a release file that follows a version elsewhere as following none, with nothing the file states of that version (GHSA-jp29-vf56-5g4g)", async () => { + const before = "https://attacker.example/memo/releases/git-commands/v1.ttl"; + const published = `${INSTANCE}releases/git-commands/v2.ttl`; + const text = `${readFileSync(join(DECKS_ROOT, "git-commands/v1.ttl"), "utf8") + .replace( + "@base ", + "@base ", + ) + .replace( + 'dcat:version "1"', + `dcat:version "2" ; dcat:prev <${before}> ; dcat:previousVersion <${before}>`, + )} +<${before}> a dcat:Dataset ; dcterms:title "Planted"@en ; dcterms:description "x"@en ; + dcat:version "1" ; adms:versionNotes "Planted notes" ; dcterms:publisher [ adms:versionNotes "Planted" ] . +`; + const pod = await draftPod(); + const useCases = createReleaseDraftUseCases({ + releaseDraftRepository: pod.repository, + deckRepository: {} as never, + deckLibrary: {} as never, + shapeValidator: pod.validator, + fileExchange: { open: async () => ({ name: "git.ttl", text }), save: () => undefined }, + storageGateway: { discoverStorages: async () => [] }, + now: () => new Date(NOW), + }); + const created = await useCases.createReleaseDraft( + INSTANCE, + { kind: "fromFile" }, + "https://pod.example/profile/card#me", + ); + const draft = await useCases.getReleaseDraft(created!.draft.url); + expect(draft.root.version).toBe("2"); + expect(draft.root.prev).toBeUndefined(); + expect(draft.root.previousVersion).toBeUndefined(); + // Nothing to compare it with: everything in it is new. + expect(await useCases.diffReleaseDraft(draft)).toBeNull(); + const turtle = await pod.repository.assemble(draft.url, published, NOW); + expect(turtle).not.toContain("attacker.example"); + expect(turtle).not.toContain("Planted"); + const quads = await quadsOfTurtle(turtle, published); + const DCAT = "http://www.w3.org/ns/dcat#"; + const follows = [`${DCAT}prev`, `${DCAT}previousVersion`]; + expect(quads.filter((quad) => follows.includes(quad.predicate.value))).toEqual([]); + const release = toStandaloneLibraryDeck(published, datasetFromQuads(quads)); + expect(release.releases.map((listed) => listed.url)).toEqual([published]); + }); + it("makes no series elsewhere that does not list the version it follows the release's, whatever the draft states", async () => { const pod = await draftPod(); const series = "https://creator.example/memo/releases/capitals/v1.ttl#series";