/**
* Markdown rendering across the SSR pages.
*
* Pins the two defects that took the docs + file viewers out:
*
* 1. INLINE-SCRIPT INJECTION. Every markdown surface shipped its source
* as `var src = ${JSON.stringify(content)}` inside an inline
* <script>. JSON.stringify does not escape `</script`, so any file
* containing that sequence closed the element early: SyntaxError,
* blank viewer (37 of 4188 markdown files in the served repos), and
* an XSS vector — everything after the terminator was parsed as HTML
* in the viewer's origin. Source now travels as base64 in a data
* attribute, whose alphabet cannot close a tag or an attribute.
*
* 2. TEMPLATE-LITERAL ESCAPE ROT. The Docs tab's inline script held a
* regex written `/…|\/\/|#/`. Inside a template literal `\/` reduces
* to `/`, so the emitted regex was invalid, the whole block was a
* SyntaxError, and the docs viewer stayed empty for EVERY repo. The
* renderer now lives in a real .js file — there is no template layer
* left to escape through.
*
* The client-side renderer's URL resolution is exercised directly by
* evaluating static/pgv3-markdown.js's `resolve` semantics, so the
* link-rewriting rules are pinned without needing a browser.
*/
import test from 'brittle'
import { readFileSync } from '#node:fs'
import { fileURLToPath } from '#node:url'
import { dirname, join } from '#node:path'
import b4a from 'b4a'
import { markdownBody, dirOf } from '../lib/web/helpers.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const STATIC_DIR = join(__dirname, '..', 'lib', 'web', 'static')
// The exact sequence that used to break every markdown viewer.
const HOSTILE = '# Title\n\nExample:\n\n```html\n<script>alert(1)</script>\n```\n'
function b64Of (html) {
const m = html.match(/data-md-src-b64="([^"]*)"/)
return m ? m[1] : null
}
function decode (b64) {
return b4a.toString(b4a.from(b64, 'base64'), 'utf8')
}
// ── 1. injection ──────────────────────────────────────────────
test('markdownBody never emits a script-closing sequence', (t) => {
const html = markdownBody(HOSTILE)
t.absent(html.includes('</script'), 'no script terminator survives into the HTML')
t.absent(html.includes('<script'), 'the source is not wrapped in a script element')
t.absent(html.includes('alert(1)'), 'raw source does not appear as HTML text')
})
test('markdownBody round-trips the source byte-for-byte', (t) => {
for (const src of [HOSTILE, '# hi', 'a "quote" & <angle>', 'ünïcødé — em dash', '']) {
const b64 = b64Of(markdownBody(src))
t.is(decode(b64), src, 'round-trip: ' + JSON.stringify(src.slice(0, 24)))
}
})
test('base64 payload cannot break out of the attribute', (t) => {
// Base64's alphabet is [A-Za-z0-9+/=] — no quote, no angle bracket. If
// that ever stops holding, the attribute is injectable again.
const b64 = b64Of(markdownBody(HOSTILE + '"><img onerror=alert(1) src=x>'))
t.ok(/^[A-Za-z0-9+/=]*$/.test(b64), 'payload is pure base64, got: ' + b64.slice(0, 40))
})
test('no SSR page inlines markdown source into a script element', (t) => {
// The regression guard for the whole defect class: a page that goes
// back to `var src = ${JSON.stringify(...)}` reintroduces it.
const pages = ['repo-browser.js', 'repo-docs.js', 'docs.js', 'orgs.js']
for (const name of pages) {
const src = readFileSync(join(__dirname, '..', 'lib', 'web', 'pages', name), 'utf8')
t.absent(/var\s+src\s*=\s*\$\{JSON\.stringify/.test(src),
name + ' does not inline a markdown source as JSON in a script')
t.absent(/var\s+DOCS\s*=\s*\$\{JSON\.stringify/.test(src),
name + ' does not inline a markdown map as JSON in a script')
}
})
// ── 2. escape rot ─────────────────────────────────────────────
test('the client renderers are real .js files, not template literals', (t) => {
for (const name of ['pgv3-markdown.js', 'pgv3-docs-switch.js']) {
const src = readFileSync(join(STATIC_DIR, name), 'utf8')
t.ok(src.length > 0, name + ' exists')
// A regex escaped for a template literal (\\/) is the signature of
// code that was moved here from a page template without unescaping.
t.absent(src.includes('\\\\/'), name + ' has no template-escaped regex')
}
})
test('pgv3-markdown.js parses as JavaScript', (t) => {
// The Docs tab's bug WAS a parse error in emitted script text. Compile
// both client scripts so a syntax error can never ship silently again.
for (const name of ['pgv3-markdown.js', 'pgv3-docs-switch.js']) {
const src = readFileSync(join(STATIC_DIR, name), 'utf8')
t.execution(() => new Function(src), name + ' compiles') // eslint-disable-line no-new-func
}
})
test('every regex literal in the client renderer is valid', (t) => {
const src = readFileSync(join(STATIC_DIR, 'pgv3-markdown.js'), 'utf8')
// The specific shape that broke: an alternation containing an escaped
// slash. If the backslashes were lost it would not compile.
t.ok(src.includes('\\/\\/'), 'the scheme-relative alternation is single-escaped')
t.execution(() => new RegExp('^([a-z][a-z0-9+.-]*:|\\/\\/|#)', 'i'),
'the emitted absolute-URL regex is constructible')
})
// ── attribute wiring ─────────────────────────────────────────
test('markdownBody emits only the bases it was given', (t) => {
const bare = markdownBody('x')
t.absent(bare.includes('data-md-link-base'), 'no link base when none passed')
t.absent(bare.includes('data-md-sibling-base'), 'no sibling base when none passed')
const full = markdownBody('x', {
id: 'md-preview',
dir: 'docs',
linkBase: '/git/repos/r/blob/',
imgBase: '/git/repos/r/raw/',
siblingBase: '/git/repos/',
stripExt: 'md,markdown'
})
t.ok(full.includes('id="md-preview"'))
t.ok(full.includes('data-md-dir="docs"'))
t.ok(full.includes('data-md-link-base="/git/repos/r/blob/"'))
t.ok(full.includes('data-md-img-base="/git/repos/r/raw/"'))
t.ok(full.includes('data-md-sibling-base="/git/repos/"'))
t.ok(full.includes('data-md-strip-ext="md,markdown"'))
t.ok(full.includes('class="markdown-body"'))
})
test('markdownBody escapes attribute values', (t) => {
const html = markdownBody('x', { id: 'a"><b', linkBase: '/x"y' })
t.absent(html.includes('"><b'), 'id is escaped')
t.absent(html.includes('/x"y'), 'base is escaped')
})
test('dirOf gives the directory a relative link resolves against', (t) => {
t.is(dirOf('README.md'), '', 'root file has no directory')
t.is(dirOf('docs/overview.md'), 'docs')
t.is(dirOf('docs/plans/a/b.md'), 'docs/plans/a')
})
// ── link resolution semantics ────────────────────────────────
//
// Load the real `resolve` out of the static file so these assertions
// track the shipped implementation rather than a copy.
function loadResolve () {
const src = readFileSync(join(STATIC_DIR, 'pgv3-markdown.js'), 'utf8')
const start = src.indexOf('function isAbsolute')
const end = src.indexOf(' /**\n * marked v5+')
const body = src.slice(start, end)
// eslint-disable-next-line no-new-func
return new Function(body + '\nreturn resolve')()
}
const BLOB = '/git/repos/r/blob/'
const RAW = '/git/repos/r/raw/'
const REPOS = '/git/repos/'
test('relative links resolve against the source file directory', (t) => {
const resolve = loadResolve()
// A link in docs/a.md to b.md must land on docs/b.md, not on the root.
t.is(resolve('b.md', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md')
t.is(resolve('./b.md', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md')
t.is(resolve('sub/b.md', 'docs', BLOB, REPOS, false), BLOB + 'docs/sub/b.md')
// '..' inside the repo stays inside the repo.
t.is(resolve('../b.md', 'docs', BLOB, REPOS, false), BLOB + 'b.md')
t.is(resolve('b.md', '', BLOB, REPOS, false), BLOB + 'b.md')
})
test('absolute and scheme-relative hrefs pass through untouched', (t) => {
const resolve = loadResolve()
for (const url of ['/abs', 'https://x/y', '//cdn/x', '#frag', 'mailto:a@b']) {
t.is(resolve(url, 'docs', BLOB, REPOS, false), url, url)
}
})
test('non-string hrefs never throw', (t) => {
const resolve = loadResolve()
for (const v of [null, undefined, 0, {}, []]) {
t.execution(() => resolve(v, '', BLOB, REPOS, false))
}
})
test('climbing above the repo root means a SIBLING repo', (t) => {
const resolve = loadResolve()
t.is(resolve('../other', '', BLOB, REPOS, false), REPOS + 'other')
t.is(resolve('../other/docs/x.md', '', BLOB, REPOS, false),
REPOS + 'other/blob/docs/x.md')
// An image in a sibling repo has to point at the byte route.
t.is(resolve('../other/logo.png', '', BLOB, REPOS, true),
REPOS + 'other/raw/logo.png')
// With no sibling base configured the link is left alone rather than
// silently rewritten to a wrong in-repo path.
t.is(resolve('../other', '', BLOB, '', false), '../other')
})
test('fragments and queries survive rewriting', (t) => {
const resolve = loadResolve()
t.is(resolve('b.md#heading', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md#heading')
t.is(resolve('b.md?ref=dev', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md?ref=dev')
})
test('images resolve against the raw base, not the viewer', (t) => {
const resolve = loadResolve()
t.is(resolve('img/x.png', 'docs', RAW, REPOS, true), RAW + 'docs/img/x.png')
})
test('path segments are URL-encoded', (t) => {
const resolve = loadResolve()
t.is(resolve('my file.md', '', BLOB, REPOS, false), BLOB + 'my%20file.md')
// The separators must NOT be encoded — only the segments.
t.is(resolve('a/b c.md', '', BLOB, REPOS, false), BLOB + 'a/b%20c.md')
})
test('stripExt drops the extension for slug-addressed links only', (t) => {
const resolve = loadResolve()
const DOCS = '/git/docs/'
// The site docs are addressed by slug: ./origin-setup.md → /docs/origin-setup
t.is(resolve('./origin-setup.md', '', DOCS, '', false, ['md', 'markdown']),
DOCS + 'origin-setup')
t.is(resolve('./x.markdown', '', DOCS, '', false, ['md', 'markdown']),
DOCS + 'x')
// Not a listed extension → untouched.
t.is(resolve('./x.txt', '', DOCS, '', false, ['md']), DOCS + 'x.txt')
// Images keep their extension — they must point at real bytes.
t.is(resolve('./x.md', '', DOCS, '', true, ['md']), DOCS + 'x.md')
// Off by default.
t.is(resolve('./x.md', '', DOCS, '', false), DOCS + 'x.md')
})