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);
+ });
});