Skip to content
Merged
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
63 changes: 33 additions & 30 deletions docs/current/SEMANTIC_OWNERSHIP.md

Large diffs are not rendered by default.

107 changes: 107 additions & 0 deletions packages/adapter-vite/__tests__/renderer-scope-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Renderer-scope binding corpus (B1.1 audit remediation, #1271 / finding F2).
*
* `rendererScopeMatches` (entry-route-helpers.ts) is the canonical codegen-time
* scope predicate (used by entry-codegen.ts and entry-not-found-codegen.ts);
* the generated `__matchingRenderers` function emitted by
* `renderMatchingRenderersFn` is its runtime re-expression inside
* self-contained generated entries, which cannot import adapter internals.
* Before this test nothing bound the two: a predicate drift (trailing-slash,
* boundary-separator or case handling) would have been silent. The corpus
* below evaluates the generated function verbatim and requires observable
* parity with the predicate for every (scope set, route path) pair.
*/

import { assertEquals } from '@std/assert';
import {
rendererScopeMatches,
renderMatchingRenderersFn,
} from '../src/internal/ssg/entry-route-helpers.ts';
import type { RendererDecl } from '../src/internal/protocol/ssg.ts';

function rendererDecls(scopes: readonly string[]): RendererDecl[] {
return scopes.map((scope, index) => ({
varName: `__renderer_${index}`,
scope,
importPath: `./_renderer_${index}.ts`,
depth: 0,
}));
}

/**
* Evaluate the generated __matchingRenderers source verbatim. Each renderer
* variable is bound to `{ default: <unique marker> }` so the returned array
* identifies exactly which renderers the generated matcher selected.
*/
function evaluateGeneratedMatcher(
renderers: RendererDecl[],
): (routePath: string) => unknown[] {
const lines: string[] = [];
renderMatchingRenderersFn(lines, renderers);
const declarations = renderers
.map((renderer, index) => `const ${renderer.varName} = { default: __markers[${index}] };`)
.join('\n');
const body = `${declarations}\n${lines.join('\n')}\nreturn __matchingRenderers;`;
const factory = new Function('__markers', body) as (
markers: unknown[],
) => (routePath: string) => unknown[];
return factory(renderers.map((_, index) => ({ marker: index })));
}

/** Adversarial scope sets, including root-only, nested and sibling scopes. */
const SCOPE_SETS: ReadonlyArray<readonly string[]> = [
['/'],
['/docs'],
['/', '/docs'],
['/', '/docs', '/docs/api'],
['/docs', '/admin'],
['/docs/api'],
];

/** Route paths attacking exact, prefix, nested, non-match, boundary-separator and case handling. */
const ROUTE_PATHS: readonly string[] = [
'/',
'/docs',
'/docs/',
'/docs/api',
'/docs/api/v1',
'/docs/ap',
'/docsify',
'/documentation',
'/Docs',
'/admin',
'/admin/users',
'/other',
];

Deno.test('renderer scope parity: generated __matchingRenderers mirrors rendererScopeMatches', () => {
for (const scopes of SCOPE_SETS) {
const renderers = rendererDecls(scopes);
const generated = evaluateGeneratedMatcher(renderers);
for (const routePath of ROUTE_PATHS) {
const expected = renderers
.map((renderer, index) => ({ renderer, index }))
.filter(({ renderer }) => rendererScopeMatches(routePath, renderer.scope))
.map(({ index }) => ({ marker: index }));
const actual = generated(routePath);
assertEquals(
actual,
expected,
`scope mirror diverged for scopes=${JSON.stringify(scopes)} routePath=${
JSON.stringify(routePath)
}`,
);
}
}
});

Deno.test('renderer scope parity: boundary separators and case are significant', () => {
// Pins the canonical predicate contract itself so a semantic change here
// (not just a codegen/runtime skew) is a deliberate, reviewed act.
assertEquals(rendererScopeMatches('/docs', '/docs'), true);
assertEquals(rendererScopeMatches('/docs/api', '/docs'), true);
assertEquals(rendererScopeMatches('/docsify', '/docs'), false);
assertEquals(rendererScopeMatches('/Docs', '/docs'), false);
assertEquals(rendererScopeMatches('/anything', '/'), true);
assertEquals(rendererScopeMatches('/', '/'), true);
});
73 changes: 72 additions & 1 deletion packages/element/__tests__/compiled-escape-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
* both call sites and requires byte-identical output.
*/

import { assertEquals } from '@std/assert';
import { assertEquals, assertStringIncludes } from '@std/assert';
import { serializeToHtml as serializeRuntime } from '../src/internal/compiled/runtime.ts';
import { serializeToHtml as serializeServer } from '../src/internal/compiled/server/index.ts';
import { escapeAttr } from '../src/internal/core/html-escape.ts';
import { escapeText } from '../src/internal/compiled/escape-text.ts';
import { testProgram } from './compiled-runtime/test-program.ts';

const CORPUS: readonly string[] = [
Expand Down Expand Up @@ -71,3 +72,73 @@ Deno.test('escape parity: fixed attribute corpus is byte-identical across both s
Deno.test('escape parity: canonical contract escapes & < > " and \'', () => {
assertEquals(escapeAttr(`a&b"c<d>e'f`), 'a&amp;b&quot;c&lt;d&gt;e&#39;f');
});

/**
* Text-node corpus (B1.1 audit remediation, #1272 / finding F3).
*
* Text nodes use a REDUCED escape contract (`&`, `<`, `>` only — quotes are
* pass-through in text content) owned by one shared helper,
* `internal/compiled/escape-text.ts`, consumed by both serializers. Before the
* convergence each serializer carried a private copy and no test bound the
* two at byte level for text output; a drift confined to `>` escaping in text
* nodes would have been silent. This corpus requires byte-identical text
* output across both serializers and pins the shared contract.
*/
const TEXT_CORPUS: readonly string[] = [
`a&b"c<d>e'f`,
`<`,
`>`,
`&`,
`"`,
`'`,
`&quot;entity-looking&quot;`,
`plain`,
`line\nbreak\ttab`,
`unicode é ‹› „ “`,
`</script><script>alert(1)</script>`,
];

Deno.test('escape parity: static text corpus is byte-identical across both serializers', () => {
for (const value of TEXT_CORPUS) {
const program = testProgram({
tag: 'x-parity',
template: [
{ k: 'el', tag: 'div', attrs: [], children: [{ k: 'text', value }] },
],
parts: [],
});
const runtime = serializeRuntime(program, hostWith(undefined) as unknown as RuntimeHost);
const server = serializeServer(program, hostWith(undefined));
assertEquals(runtime, server, `serializers diverged for ${JSON.stringify(value)}`);
assertEquals(
runtime,
`<div>${escapeText(value)}</div>`,
`shared escapeText contract broken for ${JSON.stringify(value)}`,
);
}
});

Deno.test('escape parity: text Part corpus is byte-identical across both serializers', () => {
for (const value of TEXT_CORPUS) {
const program = testProgram({
tag: 'x-parity',
template: [
{ k: 'el', tag: 'div', attrs: [], children: [{ k: 'part', index: 0 }] },
],
parts: [{ k: 'text', index: 0, signal: 'v' }],
});
const runtime = serializeRuntime(program, hostWith(value) as unknown as RuntimeHost);
const server = serializeServer(program, hostWith(value));
assertEquals(runtime, server, `serializers diverged for ${JSON.stringify(value)}`);
assertStringIncludes(
runtime,
escapeText(value),
`escaped text missing from output for ${JSON.stringify(value)}`,
);
}
});

Deno.test('escape parity: text contract escapes & < > and passes quotes and non-ASCII through', () => {
assertEquals(escapeText(`a&b"c<d>e'f`), 'a&amp;b"c&lt;d&gt;e\'f');
assertEquals(escapeText(`unicode é ‹› „ “`), `unicode é ‹› „ “`);
});
22 changes: 22 additions & 0 deletions packages/element/src/internal/compiled/escape-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* escape-text.ts — the ONE text-node escape contract for the compiled
* serializers (B1.1 audit remediation, #1272 / finding F3).
*
* Both compiled serializers (the runtime seed serializer `runtime.ts` and the
* server serializer `server/index.ts`) emit text-node bytes through this one
* helper; before the convergence each carried a private byte-identical copy
* with no named owner and no byte-level parity corpus (the claim-parity guard
* `compiled-escape-parity.test.ts` covered attributes only).
*
* The contract is deliberately reduced: `&`, `<`, `>` only. Quotes are NOT
* escaped — they are inert in text content and the wire bytes must stay
* stable for claim parity. Distinct contracts exist elsewhere and are NOT
* this surface:
* - `escapeAttr`/`escapeHtml` (`internal/core/html-escape.ts`) additionally
* escape quotes for the attribute context.
* - `sanitize.ts` has its own entity-preserving `escapeText` with a
* deliberately different contract — do not consolidate.
*/
export function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
7 changes: 3 additions & 4 deletions packages/element/src/internal/compiled/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { trustedHtmlValue } from '../core/security.ts';
// Canonical void-element set and attribute-escape contract (issue #1220,
// M4/L1) — single source of truth, shared with the server serializer.
import { escapeAttr, VOID_TAGS } from '../core/html-escape.ts';
// Canonical text-node escape contract (#1272) — shared with the server
// serializer; do not reintroduce a private copy.
import { escapeText } from './escape-text.ts';
import { noteCompiledProgramActivated } from '../signal/selection.ts';
import {
partAnchorEndMarker,
Expand Down Expand Up @@ -1134,10 +1137,6 @@ export function createFreshDom(

// ─── Server serialization ──────────────────────────────────────────

function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}

function serializedFixedAttributes(
ctx: MountContext,
node: ProgramElementNode,
Expand Down
7 changes: 3 additions & 4 deletions packages/element/src/internal/compiled/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import { trustedHtmlValue } from '../../core/security.ts';
// the wire truth for claim parity, so both serializers share this one
// implementation (escapes & < > " ').
import { escapeAttr } from '../../core/html-escape.ts';
// Canonical text-node escape contract (#1272): shared with the runtime seed
// serializer; do not reintroduce a private copy.
import { escapeText } from '../escape-text.ts';

export type { CompiledProgramHost, CompiledSignalLike } from './shared.ts';
export { assertCompiledProgram, CompiledProgramValidationError } from './shared.ts';
Expand Down Expand Up @@ -105,10 +108,6 @@ interface SerializeContext {
readonly consumedProjections: Set<string>;
}

function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}

function pathKey(path: readonly number[]): string {
return path.join(PROPERTY_PATH_SEPARATOR);
}
Expand Down
Loading