From 7bf134e426bbd6d43bedcd4630b9b2438edadb76 Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 17 Sep 2026 13:26:13 -0400 Subject: [PATCH] fix: backport has selector validation to v2 --- package.json | 2 +- src/nwsapi.js | 120 ++++++++++++- test/repo/integration/has-validation.test.mts | 169 ++++++++++++++++++ 3 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 test/repo/integration/has-validation.test.mts diff --git a/package.json b/package.json index 16835d74..f38883ef 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "scripts": { "lint": "eslint ./src/nwsapi.js", - "test": "node --test test/repo/integration/maintenance.test.mts", + "test": "node --test test/repo/integration/*.test.mts", "wpt:serve": "node test/wpt/wpt-launcher.mjs serve", "wpt:setup": "node test/wpt/wpt-launcher.mjs setup", "wpt:verify": "node test/wpt/wpt-launcher.mjs verify" diff --git a/src/nwsapi.js b/src/nwsapi.js index b2f12459..b92c4618 100644 --- a/src/nwsapi.js +++ b/src/nwsapi.js @@ -149,6 +149,7 @@ // emulate firefox error strings qsNotArgs = 'Not enough arguments', qsInvalid = ' is not a valid selector', + errors = 0, // detect structural pseudo-classes in selectors reNthElem = RegExp('(:nth(?:-last)?-child)', 'i'), @@ -489,9 +490,9 @@ }, matchLogical = - function(selector) { + function(selector, prefix) { var chr, close, escaped, depth = 1, i, l, quote = '', - match = selector.match(REX.LogicalPfx); + match = selector.match(prefix || REX.LogicalPfx); if (!match) { return null; } @@ -516,6 +517,93 @@ ]; }, + // Validate logical arguments even when the query has no candidates. + validateLogical = + function(argument, relative) { + var previousErrors = errors, selectVars = S_VARS, + matchVars = M_VARS, nodeVars = N_VARS, + list = splitList(argument), parsed, i, j; + S_VARS = []; + M_VARS = []; + N_VARS = []; + try { + for (i = 0; i < list.length; ++i) { + if (!list[i]) { + emit(qsInvalid); + return false; + } + parsed = parse(relative ? '* ' + list[i] : list[i], false); + if (!parsed) { + return false; + } + for (j = 0; j < parsed.length; ++j) { + compileSelector(parsed[j], '', relative, false); + } + } + return errors == previousErrors; + } finally { + S_VARS = selectVars; + M_VARS = matchVars; + N_VARS = nodeVars; + } + }, + + // Invalid nested branches disappear only inside forgiving logical lists. + prepareForgivingHas = + function(logical) { + var items = splitList(logical[2]), kept = [], item, i; + for (i = 0; i < items.length; ++i) { + item = prepareHas(items[i]); + if (item !== null) { + kept.push(item); + } + } + return ':' + logical[1] + '(' + (kept.join(',') || ':not(*)') + ')'; + }, + + prepareHas = + function(text) { + var i = 0, quote = '', bracket = 0, chr, logical, + output = '', start = 0; + for (; i < text.length; ++i) { + chr = text.charAt(i); + if (chr == '\\') { + ++i; + continue; + } + if (quote) { + if (chr == quote) { + quote = ''; + } + continue; + } + if (chr == '"' || chr == "'") { + quote = chr; + continue; + } + if (chr == '[') { + ++bracket; + continue; + } + if (chr == ']') { + --bracket; + continue; + } + if (bracket || chr != ':') { + continue; + } + if (/^:(?:has\(|:|(?:before|after|first-line|first-letter)(?![-\w]))/i.test(text.slice(i))) { + return null; + } + if (Config.FORGIVING && (logical = matchLogical(text.slice(i), /^:(is|where)\(/i))) { + output += text.slice(start, i) + prepareForgivingHas(logical); + i += logical[0].length - 1; + start = i + 1; + } + } + return output + text.slice(start); + }, + method = { '#': 'getElementById', '*': 'getElementsByTagName', @@ -934,8 +1022,10 @@ if (typeof option == 'string') { return !!Config[option]; } if (typeof option != 'object') { return Config; } for (var i in option) { - // Compiled logical selectors capture the forgiving mode. - if (i == 'FORGIVING' && Config[i] !== !!option[i]) { clear = true; } + // Compiled selectors capture forgiving and error-reporting behavior. + if ((i == 'FORGIVING' || i == 'VERBOSITY') && Config[i] !== !!option[i]) { + clear = true; + } Config[i] = !!option[i]; } // clear lambda cache @@ -953,6 +1043,7 @@ emit = function(message, proto) { var err; + ++errors; if (Config.VERBOSITY) { if (proto) { err = new proto(message); @@ -1037,7 +1128,7 @@ '(?:' + pseudoparms + '?)?|' + // universal * & // namespace *|* - '(?:\\*|\\*\\|)|' + + '(?:\\*\\||\\*)|' + '(?:' + '(?::' + pseudonames + '(?:\\x28' + pseudoparms + '?(?:\\x29|$))?|' + @@ -1059,7 +1150,7 @@ '(?:' + // universal * & // namespace *|* - '(?:\\*|\\*\\|)|' + + '(?:\\*\\||\\*)|' + '(?:[.#]?' + identifier + ')+|' + '(?:' + attributes + ')+|' + '(?:::?' + pseudonames + pseudoclass + ')|' + @@ -1437,16 +1528,33 @@ source = 'if(s.matchForgiving(' + JSON.stringify(splitList(match[2])) + ',e)){' + source + '}'; } else { + if (!validateLogical(match[2], false)) { + return ''; + } source = 'if(s.match("' + expr + '",e)){' + source + '}'; } break; case 'matches': + if (!validateLogical(match[2], false)) { + return ''; + } source = 'if(s.match("' + expr + '",e)){' + source + '}'; break; case 'not': + if (!validateLogical(match[2], false)) { + return ''; + } source = 'if(!s.match("' + expr + '",e)){' + source + '}'; break; case 'has': + match[2] = prepareHas(match[2]); + if (match[2] === null) { + emit('\'' + expression + '\'' + qsInvalid); + return ''; + } + if (!validateLogical(match[2], true)) { + return ''; + } source = 'if(s.has(' + JSON.stringify(splitList(match[2])) + ',e)){' + source + '}'; break; default: diff --git a/test/repo/integration/has-validation.test.mts b/test/repo/integration/has-validation.test.mts new file mode 100644 index 00000000..0624376e --- /dev/null +++ b/test/repo/integration/has-validation.test.mts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { test } from 'node:test' +import type { TestContext } from 'node:test' + +const require = createRequire(import.meta.url) +const { JSDOM } = require('jsdom') +const source = readFileSync( + process.env['NWSAPI_TEST_SOURCE'] || + new URL('../../../src/nwsapi.js', import.meta.url), + 'utf8', +) + +function fixture(t: TestContext) { + const { window } = new JSDOM( + '
', + { + runScripts: 'outside-only', + }, + ) + t.after(() => window.close()) + window.eval(source) + const engine = window.NW.Dom + engine.install() + return { window, document: window.document, engine } +} + +for (const selector of [ + ':has()', + ':has(123)', + ':has(span, 123)', + ':has(123, span)', + ':has(, span)', + ':has(span,)', + ':has(:has(*))', + ':has(span:has(*))', + ':has(:not(:has(*)))', + ':not(:has(123))', + ':has(::before)', + ':has(:before)', + ':has(:unknown)', +]) { + test(`${selector} throws before visiting candidates in every selector API`, t => { + const { window, document, engine } = fixture(t) + const empty = document.createElement('section') + const parent = document.getElementById('parent') + const contexts = [ + document, + document.implementation.createHTMLDocument(''), + parent, + empty, + document.createDocumentFragment(), + ] + for (let repeat = 0; repeat < 2; repeat += 1) { + for (const context of contexts) { + for (const query of [ + () => context.querySelector(selector), + () => context.querySelectorAll(selector), + () => engine.select(selector, context), + () => engine.first(selector, context), + ]) { + assert.throws( + query, + error => + error instanceof window.DOMException && + error.name === 'SyntaxError', + ) + } + } + for (const element of [parent, empty]) { + assert.throws(() => element.matches(selector), { name: 'SyntaxError' }) + assert.throws(() => element.closest(selector), { name: 'SyntaxError' }) + assert.throws(() => engine.match(selector, element), { + name: 'SyntaxError', + }) + } + } + assert.equal(parent.matches(':has(> span)'), true) + }) +} + +for (const pseudo of ['is', 'where']) { + test(`:has() discards nested branches only inside forgiving :${pseudo}()`, t => { + const { document, engine } = fixture(t) + const parent = document.getElementById('parent') + for (const invalid of [':has(*)', ':not(:has(*))', '::before']) { + for (let repeat = 0; repeat < 2; repeat += 1) { + assert.equal(parent.matches(`:has(:${pseudo}(${invalid}))`), false) + const selector = `div:has(:${pseudo}(${invalid}, span))` + assert.equal(parent.matches(selector), true) + assert.deepEqual(Array.from(document.querySelectorAll(selector)), [ + parent, + ]) + } + } + assert.equal( + parent.matches(`:has(:${pseudo}(:where(:has(*)), span))`), + true, + ) + const selector = `div:has(:${pseudo}(:has(*), span))` + engine.configure({ FORGIVING: false }) + assert.throws(() => document.createElement('div').querySelector(selector), { + name: 'SyntaxError', + }) + assert.throws(() => parent.matches(selector), { name: 'SyntaxError' }) + engine.configure({ FORGIVING: true }) + assert.equal(parent.matches(selector), true) + }) +} + +test('wildcard namespaces work on either side of :has()', t => { + const { document } = fixture(t) + const parent = document.getElementById('parent') + for (const selector of [ + '*|*:has(> span)', + 'div:has(*|*)', + 'div:has(> *|span)', + '*|div:has(> *|*)', + ]) { + for (let repeat = 0; repeat < 2; repeat += 1) { + assert.deepEqual(Array.from(document.querySelectorAll(selector)), [ + parent, + ]) + assert.equal(parent.matches(selector), true) + } + } +}) + +test('quoted and escaped pseudo text stays literal inside :has()', t => { + const { document } = fixture(t) + const parent = document.getElementById('parent') + const child = document.getElementById('child') + child.setAttribute('data-value', ':has(*), ::before') + child.className = ':has(*)' + for (const selector of [ + 'div:has(> [data-value=":has(*), ::before"])', + "div:has(> [data-value=':has(*), ::before'])", + String.raw`div:has(> .\:has\(\*\))`, + 'div:has(> :is(:has(*), [data-value=":has(*), ::before"]))', + ]) { + assert.deepEqual(Array.from(document.querySelectorAll(selector)), [parent]) + assert.equal(parent.matches(selector), true) + } +}) + +test('quiet validation rejects the entire argument list and preserves verbose retries', t => { + const { document, engine } = fixture(t) + const parent = document.getElementById('parent') + for (const selector of [ + 'div:has()', + 'div:has(span, 123)', + 'div:has(123, span)', + 'div:has(:has(*), span)', + 'div:not(:has(123))', + ]) { + for (let repeat = 0; repeat < 2; repeat += 1) { + engine.configure({ VERBOSITY: false, LOGERRORS: false }) + assert.deepEqual(Array.from(engine.select(selector)), []) + assert.equal(engine.match(selector, parent), false) + engine.configure({ VERBOSITY: true }) + assert.throws(() => engine.select(selector), { name: 'SyntaxError' }) + assert.throws(() => engine.match(selector, parent), { + name: 'SyntaxError', + }) + } + } + assert.equal(parent.matches(':has(> span)'), true) +})