From 0ceb6549051a2e01e85b0e82582dede135a1e3de Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Wed, 11 Nov 2026 23:36:41 +0100 Subject: [PATCH] fix(solid): keep only the classes a draft's subject writes A release draft keeps another app's statements of its subjects as they are and writes them back on every edit, its rdf:type ones among them. The write check holds a subject to the shape of every class it has, so a class that a shape asks something of, given to the draft's deck, a card or another subject the draft reads by its shape, made every later edit of that subject fail. On the deck, the draft could no longer be edited at all. The writer already drops such classes from a subject it records, but the draft added them back from its kept statements. Reading a draft now keeps, of a shaped subject's other classes, only those its write keeps: no class a shape asks something of, none past the most the check reads, and no type that is no IRI. Writing a draft, made or changed, holds what it writes of such a subject to the same rule, whatever its statements carry. Publishing a draft leaves the classes it does not keep out of the release too, so the release holds what the check of the draft sees. The rule is the one records.ts writes a subject by, now shared as keptClasses. Refs: GHSA-8hh7-p2ww-mpjf --- docs/data-model.md | 6 +- docs/shapes.md | 7 +- .../solid/src/mappers/releaseDraftMapper.ts | 86 +++++++++- packages/solid/src/records.ts | 31 +++- .../src/solidReleaseDraftRepository.test.ts | 149 ++++++++++++++++++ .../solid/src/solidReleaseDraftRepository.ts | 67 ++++++-- 6 files changed, 313 insertions(+), 33 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 3c0f9bdd..441d1830 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1267,7 +1267,11 @@ own pod, as a draft, before publishing it made, its sources) is written as it is, and kept through every edit; the Studio's release screen writes it as the library's releases do ([deck-library.md](deck-library.md#provenance), - [studio.md](studio.md#the-releases-metadata-and-provenance)). A + [studio.md](studio.md#the-releases-metadata-and-provenance)). Of the + classes another app gives a subject the draft reads by its shape, the + draft keeps only those the subject's write would keep + ([shapes.md](shapes.md#conventions)), so none of them stops an edit. + The release published from it leaves the others out too. A draft names the release it was published as with `sm:releasedAs`, and is then no longer edited. An `sm:releasedAs` at an address no release can have (`isPublishedReleaseUrl`, as for the catalog's releases) is diff --git a/docs/shapes.md b/docs/shapes.md index 07b59fbc..235ff265 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -120,7 +120,12 @@ check a draft's cards, distractors, agents and distributions. blank node, names no class but counts toward that number, so the writer leaves it out of the subject it checks and writes. A blank node stays in a document Solid Memo patches rather than - rewrites, but no write counts it again. + rewrites, but no write counts it again. A release draft keeps another + app's statements of its subjects as they are. So it reads and writes + the classes of its deck, chapters, steps, cards, distractors, agents + and distributions by the same rule (`keptClasses` in + [records.ts](../packages/solid/src/records.ts)). Publishing leaves + out of the release each class the draft doesn't keep. - **Property shapes are named** (`<#front>`, never blank nodes) for the generator and the results. `sh:message` replaces cryptic engine text, always in exactly English and Swedish (`"…"@en`, `"…"@sv`), diff --git a/packages/solid/src/mappers/releaseDraftMapper.ts b/packages/solid/src/mappers/releaseDraftMapper.ts index 0b42ee3a..15b65978 100644 --- a/packages/solid/src/mappers/releaseDraftMapper.ts +++ b/packages/solid/src/mappers/releaseDraftMapper.ts @@ -41,7 +41,14 @@ import { migrate } from "@solid-memo/domain/shapes/migrations"; import { DRAFT_DECK_V1, SHAPES } from "@solid-memo/vocab/descriptors.generated"; import type { ShapeDescriptor } from "@solid-memo/vocab/shapeDescriptor"; import type { ShapeName } from "@solid-memo/vocab/types.generated"; -import { datetimeOf, readIris, readRecord, readVersioned, recordThing } from "../records"; +import { + datetimeOf, + keptClasses, + readIris, + readRecord, + readVersioned, + recordThing, +} from "../records"; import { DCTERMS, SM } from "../vocab"; /** @@ -381,8 +388,24 @@ function hiddenPredicateOf( export function draftFromQuads( quads: readonly Quad[], url: string, - { refuseHidden = false }: { refuseHidden?: boolean } = {}, + options: { refuseHidden?: boolean } = {}, ): ReleaseDraft | null { + return draftReadOf(quads, url, options)?.draft ?? null; +} + +/** + * The draft `quads` state (draftFromQuads), and which classes it does not + * keep: `unkept(subject, object)` holds of an rdf:type of a subject the + * draft reads by its shape (`subject` its key, `object` as the draft + * names it) that is neither one of its own nor one keptClasses keeps of + * the others. Publishing leaves those out of the release, as the draft + * it checks does (GHSA-8hh7-p2ww-mpjf). + */ +export function draftReadOf( + quads: readonly Quad[], + url: string, + { refuseHidden = false }: { refuseHidden?: boolean } = {}, +): { draft: ReleaseDraft; unkept: (subject: string, object: ReleaseTerm) => boolean } | null { const dataset = datasetFromQuads(quads); /** Each subject's statements, by its key. */ const statements = new Map(); @@ -480,11 +503,33 @@ export function draftFromQuads( own(subject, descriptor); nodes[list]!.push({ id, data }); } + // Of the other classes a shaped subject states, only those its write + // keeps (keptClasses): a class a shape asks something of, one past the + // most the write check reads, or a type that is no IRI would stop + // every write of the subject (GHSA-8hh7-p2ww-mpjf). + const classes = new Map(); + for (const quad of quads) { + const subject = keyOf(quad.subject); + if (!owned.has(subject) || !isClass(quad)) continue; + const of = classes.get(subject); + if (of === undefined) classes.set(subject, [quad.object.value]); + else of.push(quad.object.value); + } + const kept = new Map( + [...classes].map(([subject, types]) => [ + subject, + new Set(keptClasses(owned.get(subject)!.types, types)), + ]), + ); + const unkept = (subject: string, object: ReleaseTerm) => + owned.has(subject) && (object.kind !== "iri" || !kept.get(subject)!.has(object.value)); const triples = quads .filter((quad) => { - const shaped = owned.get(keyOf(quad.subject)); + const subject = keyOf(quad.subject); + const shaped = owned.get(subject); if (shaped === undefined) return true; - if (quad.predicate.value === RDF_TYPE) return !shaped.types.has(quad.object.value); + if (quad.predicate.value === RDF_TYPE) + return !shaped.types.has(quad.object.value) && !unkept(subject, termOf(quad.object)); return !shaped.predicates.has(quad.predicate.value); }) .map( @@ -494,7 +539,7 @@ export function draftFromQuads( object: termOf(quad.object), }), ); - return { + const draft: ReleaseDraft = { url, course: rootTypes.includes(SCHEMA_COURSE), root, @@ -505,6 +550,7 @@ export function draftFromQuads( triples, published: NOTHING_PUBLISHED, }; + return { draft, unkept }; } /** One subject of a draft as its document in the pod holds it: its record, when its shape describes it, and its other statements. */ @@ -647,10 +693,36 @@ export function entryQuads(entry: DraftEntry): Quad[] { recordThing(entry.subject, entry.shaped.descriptor, entry.shaped.record, null), ), ); - return [ + return withEntryClasses(entry, [ ...shaped, ...entry.raw.map((triple) => quadOf(triple.subject, triple.predicate, triple.object)), - ]; + ]); +} + +/** Whether the quad states a class of its subject: an rdf:type that is an IRI. */ +function isClass(quad: Quad): boolean { + return quad.predicate.value === RDF_TYPE && quad.object.termType === "NamedNode"; +} + +/** + * The quads of an entry with the classes its subject's write would not + * keep left out, when its shape describes it: each its own, and of the + * others only those keptClasses keeps, so that what a draft writes of a + * shaped subject is held to its own shapes alone, whatever its + * statements carry (GHSA-8hh7-p2ww-mpjf). A type that is no IRI goes too. + */ +export function withEntryClasses(entry: DraftEntry, quads: readonly Quad[]): Quad[] { + if (entry.shaped === undefined) return [...quads]; + const { descriptor } = entry.shaped; + const typed = (quad: Quad) => + quad.predicate.value === RDF_TYPE && keyOf(quad.subject) === entry.subject; + const kept = new Set( + keptClasses( + [descriptor.targetClass, ...descriptor.additionalTypes], + quads.filter((quad) => typed(quad) && isClass(quad)).map((quad) => quad.object.value), + ), + ); + return quads.filter((quad) => !typed(quad) || (isClass(quad) && kept.has(quad.object.value))); } /** Whether two entries say the same of their subject, in the same place. */ diff --git a/packages/solid/src/records.ts b/packages/solid/src/records.ts index dd9b3931..bc03ba3b 100644 --- a/packages/solid/src/records.ts +++ b/packages/solid/src/records.ts @@ -433,19 +433,36 @@ export function withoutForeignShapedClasses( thing: NamedSubject, descriptor: ShapeDescriptor, ): NamedSubject { - const own = new Set([descriptor.targetClass, ...descriptor.additionalTypes]); - let room = MAX_CLASSES - own.size; + const kept = keptClasses( + [descriptor.targetClass, ...descriptor.additionalTypes], + getUrlAll(thing, RDF.type), + ); + // A value of rdf:type that is no IRI (a literal, a blank node) names + // no class, yet the check counts it against MAX_CLASSES: it goes too. + return buildThing(thing).removeAll(RDF.type).setIriAll(RDF.type, kept).build(); +} + +/** + * Of the classes a subject states (`classes`, IRIs in the order it + * states them), the ones it keeps when its own are `own`: each of its + * own, and each class no shape asks anything of (SHAPED_CLASSES) while + * the subject, its own classes first, stays within the most the write + * check reads it by (MAX_CLASSES). The rule withoutForeignShapedClasses + * writes a subject by, for a writer that keeps a subject's classes as + * statements of its own (a release draft's). + */ +export function keptClasses(own: Iterable, classes: Iterable): string[] { + const owned = new Set(own); + let room = MAX_CLASSES - owned.size; const kept: string[] = []; - for (const type of new Set(getUrlAll(thing, RDF.type))) { - if (own.has(type)) kept.push(type); + for (const type of new Set(classes)) { + if (owned.has(type)) kept.push(type); else if (!SHAPED_CLASSES.has(type) && room > 0) { kept.push(type); room--; } } - // A value of rdf:type that is no IRI (a literal, a blank node) names - // no class, yet the check counts it against MAX_CLASSES: it goes too. - return buildThing(thing).removeAll(RDF.type).setIriAll(RDF.type, kept).build(); + return kept; } /** diff --git a/packages/solid/src/solidReleaseDraftRepository.test.ts b/packages/solid/src/solidReleaseDraftRepository.test.ts index 27537c29..2d4b4aff 100644 --- a/packages/solid/src/solidReleaseDraftRepository.test.ts +++ b/packages/solid/src/solidReleaseDraftRepository.test.ts @@ -14,6 +14,7 @@ import { ruleProblems } from "@solid-memo/domain/release/releaseCheck"; import { applyDraftChanges, blankDraft, + iriIn, isRefusal, type DraftChange, type DraftNode, @@ -2040,3 +2041,151 @@ describe("a release with a card outside it (GHSA-67w8-qjpm-jhwp)", () => { ]); }); }); + +describe("a class another app gives a draft's subject (GHSA-8hh7-p2ww-mpjf)", () => { + const PALETTE = `${SM}Palette`; + const OTHER = "http://example.org/Other"; + const CARD = `${CHAPTER_A}#q-a-2a`; + const typeOf = (subject: string, object: string) => `\n<${subject}> <${RDF}type> ${object} .\n`; + + /** The course fixture with `turtle` added to `document` as another app would, read again. */ + async function planted(document: string, turtle: string) { + const pod = await draftPod({ checked: true }); + await pod.repository.create(INSTANCE, course()); + await pod.put(document, `${await pod.text(document)}${turtle}`); + const { draft, version } = await pod.repository.read(DRAFT); + return { pod, draft, version }; + } + + /** The classes the draft keeps of `subject` as statements of its own. */ + const keptTypes = (draft: ReleaseDraft, subject: string) => + draft.triples + .filter((triple) => triple.subject === subject && triple.predicate === `${RDF}type`) + .map((triple) => triple.object.value); + + it("leaves a shaped class on the root out, so its edit is written", async () => { + const { pod, draft, version } = await planted(DRAFT, typeOf(DRAFT, `<${PALETTE}>`)); + expect(keptTypes(draft, DRAFT)).toEqual([]); + await pod.repository.applyChanges( + draft, + changed(draft, { kind: "setMeta", meta: { title: { en: "New" } } }), + version, + ); + expect(await pod.text(DRAFT)).not.toContain(PALETTE); + const { draft: read } = await pod.repository.read(DRAFT); + expect(read.root.title).toEqual({ en: "New" }); + }); + + it("leaves a shaped class on a card out, so its edit is written", async () => { + const { pod, draft, version } = await planted(CHAPTER_A, typeOf(CARD, `<${PALETTE}>`)); + expect(keptTypes(draft, iriIn(draft, "q-a-2a"))).toEqual([]); + await pod.repository.applyChanges( + draft, + changed(draft, { + kind: "editCard", + id: "q-a-2a", + card: { front: { en: "New?" }, back: { en: "Yes" } }, + }), + version, + ); + expect(await pod.text(CHAPTER_A)).not.toContain(PALETTE); + }); + + it("keeps a class no shape asks anything of through an edit", async () => { + const { pod, draft, version } = await planted(DRAFT, typeOf(DRAFT, `<${OTHER}>`)); + expect(keptTypes(draft, DRAFT)).toEqual([OTHER]); + await pod.repository.applyChanges( + draft, + changed(draft, { kind: "setMeta", meta: { title: { en: "New" } } }), + version, + ); + const { draft: read } = await pod.repository.read(DRAFT); + expect(keptTypes(read, DRAFT)).toEqual([OTHER]); + }); + + it("keeps no more classes than the write check reads, and no type that is no IRI", async () => { + const many = Array.from({ length: 40 }, (_, at) => `<${OTHER}${at}>`).join(" , "); + const { pod, draft, version } = await planted( + DRAFT, + `${typeOf(DRAFT, many)}${typeOf(DRAFT, '"Deck"')}${typeOf(DRAFT, "_:b")}`, + ); + const kept = keptTypes(draft, DRAFT); + // Its own classes (sm:Deck, dcat:Dataset) and schema:Course count first. + expect(kept).toHaveLength(32 - 3); + expect(kept.every((type) => type.startsWith(OTHER))).toBe(true); + await pod.repository.applyChanges( + draft, + changed(draft, { kind: "setMeta", meta: { title: { en: "New" } } }), + version, + ); + const text = await pod.text(DRAFT); + expect(text).not.toContain('"Deck"'); + const { draft: read } = await pod.repository.read(DRAFT); + expect(keptTypes(read, DRAFT)).toEqual(kept); + }); + + it("writes no shaped class a draft's statements carry, made or changed", async () => { + const carried = (draft: ReleaseDraft, subject: string): ReleaseDraft => ({ + ...draft, + triples: [ + ...draft.triples, + { subject, predicate: `${RDF}type`, object: { kind: "iri", value: PALETTE } }, + { subject, predicate: `${RDF}type`, object: { kind: "blank", value: "b" } }, + ], + }); + const pod = await draftPod({ checked: true }); + await pod.repository.create(INSTANCE, carried(course(), DRAFT)); + expect(await pod.text(DRAFT)).not.toContain(PALETTE); + const { draft, version } = await pod.repository.read(DRAFT); + const card = iriIn(draft, "q-a-2a"); + await pod.repository.applyChanges( + draft, + carried( + changed(draft, { + kind: "editCard", + id: "q-a-2a", + card: { front: { en: "New?" }, back: { en: "Yes" } }, + }), + card, + ), + version, + ); + expect(await pod.text(CHAPTER_A)).not.toContain(PALETTE); + }); +}); + +describe("publishing a draft whose subject another app gave a class (GHSA-8hh7-p2ww-mpjf)", () => { + const PALETTE = `${SM}Palette`; + const OTHER = "http://example.org/Other"; + const DISTRACTOR = `${CHAPTER_A}#q-a-1a-d1`; + const typeOf = (subject: string, object: string) => `\n<${subject}> <${RDF}type> ${object} .\n`; + + it("leaves a class the draft does not keep out of the release, as of the draft checked", async () => { + const pod = await draftPod({ checked: true }); + await pod.repository.create(INSTANCE, course()); + await pod.put( + CHAPTER_A, + `${await pod.text(CHAPTER_A)}${typeOf(DISTRACTOR, `<${PALETTE}>`)}${typeOf(DISTRACTOR, '"Deck"')}`, + ); + await pod.put( + DRAFT, + `${await pod.text(DRAFT)}${typeOf(DRAFT, `<${PALETTE}>`)}${typeOf(DRAFT, `<${OTHER}>`)}`, + ); + const { version } = await pod.repository.read(DRAFT); + const target = `${INSTANCE}releases/solid/v1.ttl`; + const quads = await quadsOfTurtle( + await pod.repository.assemble(DRAFT, target, NOW, version), + target, + ); + const classesOf = (subject: string) => + quads + .filter((quad) => quad.subject.value === subject && quad.predicate.value === `${RDF}type`) + .map((quad) => quad.object.value); + expect(classesOf(target)).not.toContain(PALETTE); + expect(classesOf(target)).toContain(OTHER); + expect(classesOf(`${target}#q-a-1a-d1`)).toEqual([ + `${SM}Distractor`, + "https://schema.org/Answer", + ]); + }); +}); diff --git a/packages/solid/src/solidReleaseDraftRepository.ts b/packages/solid/src/solidReleaseDraftRepository.ts index 042eb807..a0d71ac3 100644 --- a/packages/solid/src/solidReleaseDraftRepository.ts +++ b/packages/solid/src/solidReleaseDraftRepository.ts @@ -36,7 +36,7 @@ import { type ReleaseDraftSummary, } from "@solid-memo/domain/release/draftLayout"; import { seriesAbove } from "@solid-memo/domain/release/continuityRules"; -import { DCAT_NS, DCTERMS_NS } from "@solid-memo/domain/release/releaseModel"; +import { DCAT_NS, DCTERMS_NS, type ReleaseTerm } from "@solid-memo/domain/release/releaseModel"; import { RDF_TYPE, type DraftTriple, @@ -58,8 +58,10 @@ import { import { draftEntries, draftFromQuads, + draftReadOf, draftIriOf, entryQuads, + withEntryClasses, inPlaceIn, keyOf, quadOf, @@ -398,22 +400,35 @@ export function createSolidReleaseDraftRepository({ // So would one of the draft's own subjects named otherwise than the draft writes it, which no read of the draft has (GHSA-8w75-jg23-rxq5). if (quad.subject.termType !== "BlankNode" && !inPlace(urls[at]!, quad.subject.value)) throw new AppError("draftAliasedSubject", { subject: quad.subject.value }); - return quadOf( - quad.subject.termType === "BlankNode" - ? `_:${label(quad.subject.value)}` - : to(quad.subject.value), - quad.predicate.value, - object.kind === "iri" - ? { kind: "iri", value: to(object.value) } - : object.kind === "blank" - ? { kind: "blank", value: label(object.value) } - : object, - ); + // A class the draft does not keep of its subject is left out of the release, as of the draft checked (GHSA-8hh7-p2ww-mpjf). + const typed = classStated(quad, object, iriOf); + return { + typed, + quad: quadOf( + quad.subject.termType === "BlankNode" + ? `_:${label(quad.subject.value)}` + : to(quad.subject.value), + quad.predicate.value, + object.kind === "iri" + ? { kind: "iri", value: to(object.value) } + : object.kind === "blank" + ? { kind: "blank", value: label(object.value) } + : object, + ), + }; }), ); // So would a value of one of its subjects stated otherwise than its record writes it, which no read of the draft shows (GHSA-32jq-jh7h-r7v4). - draftFromQuads(draftNamedQuads(draftUrl, urls, datasets), draftUrl, { refuseHidden: true }); - const quads = releaseQuadsOf(atTarget, targetUrl, issued); + const read = draftReadOf(draftNamedQuads(draftUrl, urls, datasets), draftUrl, { + refuseHidden: true, + }); + const quads = releaseQuadsOf( + atTarget + .filter(({ typed }) => typed === null || !read?.unkept(typed.subject, typed.object)) + .map(({ quad }) => quad), + targetUrl, + issued, + ); const subjects = [...new Set(quads.map((quad) => keyOf(quad.subject)))]; return turtleOf(quads, { base: targetUrl, @@ -525,6 +540,23 @@ function versionOfDocuments(urls: readonly string[], datasets: readonly Dataset[ return JSON.stringify(urls.map((url, at) => [url, readNumberOf(datasets[at]!)])); } +/** + * What a statement of a draft's document says, when it states a class of + * a named subject: that subject and the class, as the draft names them; + * null otherwise. + */ +function classStated( + quad: Quad, + object: ReleaseTerm, + iriOf: (iri: string) => string, +): { subject: string; object: ReleaseTerm } | null { + if (quad.subject.termType === "BlankNode" || quad.predicate.value !== RDF_TYPE) return null; + return { + subject: iriOf(quad.subject.value), + object: object.kind === "iri" ? { kind: "iri", value: iriOf(object.value) } : object, + }; +} + /** * The statements of a draft's documents (`urls`, read as `datasets`), * every IRI as the draft names it, as a read of the draft takes them: a @@ -767,7 +799,8 @@ function involvesBlank(entry: DraftEntry): boolean { * (`existing`): its record written over what its shape owns (recordThing, * which refuses what a newer app wrote), the statements it said before * (`was`) and does not now taken out, those it says now put in; anything - * else the document says of it kept. + * else the document says of it kept, but of its classes only those its + * write keeps (withEntryClasses). */ function rewritten( existing: NamedSubject | null, @@ -786,10 +819,10 @@ function rewritten( : quadsOf(setThing(createSolidDataset(), base)).filter( (quad) => !removed.has(tripleKey(tripleParts(asTriple(quad)))), ); - const quads = [ + const quads = withEntryClasses(entry, [ ...kept, ...entry.raw.map((triple) => quadOf(triple.subject, triple.predicate, triple.object)), - ]; + ]); // The entry says something of its subject (a record's type, or a statement): the subject is there. return getThing(datasetFromQuads(quads), entry.subject)!; }