tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 8a038d2e7f57d358eec4f38bc3c72c2b9213475a
parent 9a0627d238cd984b8e57bd997f81e6996a007a05
Author: Tom Schuster <tschuster@mozilla.com>
Date:   Thu, 16 Oct 2025 21:25:28 +0000

Bug 1989215 - Sanitizer: Sort the result of the get() method. r=smaug

Differential Revision: https://phabricator.services.mozilla.com/D267211

Diffstat:
Mdom/security/sanitizer/Sanitizer.cpp | 67+++++++++++++++++++++++++++++++++++++++++++++----------------------
Mdom/security/sanitizer/SanitizerTypes.cpp | 6++++--
Mdom/security/sanitizer/SanitizerTypes.h | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/web-platform/tests/sanitizer-api/sanitizer-get.tentative.html | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtesting/web-platform/tests/sanitizer-api/sanitizer-modifiers.tentative.html | 190++++++-------------------------------------------------------------------------
Mtesting/web-platform/tests/sanitizer-api/sanitizer-removeUnsafe.tentative.html | 4++--
Atesting/web-platform/tests/sanitizer-api/support/util.js | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 369 insertions(+), 202 deletions(-)

diff --git a/dom/security/sanitizer/Sanitizer.cpp b/dom/security/sanitizer/Sanitizer.cpp @@ -189,23 +189,6 @@ void Sanitizer::SetDefaultConfig() { ClearOnShutdown(&sDefaultAttributes); } -auto& GetAsSanitizerElementNamespace( - const StringOrSanitizerElementNamespace& aElement) { - return aElement.GetAsSanitizerElementNamespace(); -} -auto& GetAsSanitizerElementNamespace( - const OwningStringOrSanitizerElementNamespace& aElement) { - return aElement.GetAsSanitizerElementNamespace(); -} -auto& GetAsSanitizerElementNamespace( - const StringOrSanitizerElementNamespaceWithAttributes& aElement) { - return aElement.GetAsSanitizerElementNamespaceWithAttributes(); -} -auto& GetAsSanitizerElementNamespace( - const OwningStringOrSanitizerElementNamespaceWithAttributes& aElement) { - return aElement.GetAsSanitizerElementNamespaceWithAttributes(); -} - // https://wicg.github.io/sanitizer-api/#canonicalize-a-sanitizer-element template <typename SanitizerElement> static CanonicalName CanonicalizeElement(const SanitizerElement& aElement) { @@ -224,7 +207,7 @@ static CanonicalName CanonicalizeElement(const SanitizerElement& aElement) { // Step 3. Assert: name is a dictionary and both name["name"] and // name["namespace"] exist. - const auto& elem = GetAsSanitizerElementNamespace(aElement); + const auto& elem = GetAsDictionary(aElement); MOZ_ASSERT(!elem.mName.IsVoid()); // Step 4. If name["namespace"] is the empty string, then set it to null. @@ -760,49 +743,89 @@ void Sanitizer::MaybeMaterializeDefaultConfig() { mIsDefaultConfig = false; } +// https://wicg.github.io/sanitizer-api/#dom-sanitizer-get void Sanitizer::Get(SanitizerConfig& aConfig) { MaybeMaterializeDefaultConfig(); + // Step 1. Let config be this’s configuration. + // Step 2. If config["elements"] exists: if (mElements) { nsTArray<OwningStringOrSanitizerElementNamespaceWithAttributes> elements; + // Step 2.1. For any element of config["elements"]: for (const auto& entry : *mElements) { - elements.AppendElement()->SetAsSanitizerElementNamespaceWithAttributes() = + // Step 2.1.1. If element["attributes"] exists: + // Step 2.1.2. If element["removeAttributes"] exists: + // ... + // (The attributes are sorted by the ToSanitizerAttributes call in + // ToSanitizerElementNamespaceWithAttributes) + OwningStringOrSanitizerElementNamespaceWithAttributes owning; + owning.SetAsSanitizerElementNamespaceWithAttributes() = entry.GetKey().ToSanitizerElementNamespaceWithAttributes( entry.GetData()); + + // Step 2.2. Set config["elements"] to the result of sort in ascending + // order config["elements"], with elementA being less than item elementB. + // (Instead of sorting at the end, we sort during insertion) + elements.InsertElementSorted(owning, + SanitizerComparator<decltype(owning)>()); } aConfig.mElements.Construct(std::move(elements)); } else { + // Step 3. If config["removeElements"] exists: + // Step 3.1. Set config["removeElements"] to the result of sort in ascending + // order config["removeElements"], with elementA being less than item + // elementB. nsTArray<OwningStringOrSanitizerElementNamespace> removeElements; for (const CanonicalName& canonical : *mRemoveElements) { - removeElements.AppendElement()->SetAsSanitizerElementNamespace() = + OwningStringOrSanitizerElementNamespace owning; + owning.SetAsSanitizerElementNamespace() = canonical.ToSanitizerElementNamespace(); + removeElements.InsertElementSorted( + owning, SanitizerComparator<decltype(owning)>()); } aConfig.mRemoveElements.Construct(std::move(removeElements)); } + // Step 4. If config["replaceWithChildrenElements"] exists: if (mReplaceWithChildrenElements) { + // Step 4.1. Set config["replaceWithChildrenElements"] to the result of sort + // in ascending order config["replaceWithChildrenElements"], with elementA + // being less than item elementB. nsTArray<OwningStringOrSanitizerElementNamespace> replaceWithChildrenElements; for (const CanonicalName& canonical : *mReplaceWithChildrenElements) { - replaceWithChildrenElements.AppendElement() - ->SetAsSanitizerElementNamespace() = + OwningStringOrSanitizerElementNamespace owning; + owning.SetAsSanitizerElementNamespace() = canonical.ToSanitizerElementNamespace(); + replaceWithChildrenElements.InsertElementSorted( + owning, SanitizerComparator<decltype(owning)>()); } aConfig.mReplaceWithChildrenElements.Construct( std::move(replaceWithChildrenElements)); } + // Step 5. If config["attributes"] exists: if (mAttributes) { + // Step 5.1. Set config["attributes"] to the result of sort in ascending + // order config["attributes"], with attrA being less than item attrB. + // (Sorting is done by ToSanitizerAttributes) aConfig.mAttributes.Construct(ToSanitizerAttributes(*mAttributes)); } else { + // Step 6. If config["removeAttributes"] exists: + // Step 6.1. Set config["removeAttributes"] to the result of sort in + // ascending order config["removeAttributes"], with attrA being less than + // item attrB. aConfig.mRemoveAttributes.Construct( ToSanitizerAttributes(*mRemoveAttributes)); } + // (In the spec these already exist in the |config| and don't need sorting) aConfig.mComments.Construct(mComments); if (mDataAttributes) { aConfig.mDataAttributes.Construct(*mDataAttributes); } + + // Step 7. Return config. } // https://wicg.github.io/sanitizer-api/#sanitizerconfig-allow-an-element diff --git a/dom/security/sanitizer/SanitizerTypes.cpp b/dom/security/sanitizer/SanitizerTypes.cpp @@ -86,11 +86,13 @@ bool CanonicalElementAttributes::Equals( nsTArray<OwningStringOrSanitizerAttributeNamespace> ToSanitizerAttributes( const CanonicalNameSet& aSet) { - // XXX Sorting nsTArray<OwningStringOrSanitizerAttributeNamespace> attributes; for (const CanonicalName& canonical : aSet) { - attributes.AppendElement()->SetAsSanitizerAttributeNamespace() = + OwningStringOrSanitizerAttributeNamespace owning; + owning.SetAsSanitizerAttributeNamespace() = canonical.ToSanitizerAttributeNamespace(); + attributes.InsertElementSorted(owning, + SanitizerComparator<decltype(owning)>()); } return attributes; } diff --git a/dom/security/sanitizer/SanitizerTypes.h b/dom/security/sanitizer/SanitizerTypes.h @@ -77,6 +77,81 @@ using CanonicalElementMap = nsTArray<OwningStringOrSanitizerAttributeNamespace> ToSanitizerAttributes( const CanonicalNameSet& aSet); +inline const auto& GetAsDictionary( + const OwningStringOrSanitizerAttributeNamespace& aOwning) { + return aOwning.GetAsSanitizerAttributeNamespace(); +} + +inline const auto& GetAsDictionary( + const OwningStringOrSanitizerElementNamespace& aOwning) { + return aOwning.GetAsSanitizerElementNamespace(); +} + +inline const auto& GetAsDictionary( + const OwningStringOrSanitizerElementNamespaceWithAttributes& aOwning) { + return aOwning.GetAsSanitizerElementNamespaceWithAttributes(); +} + +inline const auto& GetAsDictionary( + const StringOrSanitizerElementNamespace& aElement) { + return aElement.GetAsSanitizerElementNamespace(); +} + +inline const auto& GetAsDictionary( + const StringOrSanitizerElementNamespaceWithAttributes& aElement) { + return aElement.GetAsSanitizerElementNamespaceWithAttributes(); +} + +template <typename SanitizerNameNamespace> +class MOZ_STACK_CLASS SanitizerComparator final { + public: + bool Equals(const SanitizerNameNamespace& aItemA, + const SanitizerNameNamespace& aItemB) const { + const auto& itemA = GetAsDictionary(aItemA); + const auto& itemB = GetAsDictionary(aItemB); + + return itemA.mNamespace.IsVoid() == itemB.mNamespace.IsVoid() && + itemA.mNamespace == itemB.mNamespace && itemA.mName == itemB.mName; + } + + // https://wicg.github.io/sanitizer-api/#sanitizerconfig-less-than-item + bool LessThan(const SanitizerNameNamespace& aItemA, + const SanitizerNameNamespace& aItemB) const { + const auto& itemA = GetAsDictionary(aItemA); + const auto& itemB = GetAsDictionary(aItemB); + + // Step 1. If itemA["namespace"] is null: + if (itemA.mNamespace.IsVoid()) { + // Step 1.1. If itemB["namespace"] is not null, return true. + if (!itemB.mNamespace.IsVoid()) { + return true; + } + } else { + // Step 2. Otherwise: + // Step 2.1. If itemB["namespace"] is null, return false. + if (itemB.mNamespace.IsVoid()) { + return false; + } + + int result = Compare(itemA.mNamespace, itemB.mNamespace); + // Step 2.2. If itemA["namespace"] is code unit less than + // itemB["namespace"], return true. + if (result < 0) { + return true; + } + // Step 2.3. If itemA["namespace"] is not equal itemB["namespace"], return + // false. + // XXX https://github.com/WICG/sanitizer-api/pull/341 + if (result != 0) { + return false; + } + } + + // Step 3. Return itemA["name"] is code unit less thanitemB["name"]. + return itemA.mName < itemB.mName; + } +}; + } // namespace mozilla::dom::sanitizer #endif diff --git a/testing/web-platform/tests/sanitizer-api/sanitizer-get.tentative.html b/testing/web-platform/tests/sanitizer-api/sanitizer-get.tentative.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> +<head> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="./support/util.js"></script> +</head> +<body> +<script> + +const TEST_CASES = [ + [ + ["b", "a"], + ["a", "b"] + ], + [ + ["c", "b", "a"], + ["a", "b", "c"] + ], + [ + [{name: "b", namespace: null}, {name: "a", namespace: null}], + [{name: "a", namespace: null}, {name: "b", namespace: null}] + ], + [ + [{name: "_", namespace: "a"}, {name: "_", namespace: null}], + [{name: "_", namespace: null}, {name: "_", namespace: "a"}] + ], + [ + [{name: "_", namespace: "b"}, {name: "_", namespace: "a"}], + [{name: "_", namespace: "a"}, {name: "_", namespace: "b"}] + ], + [ + [{name: "a", namespace: "b"}, {name: "z", namespace: "a"}, {name: "b", namespace: "b"}], + [{name: "z", namespace: "a"}, {name: "a", namespace: "b"}, {name: "b", namespace: "b"}], + ] +]; + +for (const key of ["attributes", "removeAttributes", "elements", "removeElements", "replaceWithChildrenElements"]) { + test(() => { + for (const [input, expected] of TEST_CASES) { + let s = new Sanitizer({ + [key]: input + }); + assert_config(s.get(), { + [key]: expected + }); + } + }, `Sorting of ${key}`); +} + +for (const key of ["attributes", "removeAttributes"]) { + test(() => { + for (const [input, expected] of TEST_CASES) { + let s = new Sanitizer({ + elements: [{name: "_", [key]: input}] + }); + assert_config(s.get(), { + elements: [{name: "_", [key]: expected}] + }); + } + }, `Sorting of element's ${key}`); +} + +</script> +</body> +</html> +\ No newline at end of file diff --git a/testing/web-platform/tests/sanitizer-api/sanitizer-modifiers.tentative.html b/testing/web-platform/tests/sanitizer-api/sanitizer-modifiers.tentative.html @@ -3,172 +3,10 @@ <head> <script src="/resources/testharness.js"></script> <script src="/resources/testharnessreport.js"></script> + <script src="./support/util.js"></script> </head> <body> <script> -function is_same_sanitizer_name(a, b) { - return a.name === b.name && a.namespace === b.namespace; -} - -// https://pr-preview.s3.amazonaws.com/otherdaniel/purification/pull/296.html#sanitizerconfig-valid -function assert_config_is_valid(config) { - // The config has either an elements or a removeElements key, but not both. - assert_false( - "elements" in config && "removeElements" in config, - "Either elements or a removeElements, but not both", - ); - assert_true( - "elements" in config || "removeElements" in config, - "Either elements or a removeElements", - ); - - // The config has either an attributes or a removeAttributes key, but not both. - assert_false( - "attributes" in config && "removeAttributes" in config, - "Either attributes or a removeAttributes, but not both", - ); - assert_true( - "attributes" in config || "removeAttributes" in config, - "Either attributes or removeAttributes", - ); - - // If both config[elements] and config[replaceWithChildrenElements] exist, then the difference of config[elements] and config[replaceWithChildrenElements] is empty. - if (config.elements && config.replaceWithChildrenElements) { - for (let element of config.elements) { - assert_false( - config.replaceWithChildrenElements.some((replaceElement) => - is_same_sanitizer_name(element, replaceElement), - ), - `replaceWithChildrenElements should not contain ${element.name}`, - ); - } - } - - // If both config[removeElements] and config[replaceWithChildrenElements] exist, then the difference of config[removeElements] and config[replaceWithChildrenElements] is empty. - if (config.removeElements && config.replaceWithChildrenElements) { - for (let removeElement of config.removeElements) { - assert_false( - config.replaceWithChildrenElements.some((replaceElement) => - is_same_sanitizer_name(removeElement, replaceElement), - ), - `replaceWithChildrenElements should not contain ${removeElement.name}`, - ); - } - } - - // If config[attributes] exists: - if (config.attributes) { - } else { - // config[dataAttributes] does not exist. - assert_false("dataAttributes" in config, "dataAttributes does not exist"); - } -} - -function assert_config(config, expected) { - const PROPERTIES = [ - "attributes", - "removeAttributes", - "elements", - "removeElements", - "replaceWithChildrenElements", - "comments", - "dataAttributes", - ]; - - // Prevent some typos in the expected config. - for (let key of Object.keys(expected)) { - assert_in_array(key, PROPERTIES, "expected"); - } - for (let key of Object.keys(config)) { - assert_in_array(key, PROPERTIES, "config"); - } - - assert_config_is_valid(config); - - // XXX dataAttributes - // XXX comments - // XXX duplications - // XXX other consistency checks - - function assert_attrs(key, config, expected, prefix = "config") { - // XXX we allow specifying only a subset for expected. - if (!(key in expected)) { - return; - } - - if (expected[key] === undefined) { - assert_false(key in config, `Unexpected '${key}' in ${prefix}`); - return; - } - - assert_true(key in config, `Missing '${key}' from ${prefix}`); - assert_equals(config[key]?.length, expected[key].length, `${prefix}.${key}.length`); - for (let i = 0; i < expected[key].length; i++) { - let attribute = expected[key][i]; - if (typeof attribute === "string") { - assert_object_equals( - config[key][i], - { name: attribute, namespace: null }, - `${prefix}.${key}[${i}] should match`, - ); - } else { - assert_object_equals( - config[key][i], - attribute, - `${prefix}.${key}[${i}] should match`, - ); - } - } - } - - assert_attrs("attributes", config, expected); - assert_attrs("removeAttributes", config, expected); - - function assert_elems(key) { - if (!(key in expected)) { - return; - } - - if (expected[key] === undefined) { - assert_false(key in config, `Unexpected '${key}' in config`); - return; - } - - assert_true(key in config, `Missing '${key}' from config`); - assert_equals(config[key]?.length, expected[key].length, `${key}.length`); - - const XHTML_NS = "http://www.w3.org/1999/xhtml"; - - for (let i = 0; i < expected[key].length; i++) { - let element = expected[key][i]; - // To make writing tests a bit easier we also support the shorthand string syntax. - if (typeof element === "string") { - let extra = key === "elements" ? { removeAttributes: [] } : { }; - assert_object_equals( - config[key][i], - { name: element, namespace: XHTML_NS, ...extra }, - `${key}[${i}] should match`, - ); - } else { - if (key === "elements") { - assert_equals(config[key][i].name, element.name, `${key}[${i}].name should match`); - let ns = "namespace" in element ? element.namespace : XHTML_NS; - assert_equals(config[key][i].namespace, ns, `${key}[${i}].namespace should match`); - - assert_attrs("attributes", config[key][i], element, `config.elements[${i}]`); - assert_attrs("removeAttributes", config[key][i], element, `config.elements[${i}]`); - } else { - assert_object_equals(config[key][i], element, `${key}[${i}] should match`); - } - } - } - } - - assert_elems("elements"); - assert_elems("removeElements"); - assert_elems("replaceWithChildrenElements"); -} - const NS = "http://example.org/"; test(() => { @@ -339,19 +177,19 @@ test(() => { assert_true(s.removeAttribute("dir")); assert_config(s.get(), { - removeAttributes: ["title", "dir"], + removeAttributes: ["dir", "title"], elements: [{ name: "div", attributes: ["class"], removeAttributes: ["id"] }], }); assert_true(s.removeAttribute("id")); assert_config(s.get(), { - removeAttributes: ["title", "dir", "id"], + removeAttributes: ["dir", "id", "title"], elements: [{ name: "div", attributes: ["class"], removeAttributes: [] }], }); assert_true(s.removeAttribute("class")); assert_config(s.get(), { - removeAttributes: ["title", "dir", "id", "class"], + removeAttributes: ["class", "dir", "id", "title"], elements: [{ name: "div", attributes: [], removeAttributes: [] }], }); }, "sanitizer.removeAttribute() with global removeAttributes and elements"); @@ -364,13 +202,13 @@ test(() => { assert_false(s.removeElement("span")); assert_config(s.get(), { - elements: ["p", { name: "p", namespace: NS }], + elements: [{ name: "p", namespace: NS }, "p"], replaceWithChildrenElements: ["b"], }); assert_true(s.removeElement("b")); assert_config(s.get(), { - elements: ["p", { name: "p", namespace: NS }], + elements: [{ name: "p", namespace: NS }, "p"], replaceWithChildrenElements: [], }); @@ -395,25 +233,25 @@ test(() => { assert_false(s.removeElement("p")); assert_config(s.get(), { - removeElements: ["p", { name: "p", namespace: NS }], + removeElements: [{ name: "p", namespace: NS }, "p"], replaceWithChildrenElements: ["b"], }); assert_false(s.removeElement({ name: "p", namespace: NS })); assert_config(s.get(), { - removeElements: ["p", { name: "p", namespace: NS }], + removeElements: [{ name: "p", namespace: NS }, "p"], replaceWithChildrenElements: ["b"], }); assert_true(s.removeElement("span")); assert_config(s.get(), { - removeElements: ["p", { name: "p", namespace: NS }, "span"], + removeElements: [{ name: "p", namespace: NS }, "p", "span"], replaceWithChildrenElements: ["b"], }); assert_true(s.removeElement("b")); assert_config(s.get(), { - removeElements: ["p", { name: "p", namespace: NS }, "span", "b"], + removeElements: [{ name: "p", namespace: NS }, "b", "p", "span"], replaceWithChildrenElements: [], }); }, "sanitizer.removeElement() with global removeElements"); @@ -438,7 +276,7 @@ test(() => { assert_true(s.replaceElementWithChildren("b")); assert_config(s.get(), { - replaceWithChildrenElements: ["a", "span", "b"], + replaceWithChildrenElements: ["a", "b", "span"], elements: [], }); }, "sanitizer.replaceElementWithChildren() with global elements"); @@ -463,7 +301,7 @@ test(() => { assert_true(s.replaceElementWithChildren("b")); assert_config(s.get(), { - replaceWithChildrenElements: ["a", "span", "b"], + replaceWithChildrenElements: ["a", "b", "span"], removeElements: [], }); }, "sanitizer.replaceElementWithChildren() with global removeElements"); @@ -482,13 +320,13 @@ test(() => { assert_true(s.allowElement({name: "a", namespace: NS})); assert_config(s.get(), { - elements: ["a", {name: "a", namespace: NS}], + elements: [{name: "a", namespace: NS}, "a"], replaceWithChildrenElements: ["b"] }); assert_true(s.allowElement("b")); assert_config(s.get(), { - elements: ["a", {name: "a", namespace: NS}, "b"], + elements: [{name: "a", namespace: NS}, "a", "b"], replaceWithChildrenElements: [] }); }, "sanitizer.allowElement() with global elements"); diff --git a/testing/web-platform/tests/sanitizer-api/sanitizer-removeUnsafe.tentative.html b/testing/web-platform/tests/sanitizer-api/sanitizer-removeUnsafe.tentative.html @@ -22,7 +22,7 @@ test(t => { "removeElements": [ { "namespace": "http://www.w3.org/1999/xhtml", - "name": "script" + "name": "embed" }, { "namespace": "http://www.w3.org/1999/xhtml", @@ -38,7 +38,7 @@ test(t => { }, { "namespace": "http://www.w3.org/1999/xhtml", - "name": "embed" + "name": "script" }, { "namespace": "http://www.w3.org/2000/svg", diff --git a/testing/web-platform/tests/sanitizer-api/support/util.js b/testing/web-platform/tests/sanitizer-api/support/util.js @@ -0,0 +1,162 @@ +function is_same_sanitizer_name(a, b) { + return a.name === b.name && a.namespace === b.namespace; +} + +// https://pr-preview.s3.amazonaws.com/otherdaniel/purification/pull/296.html#sanitizerconfig-valid +function assert_config_is_valid(config) { + // The config has either an elements or a removeElements key, but not both. + assert_false( + "elements" in config && "removeElements" in config, + "Either elements or a removeElements, but not both", + ); + assert_true( + "elements" in config || "removeElements" in config, + "Either elements or a removeElements", + ); + + // The config has either an attributes or a removeAttributes key, but not both. + assert_false( + "attributes" in config && "removeAttributes" in config, + "Either attributes or a removeAttributes, but not both", + ); + assert_true( + "attributes" in config || "removeAttributes" in config, + "Either attributes or removeAttributes", + ); + + // If both config[elements] and config[replaceWithChildrenElements] exist, then the difference of config[elements] and config[replaceWithChildrenElements] is empty. + if (config.elements && config.replaceWithChildrenElements) { + for (let element of config.elements) { + assert_false( + config.replaceWithChildrenElements.some((replaceElement) => + is_same_sanitizer_name(element, replaceElement), + ), + `replaceWithChildrenElements should not contain ${element.name}`, + ); + } + } + + // If both config[removeElements] and config[replaceWithChildrenElements] exist, then the difference of config[removeElements] and config[replaceWithChildrenElements] is empty. + if (config.removeElements && config.replaceWithChildrenElements) { + for (let removeElement of config.removeElements) { + assert_false( + config.replaceWithChildrenElements.some((replaceElement) => + is_same_sanitizer_name(removeElement, replaceElement), + ), + `replaceWithChildrenElements should not contain ${removeElement.name}`, + ); + } + } + + // If config[attributes] exists: + if (config.attributes) { + } else { + // config[dataAttributes] does not exist. + assert_false("dataAttributes" in config, "dataAttributes does not exist"); + } +} + +function assert_config(config, expected) { + const PROPERTIES = [ + "attributes", + "removeAttributes", + "elements", + "removeElements", + "replaceWithChildrenElements", + "comments", + "dataAttributes", + ]; + + // Prevent some typos in the expected config. + for (let key of Object.keys(expected)) { + assert_in_array(key, PROPERTIES, "expected"); + } + for (let key of Object.keys(config)) { + assert_in_array(key, PROPERTIES, "config"); + } + + assert_config_is_valid(config); + + // XXX dataAttributes + // XXX comments + // XXX duplications + // XXX other consistency checks + + function assert_attrs(key, config, expected, prefix = "config") { + // XXX we allow specifying only a subset for expected. + if (!(key in expected)) { + return; + } + + if (expected[key] === undefined) { + assert_false(key in config, `Unexpected '${key}' in ${prefix}`); + return; + } + + assert_true(key in config, `Missing '${key}' from ${prefix}`); + assert_equals(config[key]?.length, expected[key].length, `${prefix}.${key}.length`); + for (let i = 0; i < expected[key].length; i++) { + let attribute = expected[key][i]; + if (typeof attribute === "string") { + assert_object_equals( + config[key][i], + { name: attribute, namespace: null }, + `${prefix}.${key}[${i}] should match`, + ); + } else { + assert_object_equals( + config[key][i], + attribute, + `${prefix}.${key}[${i}] should match`, + ); + } + } + } + + assert_attrs("attributes", config, expected); + assert_attrs("removeAttributes", config, expected); + + function assert_elems(key) { + if (!(key in expected)) { + return; + } + + if (expected[key] === undefined) { + assert_false(key in config, `Unexpected '${key}' in config`); + return; + } + + assert_true(key in config, `Missing '${key}' from config`); + assert_equals(config[key]?.length, expected[key].length, `${key}.length`); + + const XHTML_NS = "http://www.w3.org/1999/xhtml"; + + for (let i = 0; i < expected[key].length; i++) { + let element = expected[key][i]; + // To make writing tests a bit easier we also support the shorthand string syntax. + if (typeof element === "string") { + let extra = key === "elements" ? { removeAttributes: [] } : { }; + assert_object_equals( + config[key][i], + { name: element, namespace: XHTML_NS, ...extra }, + `${key}[${i}] should match`, + ); + } else { + if (key === "elements") { + assert_equals(config[key][i].name, element.name, `${key}[${i}].name should match`); + let ns = "namespace" in element ? element.namespace : XHTML_NS; + assert_equals(config[key][i].namespace, ns, `${key}[${i}].namespace should match`); + + assert_attrs("attributes", config[key][i], element, `config.elements[${i}]`); + assert_attrs("removeAttributes", config[key][i], element, `config.elements[${i}]`); + } else { + assert_object_equals(config[key][i], element, `${key}[${i}] should match`); + } + } + } + } + + assert_elems("elements"); + assert_elems("removeElements"); + assert_elems("replaceWithChildrenElements"); +}