diff --git a/docs/deck-library.md b/docs/deck-library.md index de94bb2c..3f86d6eb 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 1547e057..005a4346 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 2ae54689..1a683f48 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 80c7c781..5863ad13 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 2beaa6a6..7902b9fe 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 cf862cfb..154f3138 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 578cd609..7f340249 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 7936fbf6..715b04b8 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 655c4714..f4c7688a 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 bbe951b1..69b6bf35 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 a78b8197..be24d582 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";