From f5435336ad3f938621caf9160238b7f41e8a7932 Mon Sep 17 00:00:00 2001 From: Jason Doyle <46789294+Jason-Doyle@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:05:21 -0700 Subject: [PATCH] Fix CodeQL URL and heading findings --- scripts/markdown-heading-anchor.d.mts | 1 + scripts/markdown-heading-anchor.mjs | 30 +++++++++++++++++++++++++++ scripts/verify-docs.mjs | 10 ++------- src/browser/remote-reader.ts | 12 +++++++++-- tests/remote-reader.test.ts | 28 ++++++++++++++++++++++++- tests/verify-docs.test.ts | 21 +++++++++++++++++++ 6 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 scripts/markdown-heading-anchor.d.mts create mode 100644 scripts/markdown-heading-anchor.mjs create mode 100644 tests/verify-docs.test.ts diff --git a/scripts/markdown-heading-anchor.d.mts b/scripts/markdown-heading-anchor.d.mts new file mode 100644 index 0000000..281d023 --- /dev/null +++ b/scripts/markdown-heading-anchor.d.mts @@ -0,0 +1 @@ +export function markdownHeadingAnchor(heading: string): string; diff --git a/scripts/markdown-heading-anchor.mjs b/scripts/markdown-heading-anchor.mjs new file mode 100644 index 0000000..81b87f2 --- /dev/null +++ b/scripts/markdown-heading-anchor.mjs @@ -0,0 +1,30 @@ +export function markdownHeadingAnchor(heading) { + return withoutInlineHtml( + heading.replace(/\[([^\]]+)]\([^)]+\)/g, "$1"), + ) + .replace(/[`*_~]/g, "") + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s_-]/gu, "") + .replace(/\s+/g, "-"); +} + +function withoutInlineHtml(value) { + let result = ""; + let cursor = 0; + while (cursor < value.length) { + const opening = value.indexOf("<", cursor); + if (opening === -1) { + result += value.slice(cursor); + break; + } + result += value.slice(cursor, opening); + const closing = value.indexOf(">", opening + 1); + if (closing === -1) { + result += value.slice(opening); + break; + } + cursor = closing + 1; + } + return result; +} diff --git a/scripts/verify-docs.mjs b/scripts/verify-docs.mjs index db7b79d..4687c90 100644 --- a/scripts/verify-docs.mjs +++ b/scripts/verify-docs.mjs @@ -5,6 +5,7 @@ import { } from "node:fs"; import { createHash } from "node:crypto"; import path from "node:path"; +import { markdownHeadingAnchor } from "./markdown-heading-anchor.mjs"; const root = process.cwd(); const docsRoot = path.join(root, "docs"); @@ -377,14 +378,7 @@ function markdownAnchors(file) { const duplicateCounts = new Map(); const content = withoutFencedCode(readFileSync(file, "utf8")); for (const match of content.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) { - const base = match[1] - .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") - .replace(/<[^>]+>/g, "") - .replace(/[`*_~]/g, "") - .toLowerCase() - .trim() - .replace(/[^\p{L}\p{N}\s_-]/gu, "") - .replace(/\s+/g, "-"); + const base = markdownHeadingAnchor(match[1]); const count = duplicateCounts.get(base) ?? 0; duplicateCounts.set(base, count + 1); anchors.add(count === 0 ? base : `${base}-${count}`); diff --git a/src/browser/remote-reader.ts b/src/browser/remote-reader.ts index f56ad9e..4beb6e4 100644 --- a/src/browser/remote-reader.ts +++ b/src/browser/remote-reader.ts @@ -160,7 +160,7 @@ implements PointReadBundleReader { ): Promise { const url = new URL(this.baseUrl, this.origin); url.pathname = [ - url.pathname.replace(/\/+$/, ""), + withoutTrailingSlashes(url.pathname), encodeURIComponent(this.scopeId), encodeURIComponent(collection), encodeURIComponent(id), @@ -305,7 +305,7 @@ export function objectUrl( const url = new URL(baseUrl, origin); const query = url.search; url.search = ""; - const basePath = url.pathname.replace(/\/+$/, ""); + const basePath = withoutTrailingSlashes(url.pathname); const objectPath = key .split("/") .map((segment) => encodeURIComponent(segment)) @@ -315,6 +315,14 @@ export function objectUrl( return url.toString(); } +function withoutTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === "/") { + end -= 1; + } + return value.slice(0, end); +} + function readBundleFromJson(value: unknown): TrieReadBundle { if ( typeof value !== "object" || diff --git a/tests/remote-reader.test.ts b/tests/remote-reader.test.ts index 7450d57..8675f80 100644 --- a/tests/remote-reader.test.ts +++ b/tests/remote-reader.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { EnvelopeJsonObjectReader, HttpJsonObjectReader, + HttpPointReadBundleReader, ScopedJsonObjectReader, type ByteObjectReader, } from "../src/browser/remote-reader.js"; @@ -35,7 +36,7 @@ describe("HttpJsonObjectReader", () => { } as typeof fetch; const reader = new HttpJsonObjectReader( - "https://storage.example.test/base?sig=read", + "https://storage.example.test/base///?sig=read", receiverSensitiveFetch, ); @@ -116,3 +117,28 @@ describe("HttpJsonObjectReader", () => { ); }); }); + +describe("HttpPointReadBundleReader", () => { + it("handles slash-heavy base paths without regex backtracking", async () => { + const slashRun = "/".repeat(4_096); + const requests: string[] = []; + const reader = new HttpPointReadBundleReader( + `https://storage.example.test/${slashRun}bundles///`, + "user:user-1", + (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response(null, { status: 404 }), + ); + }, + ); + + await expect( + reader.get("notes", "note-1"), + ).resolves.toEqual({ status: "fallback" }); + expect(requests).toEqual([ + `https://storage.example.test/${slashRun}` + + "bundles/user%3Auser-1/notes/note-1", + ]); + }); +}); diff --git a/tests/verify-docs.test.ts b/tests/verify-docs.test.ts new file mode 100644 index 0000000..58d1b62 --- /dev/null +++ b/tests/verify-docs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { markdownHeadingAnchor } from "../scripts/markdown-heading-anchor.mjs"; + +describe("documentation heading anchors", () => { + it("normalises links and inline HTML", () => { + expect( + markdownHeadingAnchor( + "[Read API](#read-api) get()", + ), + ).toBe("read-api-get"); + }); + + it("handles overlapping and incomplete HTML-like text", () => { + expect( + markdownHeadingAnchor("Before <inside> after"), + ).toBe("before-inside-after"); + expect( + markdownHeadingAnchor("Before