Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-format": "^5.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-lint": "^10.0.1",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 13 additions & 38 deletions src/components/Tables/QueueDequeueReasons.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { readEnumChoices } from '../../util/enumChoices';
import configSchema from '../../util/sanitizedConfigSchema';

import { renderMarkdown } from './utils';

// The `queue-dequeue-reason` condition attribute accepts one of the merge queue
// dequeue codes. The engine is the single source of truth: the codes come from
// the attribute's enum, and a one-line description for each is published
// alongside it under `x-enum-descriptions` (keyed by the raw code). Driving the
// code list from the enum keeps the table current even before a schema sync
// delivers the descriptions.
// dequeue codes. The engine is the single source of truth: the codes and their
// one-line descriptions are published on the attribute itself, so this table
// stays current without being hand-maintained.
//
// `x-enum-descriptions` is not in the synced schema until the engine ships it,
// so it is absent from the JSON-derived types and read via a cast (it becomes a
// normal typed key once the sync bot lands it).
// `readEnumChoices` handles every shape the engine can publish here — a flat
// `enum`, the `anyOf` of `const`/`enum` branches a composed `Literal` produces
// today, or a `$ref` to a shared component — and reads the documentation from
// either `x-mergify-enum` or the older `x-enum-descriptions` map.
//
// The lookup is optional-chained through a loose cast so a future schema reshape
// (renamed attribute or restructured `$defs`) degrades to an empty table rather
Expand All @@ -22,39 +22,14 @@ const reasonProp: unknown = (
}
).$defs?.PullRequestAttributes?.properties?.['queue-dequeue-reason'];

type EnumNode = { anyOf?: EnumNode[]; enum?: string[]; const?: string };

function branchValues(node: EnumNode): string[] {
if (node.const !== undefined) {
return [node.const];
}
return node.enum ?? [];
}

// Collect the raw enum values the attribute accepts, tolerating the shapes the
// engine might emit: an `anyOf` of `{const}` / `{enum}` branches (today), or a
// flat `{enum}` / `{const}`. Returns [] for anything else (or a missing node),
// so a future schema-shape change degrades to an empty table rather than
// crashing the Astro build.
function enumValues(prop: unknown): string[] {
if (!prop || typeof prop !== 'object') {
return [];
}
const node = prop as EnumNode;
return node.anyOf ? node.anyOf.flatMap(branchValues) : branchValues(node);
}

// The engine stores codes as `UPPER_SNAKE`; conditions are written in kebab-case
// (the parser normalizes `upper().replace('-', '_')`), so that is what to show.
function toKebab(code: string): string {
return code.toLowerCase().replace(/_/g, '-');
}

export default function QueueDequeueReasons() {
const descriptions =
(reasonProp as { 'x-enum-descriptions'?: Record<string, string> } | undefined)?.[
'x-enum-descriptions'
] ?? {};
const choices = readEnumChoices(configSchema, reasonProp);

return (
<div className="table-wrap">
Expand All @@ -66,12 +41,12 @@ export default function QueueDequeueReasons() {
</tr>
</thead>
<tbody>
{enumValues(reasonProp).map((code) => (
<tr key={code}>
{choices.map((choice) => (
<tr key={choice.value}>
<td>
<code>{toKebab(code)}</code>
<code>{toKebab(choice.value)}</code>
</td>
<td dangerouslySetInnerHTML={{ __html: renderMarkdown(descriptions[code] ?? '') }} />
<td dangerouslySetInnerHTML={{ __html: renderMarkdown(choice.description) }} />
</tr>
))}
</tbody>
Expand Down
59 changes: 59 additions & 0 deletions src/components/Tables/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { renderMarkdown } from './utils';

// `renderMarkdown` output is injected with `dangerouslySetInnerHTML` by every
// schema-driven table, so what it lets through is a security property.
//
// Two different layers provide that, and it is worth keeping them apart: raw
// HTML never survives because `remark-rehype` runs without
// `allowDangerousHtml`, which is true with or without the sanitizer. Only the
// URL-protocol filtering below actually exercises `rehype-sanitize` — those
// are the assertions that fail if it is removed.
describe('renderMarkdown', () => {
it('renders the markdown the schema descriptions actually use', () => {
const html = renderMarkdown('A [real link](https://example.com) and `code`.');
expect(html).toContain('<a href="https://example.com">real link</a>');
expect(html).toContain('<code>code</code>');
});

it('keeps relative links, anchors and mailto', () => {
expect(renderMarkdown('[a](/merge-queue/batches)')).toContain('href="/merge-queue/batches"');
expect(renderMarkdown('[a](#batch-status)')).toContain('href="#batch-status"');
expect(renderMarkdown('[a](mailto:x@example.com)')).toContain('href="mailto:x@example.com"');
});

// These are the sanitizer's own guarantee: `remark-rehype` emits an <a> for
// any link target, whatever its protocol, so without `rehype-sanitize` each
// of these renders as a live link.
describe('URL protocol filtering (rehype-sanitize)', () => {
it('strips a javascript: link rather than emitting a live one', () => {
const html = renderMarkdown('[click](javascript:alert(1))');
expect(html).toContain('click');
expect(html).not.toContain('javascript:');
});

it('strips a case-obfuscated javascript: link', () => {
expect(renderMarkdown('[click](JaVaScRiPt:alert(1))')).not.toContain('alert(1)');
});

it('strips a data: URL on an image', () => {
expect(renderMarkdown('![x](data:text/html;base64,PHNjcmlwdD4=)')).not.toContain(
'data:text/html'
);
});
});

// Kept as a regression pin on the pipeline as a whole, not on the sanitizer:
// these pass because raw HTML is discarded before it becomes a node. If a
// caller ever enables `allowDangerousHtml`, `rehype-raw` parses it and the
// sanitizer becomes what keeps these green.
describe('raw HTML never reaches the output', () => {
it('drops an event handler', () => {
expect(renderMarkdown('<img src=x onerror="alert(1)">')).not.toContain('onerror');
});

it('drops a script tag', () => {
expect(renderMarkdown('<script>alert(1)</script>')).not.toContain('<script');
});
});
});
20 changes: 19 additions & 1 deletion src/components/Tables/utils.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import rehypeFormat from 'rehype-format';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';

/**
* Render a short markdown string (a schema description, a template variable
* blurb) to HTML for injection via `dangerouslySetInnerHTML`.
*
* Sanitized on the way out. The input is always first-party — descriptions
* synced from the engine's schemas — so this is defence in depth rather than a
* response to untrusted input, but the output goes straight into the DOM and
* several tables share this helper, so the guarantee belongs here and not in
* each caller. Without it, `[x](javascript:...)` in a description would render
* as a live `javascript:` link: `remark-rehype` does not filter URL protocols,
* and being first-party is a property of today's callers, not of this function.
*
* `rehype-raw` is kept ahead of the sanitizer so that if a caller ever enables
* `allowDangerousHtml`, the embedded HTML is parsed and then sanitized rather
* than passed through as an opaque raw node.
*/
export function renderMarkdown(markdown: string) {
const file = unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeRaw)
.use(rehypeSanitize)
.use(rehypeFormat)
.use(rehypeStringify)
.use(rehypeRaw)
.processSync(markdown);

return file.toString();
Expand Down
19 changes: 15 additions & 4 deletions src/util/dataType.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ import { collectDataTypeTitles, getDataTypeHref, isDataType } from './dataType';
import { dataTypesHeadingAnchors, missingDataTypeAnchors } from './dataTypeAnchors';

describe('isDataType', () => {
it('recognizes flagged nodes only', () => {
it('recognizes the namespaced marker', () => {
expect(isDataType({ 'x-mergify-has-data-type': true })).toBe(true);
expect(isDataType({ 'x-mergify-has-data-type': 'queue-dequeue-reason' })).toBe(false);
});

// The engine is renaming the key, and syncs land as direct pushes to main,
// so a schema carrying either spelling has to keep working until no synced
// schema publishes the old one.
it('still recognizes the legacy marker', () => {
expect(isDataType({ 'x-has-data-type': true })).toBe(true);
expect(isDataType({ 'x-has-data-type': 'queue-dequeue-reason' })).toBe(false);
});

it('rejects everything else', () => {
expect(isDataType({ type: 'string' })).toBe(false);
expect(isDataType(null)).toBe(false);
expect(isDataType('string')).toBe(false);
Expand All @@ -23,17 +34,17 @@ describe('getDataTypeHref', () => {
});

describe('collectDataTypeTitles', () => {
it('finds flagged nodes wherever they appear in a schema', () => {
it('finds flagged nodes wherever they appear, in either spelling', () => {
const schema = {
$defs: {
ReportMode: { 'x-has-data-type': true, title: 'Report Mode' },
ReportMode: { 'x-mergify-has-data-type': true, title: 'Report Mode' },
},
properties: {
reason: {
anyOf: [{ 'x-has-data-type': true, title: 'Queue dequeue reason' }, { type: 'null' }],
},
report_mode: { type: 'array', items: { $ref: '#/$defs/ReportMode' } },
untitled: { 'x-has-data-type': true },
untitled: { 'x-mergify-has-data-type': true },
},
};
expect(collectDataTypeTitles(schema).sort()).toEqual([
Expand Down
36 changes: 21 additions & 15 deletions src/util/dataType.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
import { slugify } from './slugify';

// The engine flags a schema node that corresponds to a documented Mergify
// data type with `x-has-data-type: true`. Everything else derives from the node's
// standard `title`: the link label is the title, and the link target is the
// slugified title, which by convention equals the section heading anchor on
// /configuration/data-types. The convention is enforced where drift can
// data type with `x-mergify-has-data-type: true`. Everything else derives from
// the node's standard `title`: the link label is the title, and the link target
// is the slugified title, which by convention equals the section heading anchor
// on /configuration/data-types. The convention is enforced where drift can
// actually arrive: the Astro build fails when a marked title has no matching
// heading anchor (schema syncs land as direct pushes to main, so the deploy
// build is the gate), and dataType.test.ts gives the same signal earlier, in
// PR CI.
const DATA_TYPE_KEY = 'x-has-data-type';
//
// `x-has-data-type` is the legacy spelling, still accepted because a synced
// schema can carry either during the engine-side rename: syncs land as direct
// pushes to main, so the docs repo cannot assume both sides move together.
// Reading both keeps the marker working in whichever direction the two repos
// merge, and this fallback can go once no synced schema publishes the old key.
const DATA_TYPE_KEYS = ['x-mergify-has-data-type', 'x-has-data-type'] as const;

const DATA_TYPES_PAGE = '/configuration/data-types';

export function isDataType(definition: unknown): boolean {
return (
!!definition &&
typeof definition === 'object' &&
(definition as Record<string, unknown>)[DATA_TYPE_KEY] === true
);
if (!definition || typeof definition !== 'object') {
return false;
}
const node = definition as Record<string, unknown>;
return DATA_TYPE_KEYS.some((key) => node[key] === true);
}

export function getDataTypeHref(title: string): string {
return `${DATA_TYPES_PAGE}#${slugify(title)}`;
}

/**
* Walk a JSON schema and return the `title` of every node flagged
* `x-has-data-type: true`, wherever the flag appears (inline property nodes,
* `$ref` siblings, `$defs` entries, array items, `anyOf` branches). A marked
* node without a title yields `undefined` — an invalid marker the anchor
* check reports.
* Walk a JSON schema and return the `title` of every node flagged as a
* documented data type, in either spelling, wherever the flag appears (inline
* property nodes, `$ref` siblings, `$defs` entries, array items, `anyOf`
* branches). A marked node without a title yields `undefined` — an invalid
* marker the anchor check reports.
*/
export function collectDataTypeTitles(
node: unknown,
Expand Down
Loading
Loading