From 93369ede7a07e513159bb0c21b16a2485ff6981b Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sat, 31 Oct 2026 23:34:58 +0100 Subject: [PATCH 1/2] fix(solid): bound Turtle declarations and stop the parse at a limit The Turtle parser resolves each base declaration (`@base`, `BASE`) against the base before it, and each relative IRI and each prefix declaration's IRI against the base, walking the whole base each time, and once more for each dot segment in it. A chain of relative bases, a long base, a base with dot segments, or many prefix declarations under one, takes time far beyond linear in the text on the main thread. A declaration makes no triple, so none of the read limits counted it, and the parser went on to the end of the text past them: a release small enough to pass every limit could hold the tab for a long time, on adding it from a link, on each later read of it or its catalog for the deck list, and on a Studio import. A Turtle text read under limits is now written to the parser in parts, as a JSON-LD text already is, and the parser gets no more once the text is past its limits. Before each part, the declarations that start in it are checked. More than 16 bases, more than 4,096 prefixes, a base whose path has a `.`, `..` or empty segment or an escape, or a base that can grow past 512 characters (the longest of the address and the absolute bases, with the relative ones added on), refuses the text with the read-limit error, as one past the other limits is: a release with releaseTooLarge, a deck file with deckFileTooLarge. No release the app writes has such a base, and a document needs a few prefixes. The search reads the text as the parser does, past strings, IRIs and comments, so a comment cannot hide a declaration and one quoted in a card's text is not counted; it takes time linear in the text, and where it could read a directive other than the parser, it errs toward a refusal. Both Solid clients do so, in a read of a document, in getSolidDatasetLinear and in parseQuads, and the clients' contract holds both to it, with growth cases for the time to refuse. A text read with no limits, from the user's own pod, has its declarations as it makes them. The docs on release and deck file limits say so. Refs: GHSA-q3q9-h5mp-3956 --- docs/data-model.md | 7 +- docs/deck-library.md | 22 +- docs/testing.md | 2 +- packages/solid-memo-client/src/resources.ts | 53 +++- packages/solid/src/client/linearDataset.ts | 24 +- packages/solid/src/client/readLimits.test.ts | 237 +++++++++++++++- packages/solid/src/client/readLimits.ts | 259 +++++++++++++++++- packages/solid/src/client/resources.ts | 25 +- packages/solid/src/solidDeckArchive.test.ts | 10 +- .../solid/src/testing/contract/resources.ts | 151 +++++++++- 10 files changed, 739 insertions(+), 51 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index b6224deb3..5611b0f83 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1396,7 +1396,12 @@ An import reads the file as the pod is read, with the same mappers: of those triples, which a prefix or a context can make far longer than the text. They leave a wide margin over a deck of tens of thousands of cards with progress, which the pod holds with no limit. - A release file the Studio opens is held to a release's limits + A Turtle file is refused the same way when it declares its base more + than 16 times or more than 4,096 prefixes. So is one whose base can + grow past 512 characters or has a `.`, `..` or empty segment in its + path. Its parse stops there, as it stops at the first triple past the + limits. A declaration quoted in a card's text doesn't count. A + release file the Studio opens is held to a release's limits ([deck-library.md](deck-library.md#from-a-link)); - a triple with a term Turtle can't write as it is is left out (below). diff --git a/docs/deck-library.md b/docs/deck-library.md index 22a534274..4f2467464 100644 --- a/docs/deck-library.md +++ b/docs/deck-library.md @@ -961,10 +961,24 @@ flowchart LR the limit is canceled as soon as it's past it, or before it's read when its `Content-Length` says so. Parsing stops keeping triples at the first one past either of the other limits, before any are built - into a dataset. A JSON-LD text stops being read there too: it's given - to the parser in parts, and the parser is stopped before the next. Each case refuses the release (`releaseTooLarge`), - however the release is read. The same limits apply to a creator's - catalog read for the release's series, to a release the Studio reads, + into a dataset. The text stops being read there too: it's given to + the parser in parts, and the parser gets no more after it. A + Turtle text is also refused at the part where it declares its base + (`@base`, `BASE`) for the 17th time, or where its base can grow past + 512 characters (`writeTurtle`). It's refused there too when a base + it declares has a `.`, `..` or empty segment in its path, or when it + declares a prefix (`@prefix`, `PREFIX`) for the 4,097th time. The + length bound is the longest of the release's address and the + absolute bases it declares, with each relative one added on. The + parser walks the whole base for each relative IRI, base and prefix it + resolves, and a declaration makes no triple to count. The search + reads the text as the parser does, past strings, IRIs and comments, + so a declaration quoted in a card's text doesn't count. With the + bases and prefixes bounded and the parse stopped at the first limit, + the time to read or refuse a release grows with its size, not faster. Each case refuses + the release (`releaseTooLarge`), however the release is read. The + same limits apply to a creator's catalog read for the release's + series, to a release the Studio reads, opens as a file, or checks against the library's shapes. The library's index, which comes with the site and grows with the library, and the user's own pod documents aren't limited. A deck file has limits of its diff --git a/docs/testing.md b/docs/testing.md index a40cbe53a..65995e441 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1022,7 +1022,7 @@ Dependency inversion gives every layer a seam for its fakes: | Layer | Seam | Technique | |---|---|---| -| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the release check's course rules (`courseProblems`), a draft's outline (`draftOutline`) over the chapters and steps, the cards the Studio's outline shows (`draftCardFinder`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike, a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the notes on a release's making (`makingNotesOf`) over many in one language, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, a deck's cards added from a release, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, catalog entries and review states brought to this app's format, review states removed, a day's answers removed and many month summaries dropped from the digest, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay` and `updateDigest` in `solid`), a subject's many values set in one call (`setIriAll` and `setStringAll`, in the client modules' contract tests), and the Studio's graph layout and routes over a deck's cards. | +| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the release check's course rules (`courseProblems`), a draft's outline (`draftOutline`) over the chapters and steps, the cards the Studio's outline shows (`draftCardFinder`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike, a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, the search of a Turtle text for its base and prefix declarations (`writeTurtle` in `solid`), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the notes on a release's making (`makingNotesOf`) over many in one language, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, a deck's cards added from a release, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, catalog entries and review states brought to this app's format, review states removed, a day's answers removed and many month summaries dropped from the digest, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay` and `updateDigest` in `solid`), a subject's many values set in one call (`setIriAll` and `setStringAll`, in the client modules' contract tests), and the Studio's graph layout and routes over a deck's cards. | | Application | ports | Fake ports are injected (`vi.fn` per method): the fixtures and fake ports in [fakePorts.ts](../packages/application/src/testing/fakePorts.ts), and `NO_PORTS` ([noPorts.ts](../packages/application/src/testing/noPorts.ts)), which other packages' tests spread too, for the ports a test doesn't use. Each slice is tested in its own `.test.ts`, the stores the slices share in theirs. No module mocking. | | UI | `UseCases` prop | Screens render with a fake `UseCases`: `makeUseCasesFake` ([useCasesFake.ts](../packages/ui/src/test/useCasesFake.ts)) makes every slice's fake ([test/fakes/](../packages/ui/src/test/fakes/)) in one object, each calling the others through it. A screen that takes a slice (the trial's) can be given that slice's fake alone. Assertions use testing-library queries. Query-dependent components get a fresh `QueryClient` (retries off). | | Infrastructure mappers | none needed | Pure `Dataset`/`Subject` → domain functions, fed in-memory datasets built with `datasetFrom`/`buildThing` ([datasets.ts](../packages/solid/src/testing/datasets.ts)). | diff --git a/packages/solid-memo-client/src/resources.ts b/packages/solid-memo-client/src/resources.ts index 770e00196..08ef447be 100644 --- a/packages/solid-memo-client/src/resources.ts +++ b/packages/solid-memo-client/src/resources.ts @@ -17,7 +17,14 @@ import { type RequestOptions, type SourcedDataset, } from "@solid-memo/solid/client/interface"; -import { limitsOf, quadTally, refusalOf, textChunks } from "@solid-memo/solid/client/readLimits"; +import { + limitsOf, + quadTally, + refusalOf, + type TextStream, + textChunks, + writeTurtle, +} from "@solid-memo/solid/client/readLimits"; import { MAX_PATCH_BYTES, patchBody } from "@solid-memo/solid/client/sparqlUpdate"; import { refuseUnwritable } from "@solid-memo/solid/writableTerms"; import { @@ -151,35 +158,50 @@ function resourceOf(response: Response): Resource { /** * A collector of a text's quads, parsed as the document at `url` under * `limits`: `add` keeps each, until one past them (quadTally), which - * drops what was kept and rejects once, with a ReadLimitError; the - * parser may go on to the end of the text, but nothing more is kept. + * drops what was kept and rejects once, with a ReadLimitError, as + * `refuse` does for a text past them otherwise (writeTurtle); nothing + * more is kept, and `refused` says to stop the parser. */ function collector(url: string, limits: ReadLimits | undefined, reject: (error: Error) => void) { const quads: Quad[] = []; const past = quadTally(limits); let refused = false; + const refuse = (): void => { + refused = true; + quads.length = 0; + // The tally, and the bases, are past limits only under some. + reject(new ReadLimitError(url, limits!)); + }; return { quads, + refuse, + refused: () => refused, add(quad: Quad): void { if (refused) return; if (!past(quad)) return void quads.push(quad); - refused = true; - quads.length = 0; - // The tally is past limits only under some. - reject(new ReadLimitError(url, limits!)); + refuse(); }, }; } -/** The quads of a Turtle text, its relative IRIs resolved against `baseIRI`, under `limits` (collector). */ +/** + * The quads of a Turtle text, its relative IRIs resolved against + * `baseIRI`, under `limits` (collector), as is a base past them + * (writeTurtle). The text is written to the parser in parts, so that it + * reads no further than the part it is in. + */ function parseTurtle(text: string, baseIRI: string, limits?: ReadLimits): Promise { return new Promise((resolve, reject) => { - const { quads, add } = collector(baseIRI, limits, reject); - new Parser({ format: TURTLE, baseIRI }).parse(text, (error, quad) => { - if (error) reject(error); - else if (quad) add(quad); - else resolve(quads); - }); + const { quads, add, refuse, refused } = collector(baseIRI, limits, reject); + const parser = new Parser({ format: TURTLE, baseIRI }); + // n3's parser reads a stream as such an object, as its own stream parser hands it one. + const parse = (stream: TextStream) => + parser.parse(stream as unknown as string, (error, quad) => { + if (error) reject(error); + else if (quad) add(quad); + else resolve(quads); + }); + if (!writeTurtle(text, baseIRI, limits, parse, () => !refused())) refuse(); }); } @@ -639,7 +661,8 @@ const MEDIA_TYPES = { turtle: TURTLE, jsonld: "application/ld+json" } as const; * The quads of a Turtle or JSON-LD text, its relative IRIs resolved * against `baseUrl`, parsed as a pod's documents are: in every graph the * text names. Rejects with the parser's error on a text that does not - * parse, and with a ReadLimitError at the first quad past `limits`. + * parse, and with a ReadLimitError at the first quad past `limits`, or + * on a Turtle text at its first base past them (writeTurtle). */ export async function parseQuads( text: string, diff --git a/packages/solid/src/client/linearDataset.ts b/packages/solid/src/client/linearDataset.ts index f7ce24b37..257e9f4f8 100644 --- a/packages/solid/src/client/linearDataset.ts +++ b/packages/solid/src/client/linearDataset.ts @@ -4,13 +4,14 @@ import { getSolidDataset, + getSourceUrl, getTurtleParser, type Parser, type SolidDataset, } from "@inrupt/solid-client"; import type { Quad } from "@rdfjs/types"; import { ReadLimitError, type ReadLimits } from "./interface"; -import { limitsOf, quadTally, refusalOf } from "./readLimits"; +import { limitsOf, quadTally, refusalOf, type TextStream, writeTurtle } from "./readLimits"; type Graphs = SolidDataset["graphs"]; type Graph = Graphs["default"]; @@ -63,8 +64,9 @@ interface Collected { /** * solid-client's Turtle parser, whose quads go to `collected` instead of * to solid-client; one past the limits of the read of `url` (quadTally) - * fails the parse instead, once, and drops what was kept. The parser - * goes on to the end of the text it was given, but keeps none of it. + * fails the parse instead, once, and drops what was kept, as does a + * base past them (writeTurtle). The text is written to the parser in + * parts, so that it reads no further than the part it is in. */ function collectingTurtleParser(collected: Collected, url: string): Parser { const parser = getTurtleParser(); @@ -80,15 +82,21 @@ function collectingTurtleParser(collected: Collected, url: string): Parser { const quads: Quad[] = []; const past = quadTally(collected.limits); collected.quads = quads; - parser.onQuad((quad) => { - if (collected.refused !== undefined) return; - if (!past(quad)) return quads.push(quad); - // The tally is past limits only under some. + const refuse = () => { + // The tally, and the bases, are past limits only under some. collected.refused = new ReadLimitError(url, collected.limits!); quads.length = 0; for (const callback of failed) callback(collected.refused); + }; + parser.onQuad((quad) => { + if (collected.refused !== undefined) return; + if (!past(quad)) return quads.push(quad); + refuse(); }); - parser.parse(source, resourceInfo); + // solid-client hands the text to n3's parser as it is, which reads a stream as well. + const parse = (stream: TextStream) => parser.parse(stream as unknown as string, resourceInfo); + const goOn = () => collected.refused === undefined; + if (!writeTurtle(source, getSourceUrl(resourceInfo), collected.limits, parse, goOn)) refuse(); }, }; } diff --git a/packages/solid/src/client/readLimits.test.ts b/packages/solid/src/client/readLimits.test.ts index 964926823..6601bb69b 100644 --- a/packages/solid/src/client/readLimits.test.ts +++ b/packages/solid/src/client/readLimits.test.ts @@ -6,8 +6,16 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { DECKS_ROOT } from "@solid-memo/vocab/tooling/root"; -import { ReadLimitError } from "./interface"; -import { limitedFetch, limitsOf, refusalOf, RELEASE_LIMITS, textChunks } from "./readLimits"; +import { ReadLimitError, type ReadLimits } from "./interface"; +import { growth } from "@solid-memo/domain/testing/growth"; +import { + limitedFetch, + limitsOf, + refusalOf, + RELEASE_LIMITS, + textChunks, + writeTurtle, +} from "./readLimits"; import { parseQuads } from "./resources"; const URL_ = "https://other.example/release.ttl"; @@ -103,14 +111,233 @@ describe("limitedFetch", () => { describe("textChunks", () => { it("gives a text in parts that are the text again, each at most one character past a part's length", () => { const text = "0123456789".repeat(1000); - const chunks = textChunks(text); + const chunks = [...textChunks(text)]; expect(chunks.join("")).toBe(text); expect(chunks.length).toBeGreaterThan(1); - expect(textChunks("")).toEqual([]); + expect([...textChunks("")]).toEqual([]); }); it("never ends a part inside a surrogate pair", () => { const text = `${"x".repeat(4095)}\u{1F600}y`; - expect(textChunks(text)).toEqual([`${"x".repeat(4095)}\u{1F600}`, "y"]); + expect([...textChunks(text)]).toEqual([`${"x".repeat(4095)}\u{1F600}`, "y"]); + }); +}); + +describe("writeTurtle", () => { + /** Whether writeTurtle refuses `text`, read as `url` under `limits` (none: null), written to a parser that reads nothing. */ + const refuses = (text: string, url = URL_, limits: ReadLimits | null = LIMITS) => + !writeTurtle( + text, + url, + limits ?? undefined, + () => undefined, + () => true, + ); + const forms = [ + "@base .", + "BASE ", + "@BASE.", + "base # an IRI next\n ", + "Base #\r\n# <>\r\t", + ]; + const declaring = (count: number) => + Array.from({ length: count }, (_, at) => forms[at % forms.length]).join("\n"); + const lines = " .\n".repeat(1000); + + /** What writeTurtle writes of `text` to the stream it hands the parser, and whether it ends it. */ + const writing = (text: string, goOn: (written: string[]) => boolean) => { + const written: string[] = []; + let ended = false; + const wrote = writeTurtle( + text, + URL_, + LIMITS, + (stream) => { + stream.on("data", (part) => written.push(part!)); + stream.on("end", () => { + ended = true; + }); + }, + () => goOn(written), + ); + return { wrote, written, ended }; + }; + + it("writes the text in parts, then its end, to the stream it hands the parser", () => { + const { wrote, written, ended } = writing(lines, () => true); + expect(wrote).toBe(true); + expect(written.length).toBeGreaterThan(1); + expect(written.join("")).toBe(lines); + expect(ended).toBe(true); + }); + + it("writes an empty text as a blank one, which the parser ends as it ends any", () => { + expect(writing("", () => true)).toEqual({ wrote: true, written: [" "], ended: true }); + }); + + it("writes no more, nor the end, once told not to go on", () => { + const { wrote, written, ended } = writing(lines, (written) => written.length < 1); + expect(wrote).toBe(true); + expect(written).toHaveLength(1); + expect(ended).toBe(false); + }); + + it("refuses a text at the part where a base past the limits starts, and writes none of it", () => { + const text = `${lines}@base <${"a".repeat(600)}/> .\n${lines}`; + const { wrote, written, ended } = writing(text, () => true); + expect(wrote).toBe(false); + expect(written.length).toBeGreaterThan(0); + expect(text.startsWith(written.join(""))).toBe(true); + expect(written.join("").length).toBeLessThanOrEqual(lines.length); + expect(ended).toBe(false); + }); + + it("lets a text under limits declare its base 16 times, in any form, and no more", () => { + expect(refuses(declaring(16))).toBe(false); + expect(refuses(declaring(17))).toBe(true); + // Each of these is a declaration to the parser too. + expect(refuses(`${declaring(16)}\n .BASE `)).toBe(true); + expect(refuses(`${declaring(16)}\n "c"base `)).toBe(true); + expect(refuses(`${declaring(16)}\n \t.@base.`)).toBe(true); + }); + + it("counts no declaration in a string, an IRI, a comment or a name, as the parser does not", () => { + const quoted = [ + '"When the base < 2, the logarithm is undefined."', + '"git rebase
"', + "'BASE '", + '"Database
"', + '"""Write\n@base <../> .\nto go up."""', + "'''@prefix ex: .'''", + '"a \\"quote\\" and base "', + '"""a "" and ""\\""" base """', + '"an escaped backslash \\\\"', + ]; + const cards = quoted.map((text) => `
${text} .\n`).join(""); + expect(refuses(cards.repeat(20))).toBe(false); + expect(refuses(" .\n .\n".repeat(20))).toBe(false); + expect(refuses("ex:base .\n_:base .\n".repeat(20))).toBe(false); + expect(refuses("# BASE \n .\n".repeat(20))).toBe(false); + // A string with no end runs to the end of the text, as the parser reads it. + expect(refuses(` "${"BASE ".repeat(20)}`)).toBe(false); + }); + + it("lets the longest of the URL and the absolute bases, the relative ones added on, come to 512 characters, and no more", () => { + // Two characters of IRI each, between its < and its >. + const url = `https://other.example/${"u".repeat(512 - 22 - 2 * 16)}`; + expect(refuses(declaring(16), url)).toBe(false); + expect(refuses(declaring(16), `${url}u`)).toBe(true); + expect(refuses(" .", `${url}${"u".repeat(2 * 16)}`)).toBe(false); + expect(refuses(" .", `${url}${"u".repeat(2 * 16 + 1)}`)).toBe(true); + // An absolute base is the base on its own, whatever came before it. + const absolute = `@base .\n`; + expect(refuses(absolute.repeat(16))).toBe(false); + expect(refuses(absolute.replace("<", " .`)).toBe(true); + }); + + it("finds the IRI of a base past white space and comments, as the parser does, and none in a string", () => { + const long = ``; + expect(refuses(`@base # <>\n${long} .`)).toBe(true); + expect(refuses(`@base # <> \r${long} .`)).toBe(true); + expect(refuses(`BASE #\r\n # base <>\n\t${long}`)).toBe(true); + // A `base` in a string, with what looks like a comment next, hides none after it. + expect(refuses(`

"base # " .\n@base ${long} .`)).toBe(true); + expect(refuses(`

"base #base" . @base ${long} .`)).toBe(true); + }); + + it("refuses a base whose path has a dot segment, an empty segment or an escape, and lets its authority, query and fragment be", () => { + for (const iri of [ + "https://other.example/a/./b/", + "https://other.example/a/../", + "https://other.example/a/..", + "https://other.example/.", + "https://other.example/a//b/", + "./a/", + "../", + "a/.", + "a//b/", + "//other.example/a/", + "https://other.example/a\\u002E\\u002E/", + ]) { + expect(refuses(`@base <${iri}> .`), iri).toBe(true); + } + for (const iri of [ + "https://other.example/a/b/", + "https://other.example", + "https://other.example?a/../b", + "https://other.example/a.b/..c/.d/?q=/./#/../", + "a.b/c..", + "a/b?./..", + "#./", + ]) { + expect(refuses(`@base <${iri}> .`), iri).toBe(false); + } + }); + + it("lets a text under limits declare 4,096 prefixes, in any form, and no more", () => { + const prefixForms = [ + "@prefix ex: .", + "PREFIX : ", + "@prefix:.", + "prefix # a name next\n a.b-c: ", + "PrEfIx\r\n# <>\r\té:\t", + ]; + const prefixes = (count: number) => + Array.from({ length: count }, (_, at) => prefixForms[at % prefixForms.length]).join("\n"); + expect(refuses(prefixes(4096))).toBe(false); + expect(refuses(prefixes(4097))).toBe(true); + // One in a string declares none, nor does the word as part of a name. + expect(refuses(` "prefix ex: " .\n${prefixes(4096)}`)).toBe(false); + expect(refuses(' "a prefix, the prefixes" .\n'.repeat(5000))).toBe(false); + expect(refuses(`${"ex:prefixed ".repeat(5000)}`)).toBe(false); + expect(refuses(prefixes(5000), URL_, null)).toBe(false); + }); + + it("counts a base with no IRI of 512 characters at most as past the limits, and a `base` with no IRI next as no declaration", () => { + expect(refuses(`@base <${"a".repeat(10)}`)).toBe(true); + expect(refuses("@base .")).toBe(true); + expect(refuses("@base .")).toBe(true); + expect(refuses("@base #")).toBe(false); + // An IRI with no end is no IRI to the parser. + expect(refuses(` \n".repeat(100))).toBe(false); + }); + + it("limits no text read with no limits", () => { + expect(refuses(declaring(100), URL_, null)).toBe(false); + expect(refuses(`@base <${"a".repeat(10_000)}> .`, URL_, null)).toBe(false); + }); + + it("looks through a text in time that grows with it, not its square", () => { + // Eight times the text must take less than four times as long as an eighth of it done eight times over (testing.md). + for (const words of [ + // Many a `base` in comments, each with the rest of them to look past. + "#base\n", + "base #", + // White space after each. + "base \t ", + // A name each, which runs on to the end of the text. + "prefix", + // Names in comments, each with the rest of them to look past. + "# prefix prefixprefix", + "#prefixprefix\nprefix", + // Strings with no end, each with the rest of the text to look through. + '"\\', + "'''\\'", + // IRIs with no end, each up to the next. + " { + const text = words.repeat(count); + return () => refuses(text); + }, + 20_000, + 8, + ); + expect(ratio).toBeLessThan(4); + } }); }); diff --git a/packages/solid/src/client/readLimits.ts b/packages/solid/src/client/readLimits.ts index 72c067233..9782f8cce 100644 --- a/packages/solid/src/client/readLimits.ts +++ b/packages/solid/src/client/readLimits.ts @@ -96,6 +96,259 @@ export function quadTally(limits: ReadLimits | undefined): (quad: Quad) => boole }; } +/** + * The most base declarations (`@base`, `BASE`) a Turtle text read under + * limits may make. The parser resolves each against the one before it, + * walking the whole base each time, and a declaration makes no triple + * for quadTally to count. A document needs one at most; each release in + * the library has one. + */ +const MAX_BASE_DECLARATIONS = 16; + +/** + * The most prefix declarations (`@prefix`, `PREFIX`) a Turtle text read + * under limits may make. The parser resolves the IRI of each against + * the base, walking the whole base, and a declaration makes no triple + * for quadTally to count. A document needs a few; the bound leaves a + * wide margin over that. + */ +const MAX_PREFIX_DECLARATIONS = 4096; + +/** + * The most characters the base of a Turtle text read under limits may + * reach. The parser walks the whole base for each relative IRI it + * resolves, and a triple's terms can be far shorter than the base (an + * IRI that goes up a level), so the read limits on the triples bound + * the time only with the base bounded too. A release names itself as + * its base, so this is about the longest address it can have. + */ +const MAX_BASE_CHARACTERS = 512; + +const SPACE = 0x20; +const TAB = 0x09; +const LF = 0x0a; +const CR = 0x0d; +const HASH = 0x23; +const LESS_THAN = 0x3c; +const GREATER_THAN = 0x3e; +const AT_SIGN = 0x40; +const BACKSLASH = 0x5c; + +/** An IRI with a scheme, which a base resolves to on its own, as the Turtle parser tells one. */ +const ABSOLUTE_IRI = /^[a-z][a-z0-9+.-]*:/i; + +/** A `.` or `..` segment of a path. */ +const DOT_SEGMENT = /(?:^|\/)\.\.?(?:$|\/)/; + +/** + * Where the search of a Turtle text stops to look: a string, an IRI, an + * escape, a comment, or what may start a directive. + */ +const NEXT = /["'<\\#@bBpP]/g; + +/** + * Where an IRI the parser reads stops: at its `>`, or at what no IRI + * holds as it is written, where there is none. The parser refuses an + * IRI with any other character an IRI can't hold, once it has read it. + */ +const IRI_STOP = /[ <>{}]|\\(?![uU])/g; + +/** A directive with an at sign, as the parser reads one: a longer word is none. */ +const AT_DIRECTIVE = /@(base|prefix)(?![a-z])/iy; + +/** A directive in SPARQL's form, as the parser reads one, in any case. */ +const DIRECTIVE = /(base|prefix)(?=[\s#<"'])/iy; + +/** What a name goes on with: a directive in SPARQL's form starts no later than the name does. */ +const IN_NAME = /[\w:-]/; + +/** + * Whether a declared base, the IRI as written, has a plain path: no `.` + * or `..` segment and no empty one, and no escape that could stand for + * one. The parser walks such segments of the base again for every IRI + * it resolves against it, and no release the app writes has one. + */ +function plainBase(iri: string): boolean { + if (iri.includes("\\")) return false; + const scheme = ABSOLUTE_IRI.exec(iri)?.[0].length ?? 0; + let path = iri.slice(scheme); + if (scheme > 0 && path.startsWith("//")) { + const authority = path.slice(2).search(/[/?#]/); + path = authority < 0 ? "" : path.slice(2 + authority); + } + const query = path.search(/[?#]/); + if (query >= 0) path = path.slice(0, query); + return !path.includes("//") && !DOT_SEGMENT.test(path); +} + +/** Where the IRI that starts at `at` (its `<`) of a Turtle text ends, as the parser reads it: -1 when none does. */ +function iriEnd(text: string, at: number): number { + IRI_STOP.lastIndex = at + 1; + const stop = IRI_STOP.exec(text)?.index ?? -1; + return text.charCodeAt(stop) === GREATER_THAN ? stop + 1 : -1; +} + +/** + * Where the string that starts at `at` of a Turtle text ends, as the + * parser ends it: at the first of its quotes (three of them for a long + * string) that no escape takes, or at the end of the text. A string the + * parser refuses, such as a short one over two lines, ends the parse + * there, so what the search makes of the rest is the same. + */ +function stringEnd(text: string, at: number): number { + const quote = text[at]!; + const closing = text.startsWith(quote.repeat(3), at) ? quote.repeat(3) : quote; + let from = at + closing.length; + for (;;) { + const found = text.indexOf(closing, from); + if (found < 0) return text.length; + let escapes = 0; + while (text.charCodeAt(found - escapes - 1) === BACKSLASH) escapes++; + if (escapes % 2 === 0) return found + closing.length; + from = found + 1; + } +} + +/** + * Whether the base and prefix declarations of a Turtle text read as + * `url` under `limits`, those that start before an offset of the text, + * are past what such a read takes in: more than MAX_BASE_DECLARATIONS + * bases, more than MAX_PREFIX_DECLARATIONS prefixes, a base whose path + * is not plain (plainBase), or a base of more than MAX_BASE_CHARACTERS + * (never, with no limits). Each call looks on from where the last + * stopped, so a text is looked through once, as far as it is written + * (writeTurtle), in time linear in it. + * + * The search reads the text as the parser does: past strings (with + * their escapes), IRIs and comments, where a declaration is only text, + * a directive (`@base`, `@prefix`, or `BASE`, `PREFIX` in any case) is + * one where the parser could start one, and a base's IRI the first after + * it, past white space and comments: a `<` there with no IRI of at most + * MAX_BASE_CHARACTERS from it is past the limits. Where the two could + * differ, the search errs toward a refusal, or the parser stops at an + * error before it. The longest base the parser can reach is then the + * longest of the URL and the absolute IRIs, with every relative IRI + * added on. + */ +function declarationsPast( + text: string, + url: string, + limits: ReadLimits | undefined, +): (end: number) => boolean { + if (limits === undefined) return () => false; + const lineEnd = /[\n\r]/g; + let bases = 0; + let prefixes = 0; + let longest = url.length; + let added = 0; + + /** Where the parser reads the next token from `from` on, past white space and comments. */ + const pastSeparators = (from: number): number => { + let at = from; + for (;;) { + const code = text.charCodeAt(at); + if (code === SPACE || code === TAB || code === LF || code === CR) at++; + else if (code === HASH) { + lineEnd.lastIndex = at; + at = lineEnd.exec(text)?.index ?? text.length; + } else return at; + } + }; + + /** Whether the directive `word`, which ends at `from`, declares one past the limits. */ + const declarationPast = (word: string, from: number): boolean => { + if (word.length !== 4) return ++prefixes > MAX_PREFIX_DECLARATIONS; + const at = pastSeparators(from); + // With no IRI next, the parser stops at an error. + if (text.charCodeAt(at) !== LESS_THAN) return false; + if (++bases > MAX_BASE_DECLARATIONS) return true; + // An IRI no longer than a base may be, or one the parser stops at. + const end = iriEnd(text.slice(at, at + MAX_BASE_CHARACTERS + 2), 0); + if (end < 0) return true; + const iri = text.slice(at + 1, at + end - 1); + if (!plainBase(iri)) return true; + if (ABSOLUTE_IRI.test(iri)) longest = Math.max(longest, iri.length); + else added += iri.length; + return longest + added > MAX_BASE_CHARACTERS; + }; + + let refused = url.length > MAX_BASE_CHARACTERS; + + /** Where the search goes on after what starts at `at`, having checked a directive there. */ + const lookAt = (at: number): number => { + const code = text.charCodeAt(at); + if (code === HASH) { + lineEnd.lastIndex = at; + return lineEnd.exec(text)?.index ?? text.length; + } + if (code === BACKSLASH) return at + 2; + if (code === LESS_THAN) { + const end = iriEnd(text, at); + return end < 0 ? at + 1 : end; + } + if (text[at] === '"' || text[at] === "'") return stringEnd(text, at); + const directive = + code === AT_SIGN ? AT_DIRECTIVE : IN_NAME.test(text[at - 1] ?? "") ? undefined : DIRECTIVE; + if (directive === undefined) return at + 1; + directive.lastIndex = at; + const word = directive.exec(text)?.[1]; + if (word === undefined) return at + 1; + refused = declarationPast(word, directive.lastIndex); + return directive.lastIndex; + }; + + // Where the search goes on from: past all it looked at. + let searched = 0; + return (end) => { + while (!refused && searched < end) { + NEXT.lastIndex = searched; + const next = NEXT.exec(text)?.index ?? text.length; + searched = next < end ? lookAt(next) : next; + } + return refused; + }; +} + +/** A stream as the Turtle parser reads one: it listens for its parts (`data`) and its `end`. */ +export interface TextStream { + on(event: string, listener: (part?: string) => void): void; +} + +/** + * Parse a Turtle text read as `url` under `limits`, by a parser that + * reads streams: `parse` hands it the text as one (n3's parser reads + * such an object, as its own stream parser hands it one), and the text + * is written to it in parts (textChunks), then ended, while `goOn` says + * to. A parser given the whole text goes on through all of it after the + * tally is past its limits; here it reads no further than the part it + * is in. Before each part, the base and prefix declarations that start + * in it, or before, are checked (declarationsPast): false when they are past + * the limits, and the rest is not written; true otherwise. + */ +export function writeTurtle( + text: string, + url: string, + limits: ReadLimits | undefined, + parse: (stream: TextStream) => void, + goOn: () => boolean, +): boolean { + const listeners = new Map void>(); + parse({ on: (event, listener) => void listeners.set(event, listener) }); + const past = declarationsPast(text, url, limits); + let end = 0; + for (const part of textChunks(text)) { + end += part.length; + if (past(end)) return false; + if (!goOn()) return true; + listeners.get("data")?.(part); + } + if (!goOn()) return true; + // The parser ends only a text it was given some of: an empty one is a blank one to it. + if (end === 0) listeners.get("data")?.(" "); + listeners.get("end")?.(); + return true; +} + function length(term: Term): number { if (term.termType !== "Literal") return term.value.length; return term.value.length + term.datatype.value.length + term.language.length; @@ -111,14 +364,12 @@ const TEXT_CHUNK = 4096; * through all of it after the tally is past its limits; one written in * parts can be stopped (destroyed) between them, with the rest unread. */ -export function textChunks(text: string): string[] { - const chunks: string[] = []; +export function* textChunks(text: string): Generator { for (let start = 0; start < text.length; ) { let end = start + TEXT_CHUNK; const last = text.charCodeAt(end - 1); if (last >= 0xd800 && last <= 0xdbff) end++; - chunks.push(text.slice(start, end)); + yield text.slice(start, end); start = end; } - return chunks; } diff --git a/packages/solid/src/client/resources.ts b/packages/solid/src/client/resources.ts index 3b057b5a1..4a79778dc 100644 --- a/packages/solid/src/client/resources.ts +++ b/packages/solid/src/client/resources.ts @@ -37,7 +37,7 @@ import { } from "./interface"; import { getSolidDatasetLinear as libraryGetSolidDatasetLinear } from "./linearDataset"; import { changesOf, datasetAt, localNameOf, sourceOf, toRdfJsDataset } from "./rdf"; -import { quadTally, textChunks } from "./readLimits"; +import { quadTally, type TextStream, textChunks, writeTurtle } from "./readLimits"; import { MAX_PATCH_BYTES, patchBody } from "./sparqlUpdate"; import { refuseUnwritable } from "../writableTerms"; @@ -356,8 +356,9 @@ const MEDIA_TYPES = { turtle: "text/turtle", jsonld: "application/ld+json" } as * text names. Rejects with the parser's error on a text that does not * parse, and with a ReadLimitError at the first quad past `limits` * (quadTally; the text's own size is its caller's to check: it holds the - * text already). JSON-LD is read with no fetch at all: a context named - * by its address is refused (parseJsonLd). + * text already), or on a Turtle text at its first base past them + * (writeTurtle); the parser reads no further. JSON-LD is read with no + * fetch at all: a context named by its address is refused (parseJsonLd). */ export function parseQuads( text: string, @@ -369,17 +370,20 @@ export function parseQuads( const quads: Quad[] = []; const past = quadTally(limits); let refused = false; - // Whether to go on: false once past limits, for the JSON-LD parser to stop (parseJsonLd). + const refuse = () => { + refused = true; + quads.length = 0; + // The tally, and the bases, are past limits only under some. + reject(new ReadLimitError(baseUrl, limits!)); + }; + // Whether to go on: false once past limits, for the parser to stop (parseJsonLd, writeTurtle). const onQuad = (quad: Quad): boolean => { if (refused) return false; if (!past(quad)) { quads.push(quad); return true; } - refused = true; - quads.length = 0; - // The tally is past limits only under some. - reject(new ReadLimitError(baseUrl, limits!)); + refuse(); return false; }; const done = () => resolve(quads); @@ -391,7 +395,10 @@ export function parseQuads( parser.onQuad(onQuad); parser.onError(reject); parser.onComplete(done); - parser.parse(text, serverDataset(datasetAt(baseUrl, MEDIA_TYPES[format]))); + const resourceInfo = serverDataset(datasetAt(baseUrl, MEDIA_TYPES[format])); + // solid-client hands the text to n3's parser as it is, which reads a stream as well. + const parse = (stream: TextStream) => parser.parse(stream as unknown as string, resourceInfo); + if (!writeTurtle(text, baseUrl, limits, parse, () => !refused)) refuse(); }); } diff --git a/packages/solid/src/solidDeckArchive.test.ts b/packages/solid/src/solidDeckArchive.test.ts index a6414749a..5cab985b1 100644 --- a/packages/solid/src/solidDeckArchive.test.ts +++ b/packages/solid/src/solidDeckArchive.test.ts @@ -316,9 +316,13 @@ describe("readDeckFile", () => { }); it("refuses as unreadable a text the parser throws on at once, not only one it reports", async () => { - // Not a string: the Turtle parser takes it for a stream and throws before it reads. - const text = {} as unknown as string; - await expect(parseDeckFile(text, "turtle", base)).rejects.toMatchObject({ + // Not a string: reading it throws before the parser reads any of it. + const text = { + get length(): number { + throw new TypeError("Not a text"); + }, + } as unknown as string; + await expect(parseDeckFile(text, "turtle", base, null)).rejects.toMatchObject({ code: "deckFileUnreadable", }); }); diff --git a/packages/solid/src/testing/contract/resources.ts b/packages/solid/src/testing/contract/resources.ts index f05681a8a..d6eb0c970 100644 --- a/packages/solid/src/testing/contract/resources.ts +++ b/packages/solid/src/testing/contract/resources.ts @@ -2,10 +2,11 @@ // // SPDX-License-Identifier: MIT +import { growthAsync } from "@solid-memo/domain/testing/growth"; import { JsonLdParser } from "jsonld-streaming-parser"; import { describe, expect, it, vi } from "vitest"; import { HttpError, ReadLimitError } from "../../client/interface"; -import { limitedFetch } from "../../client/readLimits"; +import { limitedFetch, RELEASE_LIMITS } from "../../client/readLimits"; import { MAX_PATCH_BYTES, patchBody } from "../../client/sparqlUpdate"; import { type DataClient, turtleAt } from "./rdf"; @@ -219,6 +220,154 @@ export function resourcesContract({ rdf, resources }: DataClient): void { getThing(await getSolidDatasetLinear(URL_, { fetch: within }), `${long}a`), ).not.toBeNull(); }); + + it("refuses a document at its base when it declares one too often, and reads one that declares it a few times", async () => { + const bases = (count: number) => `${"@base .\n".repeat(count)}`; + const past = `${bases(17)} <${EX}p> "x" .`; + // Limits the text is well within but for its bases. + const limits = { bytes: past.length, quads: 10, characters: 100_000 }; + const fetch = limitedFetch(turtleAt(past), limits); + for (const read of [ + () => getSolidDatasetLinear(URL_, { fetch }), + () => readDocument(URL_, { fetch }), + () => parseQuads(past, "turtle", URL_, limits), + ]) { + await expect(read()).rejects.toMatchObject({ name: "ReadLimitError", url: URL_, limits }); + } + // Each relative base adds to the one before, which the parser walks for each one. + const few = `${"@base .\n".repeat(16)} <${EX}p> "x" .`; + const subject = `https://pod.example/decks/${"x/".repeat(16)}a`; + const dataset = await getSolidDatasetLinear(URL_, { + fetch: limitedFetch(turtleAt(few), limits), + }); + expect(getThing(dataset, subject)).not.toBeNull(); + const [quad] = await parseQuads(few, "turtle", URL_, limits); + expect(quad?.subject.value).toBe(subject); + // A text from the user's own pod is read with no limits, its bases as many as it says. + expect(await parseQuads(`${bases(17)} <${EX}p> "x" .`, "turtle", URL_)).toHaveLength(1); + }); + + it("refuses a document at its base when the base is too long, and reads one whose base is not", async () => { + // The parser walks the whole base for each relative IRI it resolves against it. + const based = (length: number) => + `@base <${"a".repeat(length)}/> .\n${"<./b> <./b> <./b> .\n".repeat(1_000)}`; + const past = based(600); + // Limits the text is well within but for its base. + const limits = { bytes: past.length, quads: 1_000, characters: 100_000_000 }; + const fetch = limitedFetch(turtleAt(past), limits); + for (const read of [ + () => getSolidDatasetLinear(URL_, { fetch }), + () => readDocument(URL_, { fetch }), + () => parseQuads(past, "turtle", URL_, limits), + ]) { + await expect(read()).rejects.toMatchObject({ name: "ReadLimitError", url: URL_, limits }); + } + const within = based(300); + const subject = `https://pod.example/decks/${"a".repeat(300)}/b`; + const dataset = await getSolidDatasetLinear(URL_, { + fetch: limitedFetch(turtleAt(within), limits), + }); + expect(getThing(dataset, subject)).not.toBeNull(); + expect(await parseQuads(within, "turtle", URL_, limits)).toHaveLength(1_000); + // A text from the user's own pod is read with no limits, its base as long as it says. + expect(await parseQuads(past, "turtle", URL_)).toHaveLength(1_000); + }); + + it("refuses a document whose base is too long past a comment that names a short one", async () => { + const past = `@base # <>\n<${"a".repeat(600)}/> .\n${"<./b> <./b> <./b> .\n".repeat(1_000)}`; + const limits = { bytes: past.length, quads: 1_000, characters: 100_000_000 }; + const fetch = limitedFetch(turtleAt(past), limits); + for (const read of [ + () => getSolidDatasetLinear(URL_, { fetch }), + () => readDocument(URL_, { fetch }), + () => parseQuads(past, "turtle", URL_, limits), + ]) { + await expect(read()).rejects.toMatchObject({ name: "ReadLimitError", url: URL_, limits }); + } + }); + + it("reads a document whose text speaks of bases and prefixes, as a release's cards may, as any other", async () => { + const texts = [ + "When the base < 2, the logarithm is undefined.", + "Run git rebase

to replay the commits.", + "BASE sets the base.", + "Database
", + "Write @base <../> . to go up a level.", + "@prefix ex: . names a prefix.", + ]; + // More of them than a document may declare bases, in short, long and single-quoted strings. + const cards = Array.from({ length: 20 }, (_, at) => { + const text = texts[at % texts.length]!; + const literal = [`"${text}"`, `"""${text}\n"""`, `'${text}'`][at % 3]; + return `<#c${at}> <${EX}text> ${literal} .\n`; + }).join(""); + const fetch = limitedFetch(turtleAt(cards), RELEASE_LIMITS); + const dataset = await getSolidDatasetLinear(URL_, { fetch }); + expect(getThing(dataset, `${URL_}#c19`)).not.toBeNull(); + expect("dataset" in (await readDocument(URL_, { fetch }))).toBe(true); + const quads = await parseQuads(cards, "turtle", URL_, RELEASE_LIMITS); + expect(quads).toHaveLength(20); + expect(quads[0]?.object.value).toBe(texts[0]); + }); + + it("refuses a document at its base when the base's path has dot segments, and at its prefixes when it declares too many", async () => { + const limits = { bytes: Number.MAX_SAFE_INTEGER, quads: 1_000, characters: 100_000_000 }; + for (const past of [ + `@base .\n
<${EX}p> "x" .`, + `@base .\n <${EX}p> "x" .`, + `${"PREFIX : <.>\n".repeat(4097)} <${EX}p> "x" .`, + ]) { + const fetch = limitedFetch(turtleAt(past), limits); + for (const read of [ + () => getSolidDatasetLinear(URL_, { fetch }), + () => readDocument(URL_, { fetch }), + () => parseQuads(past, "turtle", URL_, limits), + ]) { + await expect(read()).rejects.toMatchObject({ name: "ReadLimitError", url: URL_, limits }); + } + } + const few = `${"PREFIX : <.>\n".repeat(4096)}:a <${EX}p> "x" .`; + const [quad] = await parseQuads(few, "turtle", URL_, limits); + expect(quad?.subject.value).toBe("https://pod.example/decks/a"); + }); + + it("refuses a text of prefixes, or of IRIs that go up from a base, in time that does not grow with the rest", async () => { + const limits = { bytes: Number.MAX_SAFE_INTEGER, quads: 10, characters: 100_000_000 }; + const dotted = `@base .\n`; + const longest = `@base .\n`; + for (const [head, line] of [ + [dotted, "PREFIX :<.>\n"], + [dotted, "<.> <.> <.> .\n"], + [longest, "PREFIX :<.>\n"], + ]) { + // A text eight times as long must take less than half as long as the short one done eight times over. + const ratio = await growthAsync( + (count) => { + const text = `${head}${line.repeat(count)}`; + return () => parseQuads(text, "turtle", URL_, limits).catch(() => undefined); + }, + 20_000, + 8, + ); + expect(ratio).toBeLessThan(0.5); + } + }); + + it("stops parsing a Turtle text at its first triple past the limits, however long the rest", async () => { + // A base as long as is let through, and IRIs that go up from it: each walks the whole base. + const head = `@base .\n${" .\n".repeat(20)}`; + const limits = { bytes: Number.MAX_SAFE_INTEGER, quads: 10, characters: 100_000_000 }; + // A text eight times as long must take less than half as long as the short one done eight times over. + const ratio = await growthAsync( + (count) => { + const text = `${head}${"<../b> <../b> <../b> .\n".repeat(count)}`; + return () => parseQuads(text, "turtle", URL_, limits).catch(() => undefined); + }, + 20_000, + 8, + ); + expect(ratio).toBeLessThan(0.5); + }); }); describe("readDocument", () => { From d5c7f0b2acb2ed2f0830d1b0cd9795cb6bf28962 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 1 Nov 2026 05:31:57 +0100 Subject: [PATCH 2/2] test(solid): write out the over-long absolute base in the read limits test CodeQL flagged replace("<", " { expect(refuses(" .", `${url}${"u".repeat(2 * 16)}`)).toBe(false); expect(refuses(" .", `${url}${"u".repeat(2 * 16 + 1)}`)).toBe(true); // An absolute base is the base on its own, whatever came before it. - const absolute = `@base .\n`; + const iri = `https://other.example/${"a".repeat(512 - 22)}`; + const absolute = `@base <${iri}> .\n`; expect(refuses(absolute.repeat(16))).toBe(false); - expect(refuses(absolute.replace("<", " .\n`)).toBe(true); expect(refuses(`${absolute}@base .`)).toBe(true); });