diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index bd47dbb5c..3ed0f6115 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -487,6 +487,8 @@ pub trait Repo: Profile { "README", "README.md", "README.markdown", + "README.adoc", + "README.asciidoc", "README.txt", "README.rst", "README.org", diff --git a/package-lock.json b/package-lock.json index b309c69c7..a72072bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { + "@asciidoctor/core": "^4.0.11", "@floating-ui/dom": "^1.8.0", "@pierre/diffs": "1.3.0-beta.9", "@pierre/trees": "1.0.0-beta.5", @@ -86,6 +87,15 @@ "npm": ">=11.16" } }, + "node_modules/@asciidoctor/core": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-4.0.11.tgz", + "integrity": "sha512-5uWPTLTiPJVQp9q4MllSROM2avdECzoT7oeVw/onT9H2PIdCCtQwKrSTvztwxMbSu/8cqHqZox/2qTBrQN454g==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", diff --git a/package.json b/package.json index a0fd04d8d..768d37868 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "license": "GPL-3.0-only", "dependencies": { + "@asciidoctor/core": "^4.0.11", "@floating-ui/dom": "^1.8.0", "@pierre/diffs": "1.3.0-beta.9", "@pierre/trees": "1.0.0-beta.5", diff --git a/src/components/Asciidoc.svelte b/src/components/Asciidoc.svelte new file mode 100644 index 000000000..065aedb39 --- /dev/null +++ b/src/components/Asciidoc.svelte @@ -0,0 +1,369 @@ + + + + +{#if error} +
+ Not able to render AsciiDoc: {error} +
+{:else} +
+ {@html html} +
+{/if} diff --git a/src/lib/asciidoc.ts b/src/lib/asciidoc.ts new file mode 100644 index 000000000..3abd0f4e8 --- /dev/null +++ b/src/lib/asciidoc.ts @@ -0,0 +1,227 @@ +import type { Blob } from "@bindings/source/Blob"; + +import dompurify from "dompurify"; + +import { invoke } from "@app/lib/invoke"; + +// `markdown.ts` pins the shared DOMPurify singleton to the markdown allowlist +// via `setConfig`, which makes per-call configs no-ops. AsciiDoc output needs a +// wider allowlist, so it gets its own instance. +const purify = dompurify(window); + +purify.setConfig({ + /* eslint-disable @typescript-eslint/naming-convention */ + ALLOWED_ATTR: [ + "alt", + "class", + "colspan", + "data-lang", + "href", + "id", + "rel", + "rowspan", + "scope", + "src", + "start", + "target", + "title", + "type", + "width", + ], + ALLOWED_TAGS: [ + "a", + "b", + "blockquote", + "br", + "caption", + "cite", + "code", + "col", + "colgroup", + "dd", + "del", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "span", + "strong", + "sub", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", + ], + /* eslint-enable @typescript-eslint/naming-convention */ +}); + +export interface AsciidocSource { + rid: string; + sha: string; + path: string; +} + +// Asciidoctor resolves include targets against `base_dir` and reads them with +// the Fetch API. Pointing `base_dir` at a synthetic origin and registering a +// cache for it turns every include into a repository blob lookup instead of a +// network request, while leaving the tag, line-range and nesting handling to +// Asciidoctor itself. +const includeOrigin = "https://include.radicle.invalid"; + +// The repository a conversion may read from, keyed by a token that only that +// conversion's `base_dir` carries. Keeping the repository out of the URL means +// a `..` target cannot reach a different repository or commit by rewriting the +// path: escaping past the token just drops the entry, and the read is refused. +const includeScopes = new Map< + string, + { source: AsciidocSource; reads: number } +>(); + +// Asciidoctor neither deduplicates includes nor bounds their total, so a file +// that includes itself twice would issue reads exponential in the include +// depth. Every read past this budget is refused. +export const maxIncludeReads = 256; + +/** + * Resolve a link written relative to the document at `from` into a repository + * path. Returns undefined when the link points to another origin or to the + * repository root. Targets that climb above the root stop at it. + */ +export function resolveRepoPath( + href: string, + from: string, +): string | undefined { + const dir = from.split("/").slice(0, -1).join("/"); + + let url: URL; + try { + // The trailing slash makes the base a directory, so `a.adoc` resolves as + // a sibling of `from` rather than replacing its last segment. + url = new URL(href, `${includeOrigin}/${dir ? `${dir}/` : ""}`); + } catch { + return undefined; + } + + if (url.origin !== includeOrigin) { + return undefined; + } + + return decodeURIComponent(url.pathname).replace(/^\//, "") || undefined; +} + +async function readInclude(uri: string): Promise { + let url: URL; + try { + url = new URL(uri); + } catch { + return new Response(undefined, { status: 400 }); + } + + if (url.origin !== includeOrigin) { + return new Response(undefined, { status: 403 }); + } + + const [, token, ...rest] = url.pathname.split("/").map(decodeURIComponent); + const scope = includeScopes.get(token); + const path = rest.join("/"); + if (!scope || !path) { + return new Response(undefined, { status: 403 }); + } + if (++scope.reads > maxIncludeReads) { + return new Response(undefined, { status: 429 }); + } + + try { + const blob = await invoke("repo_blob", { + rid: scope.source.rid, + sha: scope.source.sha, + path, + }); + if (blob.binary) { + return new Response(undefined, { status: 415 }); + } + + return new Response(blob.content); + } catch { + return new Response(undefined, { status: 404 }); + } +} + +let asciidoctor: Promise | undefined; + +function loadAsciidoctor() { + if (!asciidoctor) { + asciidoctor = import("@asciidoctor/core").then(module => { + const cache = new module.HttpCache(); + cache.read = readInclude; + module.HttpCacheManager.setCache(cache); + // Asciidoctor reports problems such as unresolved includes inline in + // the rendered document, so logging them to the console as well only + // adds noise. + module.LoggerManager.setLogger(module.NullLogger.create()); + + return module; + }); + } + + return asciidoctor; +} + +export async function renderAsciidoc( + content: string, + source: AsciidocSource, +): Promise { + const { convert } = await loadAsciidoctor(); + + const token = crypto.randomUUID(); + const dir = source.path.split("/").slice(0, -1).join("/"); + includeScopes.set(token, { source, reads: 0 }); + + let html; + try { + html = await convert(content, { + // `server` keeps docinfo and absolute paths disabled while allowing + // includes; every read they perform goes through `readInclude`. + safe: "server", + standalone: false, + /* eslint-disable @typescript-eslint/naming-convention */ + to_file: false, + base_dir: [includeOrigin, token, dir].filter(Boolean).join("/"), + attributes: { + showtitle: true, + "allow-uri-read": "", + "cache-uri": "", + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } finally { + includeScopes.delete(token); + } + + if (typeof html !== "string") { + throw new Error("Asciidoctor did not return a converted document"); + } + + return purify.sanitize(html); +} diff --git a/src/views/repo/RepoHome.svelte b/src/views/repo/RepoHome.svelte index a866b33e3..03b913c75 100644 --- a/src/views/repo/RepoHome.svelte +++ b/src/views/repo/RepoHome.svelte @@ -13,6 +13,7 @@ import type { SidebarData } from "@app/lib/router/definitions"; import { highlight } from "@app/lib/syntax"; + import Asciidoc from "@app/components/Asciidoc.svelte"; import FileBlock from "@app/components/FileBlock.svelte"; import Icon from "@app/components/Icon.svelte"; import Id from "@app/components/Id.svelte"; @@ -75,6 +76,10 @@ return /\.(md|mkd|markdown)$/i.test(path); } + function isAsciidocPath(path: string): boolean { + return /\.(adoc|asciidoc|asc)$/i.test(path); + } + async function fetchTree(path: string) { return await invoke("repo_tree", { rid: repo.rid, path, sha: oid }); } @@ -123,7 +128,10 @@ } }); - let preview = $derived(isMarkdownPath(currentPath)); + const previewable = $derived( + isMarkdownPath(currentPath) || isAsciidocPath(currentPath), + ); + let preview = $derived(previewable); // Parent reuses this component across routes; a sibling $effect resets blob // when the readme prop changes, and fetchBlob mutates it during navigation. @@ -252,7 +260,7 @@ {/snippet} {#snippet rightHeader()} - {#if isMarkdownPath(currentPath)} + {#if previewable} {/if} {/snippet} @@ -284,7 +292,16 @@ {/if} {:else if preview}
- + {#if isAsciidocPath(currentPath)} + + {:else} + + {/if}
{:else if blob.content.trim() === ""}
{ + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + + await expect( + readme.getByRole("heading", { name: "AsciiDoc Fixture", level: 1 }), + ).toBeVisible(); + await expect(readme.locator("code.language-rust")).toBeVisible(); + await expect(readme.locator("table.tableblock").first()).toBeVisible(); + await expect(readme.getByText("An admonition.")).toBeVisible(); + await expect(readme.getByText("A footnote.")).toBeVisible(); +}); + +test("open a relative link in the source view", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.getByRole("link", { name: "the notes" })).toBeVisible(); + + const url = page.url(); + await readme.getByRole("link", { name: "the notes" }).click(); + + // The link opens the file instead of navigating the app away. + await expect(page.getByText("Notes from a sibling file.")).toBeVisible(); + expect(page.url()).toBe(url); +}); + +test("keep wide AsciiDoc table cells inside the viewport", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.locator("table.tableblock").nth(1)).toBeVisible(); + + const overflow = await readme.evaluate( + element => element.scrollWidth - element.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); +}); + +test("resolve includes in an AsciiDoc readme", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + + await expect(readme.getByText("Alpha section text.")).toBeVisible(); + // Only the requested tag is included. + await expect(readme.getByText("Beta section text.")).toHaveCount(0); +}); + +test("refuse remote includes without a request", async ({ page }) => { + const remoteRequests: string[] = []; + page.on("request", request => { + if (new URL(request.url()).hostname === "example.invalid") { + remoteRequests.push(request.url()); + } + }); + + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect( + readme.getByText(/Unresolved directive.*example\.invalid/), + ).toBeVisible(); + expect(remoteRequests).toEqual([]); +}); + +test("bound the reads of recursive includes", async ({ page }) => { + let blobReads = 0; + page.on("request", request => { + if (new URL(request.url()).pathname === "/repo_blob") { + blobReads += 1; + } + }); + + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + await page.getByText("bomb.adoc", { exact: true }).click(); + + await expect( + page + .locator(".asciidoc") + .getByRole("heading", { name: "Include Bomb", level: 1 }), + ).toBeVisible({ timeout: 30_000 }); + // The include budget is 256 reads, plus the reads for the file itself and + // the readme's own include. + expect(blobReads).toBeLessThanOrEqual(260); +}); + +test("sanitize untrusted markup in an AsciiDoc readme", async ({ page }) => { + await page.goto("/inbox"); + await page.getByRole("link", { name: "asciidoc" }).click(); + + const readme = page.locator(".asciidoc"); + await expect(readme.getByText("Untrusted markup")).toBeVisible(); + + await expect(readme.locator("script")).toHaveCount(0); + await expect(readme.locator("iframe")).toHaveCount(0); + await expect(readme.locator("[onerror]")).toHaveCount(0); + await expect(readme.locator("[onclick]")).toHaveCount(0); + await expect(readme.locator('a[href^="javascript:"]')).toHaveCount(0); + + const marker = await page.evaluate( + () => (window as unknown as { xssMarker?: string }).xssMarker, + ); + expect(marker).toBeUndefined(); +}); diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts index 4bbcd978e..ddd02b08c 100644 --- a/tests/support/fixtures.ts +++ b/tests/support/fixtures.ts @@ -594,6 +594,104 @@ export async function createMarkdownFixture(peer: RadiclePeer) { ); } +export async function createAsciidocFixture(peer: RadiclePeer) { + const { repoFolder } = await createRepo(peer, { name: "asciidoc" }); + + await Fs.writeFile( + Path.join(repoFolder, "README.adoc"), + [ + "= AsciiDoc Fixture", + "", + "A paragraph with a `code span` and a https://radicle.xyz[link].", + "", + "== Rendering", + "", + "[source,rust]", + "----", + "fn main() {}", + "----", + "", + "|===", + "|Column A |Column B", + "", + "|1 |2", + "|===", + "", + // An AsciiDoc cell holding an unbreakable token: the column must stay + // inside the viewport instead of widening the page. + '[cols="2a,1",options="header"]', + "|===", + "|URI |Refers to", + "", + "|", + "....", + `rad:z${"L".repeat(120)}`, + "....", + "|A repository", + "|===", + "", + "NOTE: An admonition.", + "", + "A statement.footnote:[A footnote.]", + "", + "== Includes", + "", + "include::_include.adoc[tag=alpha]", + "", + // A remote include must be refused without any network request. + "include::https://example.invalid/remote.adoc[]", + "", + "A link to link:notes.txt[the notes] in the same repository.", + "", + "== Untrusted markup", + "", + // A passthrough block is the one place AsciiDoc emits author-supplied + // HTML verbatim, so it is what the sanitizer has to catch. + "++++", + '', + '', + '', + "link", + "
click
", + "++++", + "", + ].join("\n"), + ); + + await Fs.writeFile( + Path.join(repoFolder, "notes.txt"), + "Notes from a sibling file.\n", + ); + + await Fs.writeFile( + Path.join(repoFolder, "_include.adoc"), + [ + "// tag::alpha[]", + "Alpha section text.", + "// end::alpha[]", + "// tag::beta[]", + "Beta section text.", + "// end::beta[]", + "", + ].join("\n"), + ); + + // Each level includes the next one twice, so without a read budget the + // reads grow exponentially with Asciidoctor's include depth. + await Fs.writeFile( + Path.join(repoFolder, "bomb.adoc"), + ["= Include Bomb", "", "include::_bomb.adoc[]", ""].join("\n"), + ); + await Fs.writeFile( + Path.join(repoFolder, "_bomb.adoc"), + ["include::_bomb.adoc[]", "include::_bomb.adoc[]", ""].join("\n"), + ); + + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add AsciiDoc readme"], { cwd: repoFolder }); + await peer.git(["push", "rad"], { cwd: repoFolder }); +} + export const aliceMainHead = "7babd25a74eb3752ec24672b5edf0e7ecb4daf24"; export const aliceMainCommitMessage = "Verify that crate::DoubleColon::should_work()"; diff --git a/tests/support/globalSetup.ts b/tests/support/globalSetup.ts index 9c9c6dcd2..37be77721 100644 --- a/tests/support/globalSetup.ts +++ b/tests/support/globalSetup.ts @@ -2,6 +2,7 @@ import * as Fs from "node:fs"; import * as Path from "node:path"; import { + createAsciidocFixture, createCobsFixture, createMarkdownFixture, defaultConfig, @@ -85,6 +86,8 @@ export default async function globalSetup(): Promise<() => void> { try { console.log("Creating markdown fixture"); await createMarkdownFixture(palm); + console.log("Creating asciidoc fixture"); + await createAsciidocFixture(palm); console.log("Creating cobs fixture"); await createCobsFixture(peerManager, palm); console.log("All fixtures created"); diff --git a/tests/unit/asciidoc.test.ts b/tests/unit/asciidoc.test.ts new file mode 100644 index 000000000..5bb174507 --- /dev/null +++ b/tests/unit/asciidoc.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; + +import { resolveRepoPath } from "@app/lib/asciidoc"; + +describe("resolveRepoPath", () => { + test.each([ + ["notes.txt", "README.adoc", "notes.txt"], + ["./notes.txt", "README.adoc", "notes.txt"], + ["guide.adoc", "docs/index.adoc", "docs/guide.adoc"], + ["../README.adoc", "docs/index.adoc", "README.adoc"], + ["../../README.adoc", "docs/index.adoc", "README.adoc"], + ["../notes.txt", "README.adoc", "notes.txt"], + ["/src/main.rs", "docs/index.adoc", "src/main.rs"], + ["my%20notes.txt", "README.adoc", "my notes.txt"], + ["notes.txt?plain=1", "README.adoc", "notes.txt"], + ["notes.txt#usage", "README.adoc", "notes.txt"], + ["docs/", "README.adoc", "docs/"], + ])("resolves %j from %j to %j", (href, from, expected) => { + expect(resolveRepoPath(href, from)).toBe(expected); + }); + + test.each([ + ["https://radicle.xyz", "README.adoc"], + ["//radicle.xyz/notes.txt", "README.adoc"], + ["mailto:alice@example.com", "README.adoc"], + ["javascript:alert(1)", "README.adoc"], + ["/", "README.adoc"], + ["..", "README.adoc"], + ])("leaves %j from %j outside the repository", (href, from) => { + expect(resolveRepoPath(href, from)).toBeUndefined(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 7450e6caf..91f0c1949 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,31 @@ export default defineConfig({ reporters: "verbose", }, plugins: [ + // Asciidoctor derives Node-only LIB_DIR/ROOT_DIR/DATA_DIR from + // `import.meta.url`. Those paths are unused in the browser, but Vite still + // emits the whole module as an asset, duplicating it unminified in the + // bundle. Neutralize the URL so nothing is emitted. + { + name: "asciidoctor-drop-node-paths", + enforce: "pre" as const, + transform(code: string, id: string) { + if (!id.includes("@asciidoctor/core")) return; + + const pattern = /new URL\((.*?), import\.meta\.url\)/g; + const patched = code.replaceAll(pattern, "new URL($1, `file:///`)"); + const expected = code.includes("DATA_DIR") ? 3 : 0; + const found = code.match(pattern)?.length ?? 0; + + if (found !== expected) { + throw new Error( + `Expected ${expected} import.meta.url uses in ${id}, found ${found}. ` + + `Asciidoctor may now rely on them at runtime; re-check before patching.`, + ); + } + + return found > 0 ? { code: patched } : undefined; + }, + }, svelte({ // Reference: https://github.com/sveltejs/vite-plugin-svelte/issues/270#issuecomment-1033190138 dynamicCompileOptions({ filename }) {