fix: share HTML escaping for invalid wikilink atoms

This commit is contained in:
Herb committed 2026-10-24 12:16:28 -05:00
1 parent 21b71d1dad
commit 2ec3f5b5dd
5 files changed
+49 -12

No files matched your search

+15
View File
@@ -121,3 +121,18 @@ MOSAIC_REQUIRE_EDITOR_BUILD=1 for provenance and the later whole webui
gate. Capture all skip names, raw logs, short scratch TMPDIR and final
Node/Chromium process sweep. Two failures stop a gate; no rerun after an
out-of-row failure without reporting to Sage.
## Filbert R2 static B1 fixed before execution
Review at2026-10-24T17:14:55Z confirmed a defect in the authored invalid
atom path: state.text alone lacks the text-node override's HTML/entity
escaping. Factored that existing policy into escapedText and reused it for
invalid atoms. Extended the test payload/assertions to cover both <img and
&copy;. Valid atom behavior is unchanged. Excluded backticks from atom
recognition and added a text/code precedence fixture for N1.
Requested focused-grant addition: a second mutant that swaps only the
invalid-atom escapedText call for vendored state.text, dropping the <&
escape for this path. The existing malformed-attrs browser test must kill
it. The original serializer-removal mutant is still required. No runs,
checks, installation or rebuild have occurred; all remain held for CLEAR.
+16
View File
@@ -150,3 +150,19 @@ work, not a green candidate. No Node, npm, rebuild or browser starts until
Sage sends CLEAR. HASH-PINS.json records the test-tree audit and row266
intersection. The gate still requires MOSAIC_REQUIRE_EDITOR_BUILD=1, a
named skip list, whole webui and every scripts/test-*.sh.
## R2 static follow-up, Filbert B1/N1 (2026-10-24T17:14:55Z)
B1: state.text(..., true) escapes Markdown punctuation, not HTML/entities.
The invalid-atom writer now calls the same escapedText helper as ordinary
text nodes, including the <& step. Valid atoms still preserve their source
verbatim. The malformed-attribute regression now asserts both an escaped
<img and an escaped &copy;, plus flattened line breaks. A second mutant
will replace only the invalid atom's escapedText call with state.text,
leaving normal text escaping intact; this browser regression must kill it.
This additional mutant needs Sage's grant alongside the serializer-removal
mutant. No runtime result is claimed for either.
N1: backticks are excluded from the atom body, preserving code-span
precedence. The text-only fixture includes [[a`]]` and must produce no
wikilink atom. All other malformed/HTML/entity boundaries remain as before.
+2 -2
View File
@@ -5,12 +5,12 @@
{
"path": "packages/webui/editor/model.mjs",
"mode": "100644",
"sha256": "d56b75264c2c099fa0fc8c9f7a48d8ee701d8657ca64b9bd9492ec27c9446901"
"sha256": "81ac44d852cb54213a9644ebbde26630ec49614e468f376d71b471395e3dd9f7"
},
{
"path": "packages/webui/tests/editor-wikilink.test.mjs",
"mode": "100644",
"sha256": "73360cfc64b2f4d2f7888b576743baea59ea9a53557934507744775fafdc8ab5"
"sha256": "e144e7dbf23c68c136eef8f0fb2e8979c27e908c7cc14ee4ab4f722220e67d77"
},
{
"path": "packages/webui/tests/helpers/wikilink-cases.mjs",
+12 -8
View File
@@ -26,8 +26,8 @@ export const linkTarget = raw => {
// C2-11a: an inert single-line atom keeps its exact source, including labels
// and heading fragments. HTML/entity-like targets stay ordinary text so the
// existing escaped-text serialiser still protects rewritten blocks.
const wikilinkSource = /^!?\[\[[^\[\]\r\n<>&]+\]\]$/;
const wikilinkAt = /!?\[\[[^\[\]\r\n<>&]+\]\]/y;
const wikilinkSource = /^!?\[\[[^\[\]\r\n<>&`]+\]\]$/;
const wikilinkAt = /!?\[\[[^\[\]\r\n<>&`]+\]\]/y;
const isWikilink = value => typeof value === 'string' && wikilinkSource.test(value);
const kept = { raw: { default: null }, sig: { default: null }, lead: { default: '' } };
@@ -202,6 +202,13 @@ const fenceInfo = value => {
const info = typeof value === 'string' ? value.split(/[\r\n]/, 1)[0] : '';
return info.includes('`') ? '' : info;
};
// Share the complete text policy with invalid wikilink attributes. The
// vendored state.text(..., true) alone does not escape HTML or entities.
function escapedText(state, text, escape = true) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&');
try { state.text(text, escape); } finally { delete state.esc; }
}
const link = defaultMarkdownSerializer.marks.link;
const serializer = new MarkdownSerializer({
...defaultMarkdownSerializer.nodes,
@@ -209,11 +216,7 @@ const serializer = new MarkdownSerializer({
const params = fenceInfo(node.attrs.params);
defaultMarkdownSerializer.nodes.code_block(state, params === node.attrs.params ? node : node.type.create({ ...node.attrs, params }, node.content));
},
text(state, node) {
const esc = state.esc;
state.esc = (str, start) => esc.call(state, str, start).replace(/[<&]/g, '\\$&');
try { state.text(node.text, !state.inAutolink); } finally { delete state.esc; }
},
text(state, node) { escapedText(state, node.text, !state.inAutolink); },
bullet_list(state, node) { state.renderList(node, ' ', () => '- '); },
image(state, node) {
const a = node.attrs;
@@ -224,7 +227,8 @@ const serializer = new MarkdownSerializer({
const source = node.attrs.source, valid = isWikilink(source);
// A programmatic invalid atom must not bypass the same text escaping that
// protects pasted HTML and one-line attributes elsewhere in this model.
state.text(valid ? source : oneLine(String(source ?? '')), !valid);
if (valid) state.text(source, false);
else escapedText(state, oneLine(String(source ?? '')));
},
raw_block(state, node) { state.text(node.textContent, false); state.closeBlock(node); },
}, {
@@ -55,7 +55,7 @@ test('wikilinks are inline atoms and survive a neighbouring edit in every requir
test('code, escaped openers, malformed syntax and HTML-like targets stay outside wikilink atoms', { timeout: 120000 }, async t => {
const b = await page(t);
const text = '\\[[escaped]] `[[inline-code]]`\n\n```\n[[fenced-code]]\n```\n\n[[open\n\n[[]]\n\n[[Q&A]]\n\n[[<img src=x onerror=alert(1)>]]\n';
const text = '\\[[escaped]] `[[inline-code]]`\n\n```\n[[fenced-code]]\n```\n\n[[open\n\n[[]]\n\n[[Q&A]]\n\n[[a`]]`\n\n[[<img src=x onerror=alert(1)>]]\n';
assert.equal(editor.roundTrip(text), text);
await b.evaluate(mount(text));
assert.deepEqual(await b.evaluate(atoms), []);
@@ -68,7 +68,7 @@ test('wikilink DOM paste accepts only bounded single-line inert syntax; malforme
const e = window.__wikiEditor, spec = e.view.state.schema.nodes.wikilink.spec;
const attrs = source => spec.parseDOM[0].getAttrs({getAttribute: () => source});
const invalid = ['not a link', '[[a]]\\n\\n<script>x</script>', '[[<img src=x>]]', '[[a&copy;]]', null].map(source => attrs(source));
const bad = e.view.state.schema.nodes.wikilink.create({source:'[[a]]\\n\\n<img src=x onerror=alert(1)>'});
const bad = e.view.state.schema.nodes.wikilink.create({source:'[[a]]\\n\\n<img src=x onerror=alert(1)>&copy;'});
e.view.dispatch(e.view.state.tr.replaceWith(1, e.view.state.doc.content.size - 1, bad));
return {valid:attrs('[[a#h|label]]'), invalid, output:e.text()};
})()`);
@@ -77,4 +77,6 @@ test('wikilink DOM paste accepts only bounded single-line inert syntax; malforme
assert.ok(!result.output.includes('\n\n<img'));
assert.doesNotMatch(result.output, /(?<!\\)<img/);
assert.match(result.output, /\\<img/);
assert.doesNotMatch(result.output, /(?<!\\)&copy;/);
assert.match(result.output, /\\&copy;/);
});