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)!; }