Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<Body xmlns:enc="…">` 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<string> {
const prefixes = new Set<string>();
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:?/, ""));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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[] {
Expand Down Expand Up @@ -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);
}
}
Expand Down
52 changes: 52 additions & 0 deletions test/c14n-non-exclusive-unit-tests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"<root xmlns='bbb'><child1><child2 xmlns:enc='ccc'></child2></child1></root>";
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 =
"<root xmlns='bbb' xmlns:aaa='zzz'><child1><child2 xmlns:enc='ccc'></child2></child1></root>";
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 =
"<root xmlns:foo='zzz'><child1><child2 xmlnsfoo='bar'></child2></child1></root>";
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 = "<root xmlns:aaa='bbb'><child1><child2></child2></child1></root>";
Expand Down Expand Up @@ -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 =
"<root xmlns='bbb'><child1><child2 xmlns:enc='ccc'></child2></child1></root>";
const xpath = "//*[local-name()='child2']";
const expected = '<child2 xmlns="bbb" xmlns:enc="ccc"></child2>';

test_C14nCanonicalization(xml, xpath, expected);
});
});