diff --git a/src/utils.ts b/src/utils.ts index 466b252e..3d019040 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -221,15 +221,35 @@ function collectAncestorNamespaces( return collectAncestorNamespaces(parent, nsArray); } -function findNSPrefix(subset) { +/** + * Collect all namespace prefixes declared directly on a subset element. + * + * This includes every `xmlns:*` attribute on the element as well as the + * element's own namespace prefix (or `""` for the default namespace). The + * result is used by {@link findAncestorNs} to decide which ancestor namespace + * declarations are already in scope and therefore must not be hoisted onto the + * subset root during non-exclusive C14N. + * + * The previous single-return implementation (`findNSPrefix`) stopped at the + * *first* `xmlns:*` attribute, so elements that declare more than one + * namespace (e.g. `` in the default namespace) caused the + * inherited default namespace to be hoisted even though the C14N serializer + * already renders it, producing a duplicate `xmlns="…"` declaration. + */ +function findSubsetNSPrefixes(subset: Element): Set { + const prefixes = new Set(); const subsetAttributes = subset.attributes; for (let k = 0; k < subsetAttributes.length; k++) { const nodeName = subsetAttributes[k].nodeName; - if (nodeName.search(/^xmlns:?/) !== -1) { - return nodeName.replace(/^xmlns:?/, ""); + if (nodeName === "xmlns" || nodeName.startsWith("xmlns:")) { + prefixes.add(nodeName.replace(/^xmlns:?/, "")); } } - return subset.prefix || ""; + // Always include the element's own prefix (empty string for the default + // namespace) so that the C14N serializer's own rendering of that namespace + // is not duplicated by hoisting. + prefixes.add(subset.prefix || ""); + return prefixes; } function isElementSubset(docSubset: Node[]): docSubset is Element[] { @@ -267,25 +287,18 @@ export function findAncestorNs( // Remove duplicate on ancestor namespace const ancestorNs = collectAncestorNamespaces(docSubset[0]); const ancestorNsWithoutDuplicate: NamespacePrefix[] = []; - for (let i = 0; i < ancestorNs.length; i++) { - let notOnTheList = true; - for (const v in ancestorNsWithoutDuplicate) { - if (ancestorNsWithoutDuplicate[v].prefix === ancestorNs[i].prefix) { - notOnTheList = false; - break; - } - } - - if (notOnTheList) { - ancestorNsWithoutDuplicate.push(ancestorNs[i]); + for (const ns of ancestorNs) { + const isDuplicate = ancestorNsWithoutDuplicate.some((seen) => seen.prefix === ns.prefix); + if (!isDuplicate) { + ancestorNsWithoutDuplicate.push(ns); } } // Remove namespaces which are already declared in the subset with the same prefix const returningNs: NamespacePrefix[] = []; - const subsetNsPrefix = findNSPrefix(docSubset[0]); + const subsetNsPrefixes = findSubsetNSPrefixes(docSubset[0]); for (const ancestorNs of ancestorNsWithoutDuplicate) { - if (ancestorNs.prefix !== subsetNsPrefix) { + if (!subsetNsPrefixes.has(ancestorNs.prefix)) { returningNs.push(ancestorNs); } } diff --git a/test/c14n-non-exclusive-unit-tests.spec.ts b/test/c14n-non-exclusive-unit-tests.spec.ts index ee7f2ba4..c2843c40 100644 --- a/test/c14n-non-exclusive-unit-tests.spec.ts +++ b/test/c14n-non-exclusive-unit-tests.spec.ts @@ -117,6 +117,43 @@ describe("C14N non-exclusive canonicalization tests", function () { test_findAncestorNs(xml, xpath, expected); }); + it("findAncestorNs: Should not hoist default namespace when subset also declares a prefixed namespace", function () { + // child2 is in the default namespace (inherited from root) and also + // declares xmlns:enc. The default namespace must not be hoisted because + // the C14N serializer already renders it; previously findNSPrefix stopped + // at the first xmlns:* attribute ("enc") and missed the inherited "". + const xml = + ""; + const xpath = "//*[local-name()='child2']"; + const expected = []; + + test_findAncestorNs(xml, xpath, expected); + }); + + it("findAncestorNs: Should hoist non-default ancestor namespaces when subset is in default namespace", function () { + // child2 is in the default namespace and declares xmlns:enc, but its + // ancestor also declares xmlns:aaa which child2 does not redeclare. + // xmlns:aaa must still be hoisted; only the default namespace is suppressed. + const xml = + ""; + const xpath = "//*[local-name()='child2']"; + const expected = [{ prefix: "aaa", namespaceURI: "zzz" }]; + + test_findAncestorNs(xml, xpath, expected); + }); + + it("findAncestorNs: Should not suppress ancestor namespace for non-namespace attribute starting with 'xmlns'", function () { + // xmlnsfoo is an ordinary attribute, not a namespace declaration. + // findSubsetNSPrefixes must not add "foo" to the suppression set, so + // an inherited xmlns:foo declaration on an ancestor is still hoisted. + const xml = + ""; + const xpath = "//*[local-name()='child2']"; + const expected = [{ prefix: "foo", namespaceURI: "zzz" }]; + + test_findAncestorNs(xml, xpath, expected); + }); + // Tests for c14nCanonicalization it("C14n: Correctly picks up root ancestor namespace", function () { const xml = ""; @@ -210,4 +247,19 @@ describe("C14N non-exclusive canonicalization tests", function () { test_C14nCanonicalization(xml, xpath, expected); }); + + it("C14n: Should not produce duplicate default namespace when subset declares a prefixed namespace", function () { + // child2 is in the default namespace and also declares xmlns:enc. + // The C14N output must contain exactly one xmlns="bbb" declaration. + // Previously findNSPrefix returned "enc" (the first xmlns:* attribute), + // leaving the default namespace in the ancestor list; the C14N serializer + // then rendered it twice — once from the element itself and once from the + // hoisted ancestor entry. + const xml = + ""; + const xpath = "//*[local-name()='child2']"; + const expected = ''; + + test_C14nCanonicalization(xml, xpath, expected); + }); });