From 8e002b0cc6aff3e4af84ae511c526ae1f9c62cbb Mon Sep 17 00:00:00 2001
From: Jim Jones <jim.jones@uni-muenster.de>
Date: Tue, 8 Sep 2026 15:18:26 +0200
Subject: [PATCH v27] Add xmlcanonicalize function

This adds xmlcanonicalize(doc xml, keep_comments boolean DEFAULT true),
which transforms a well-formed XML document into its canonical form
according to the W3C Canonical XML 1.1 specification.  Canonicalization
yields a deterministic representation, so that two documents differing
only in ways the specification considers insignificant (attribute and
namespace declaration order, whitespace within tags, empty-element
syntax, CDATA sections, character and entity references) compare equal.
This is useful for comparing documents and as a basis for digital
signatures.

The keep_comments parameter controls whether comments in the input
document are preserved or discarded; it defaults to true.

The input must be a well-formed XML document; a content fragment is
rejected.  libxml2 emits the canonical form as UTF-8, as the
specification requires, but since this function returns text the result
is converted to the database encoding.  In databases that do not use
UTF-8, a document containing characters that cannot be represented in
the database encoding therefore produces an encoding error.

Canonicalization is an optional component of libxml2, and the C14N 1.1
mode used here appeared in libxml2 2.7.4, which is newer than the
minimum version PostgreSQL otherwise requires.  Both are detected from
the symbols libxml2's own headers advertise.

Author: Jim Jones <jim.jones@uni-muenster.de>
Reviewed-by: Andrew Dunstan <andrew@dunslane.net>
Reviewed-by: Tom Lane <tgl@sss.pgh.pa.us>
Reviewed-by: Pavel Stehule <pavel.stehule@gmail.com>
Reviewed-by: vignesh C <vignesh21@gmail.com>
Reviewed-by: Oliver Ford <ojford@gmail.com>
Reviewed-by: newtglobal postgresql_contributors <postgresql_contributors@newtglobalcorp.com>
Reviewed-by: Chapman Flack <chap@anastigmatix.net>
Discussion: https://www.postgresql.org/message-id/flat/67fa8560-8d61-5d06-8178-fc9c7684db90%40uni-muenster.de
---
 doc/src/sgml/func/func-xml.sgml   |  51 +++++++++++
 src/backend/utils/adt/xml.c       | 101 ++++++++++++++++++++++
 src/include/catalog/pg_proc.dat   |   4 +
 src/test/regress/expected/xml.out | 137 ++++++++++++++++++++++++++++++
 src/test/regress/sql/xml.sql      |  57 +++++++++++++
 5 files changed, 350 insertions(+)

diff --git a/doc/src/sgml/func/func-xml.sgml b/doc/src/sgml/func/func-xml.sgml
index 511bc90852a..98a39cb4599 100644
--- a/doc/src/sgml/func/func-xml.sgml
+++ b/doc/src/sgml/func/func-xml.sgml
@@ -61,6 +61,57 @@ SELECT xmltext('< foo & bar >');
     </para>
    </sect3>
 
+   <sect3 id="functions-producing-xml-xmlcanonicalize">
+    <title><literal>xmlcanonicalize</literal></title>
+
+    <indexterm>
+     <primary>xmlcanonicalize</primary>
+    </indexterm>
+
+<synopsis>
+<function>xmlcanonicalize</function> ( <parameter>doc</parameter> <type>xml</type> [, <parameter>keep_comments</parameter> <type>boolean</type> DEFAULT <literal>true</literal>] ) <returnvalue>text</returnvalue>
+</synopsis>
+
+    <para>
+     This function transforms a given XML document into its <ulink url="https://www.w3.org/TR/xml-c14n11/#Terminology">canonical form</ulink>,
+     as defined by the <ulink url="https://www.w3.org/TR/xml-c14n11/">W3C Canonical XML 1.1 Specification</ulink>, which standardizes the document's
+     structure and syntax to facilitate comparison and digital signatures.
+     The <parameter>keep_comments</parameter> parameter controls whether XML comments from the input document are preserved or discarded.
+     If omitted, it defaults to <literal>true</literal>.
+    </para>
+
+    <para>
+     The W3C specification defines the canonical form as UTF-8, but since this
+     function returns <type>text</type> the result is converted to the database
+     encoding.  In databases that do not use UTF-8 encoding, documents
+     containing characters that cannot be represented in the database encoding
+     will produce an encoding error.
+    </para>
+
+    <para>
+     This function requires the server to be built against
+     <productname>libxml2</productname> 2.7.4 or later, with Canonical XML
+     support enabled.
+    </para>
+
+    <para>
+     Example:
+<screen><![CDATA[
+SELECT xmlcanonicalize('<foo><!-- a comment --><bar c="3" b="2" a="1">42</bar><empty/></foo>'::xml);
+                               xmlcanonicalize
+-----------------------------------------------------------------------------
+ <foo><!-- a comment --><bar a="1" b="2" c="3">42</bar><empty></empty></foo>
+(1 row)
+
+SELECT xmlcanonicalize('<foo><!-- a comment --><bar c="3" b="2" a="1">42</bar><empty/></foo>'::xml, false);
+                      xmlcanonicalize
+-----------------------------------------------------------
+ <foo><bar a="1" b="2" c="3">42</bar><empty></empty></foo>
+(1 row)
+]]></screen>
+    </para>
+   </sect3>
+
    <sect3 id="functions-producing-xml-xmlcomment">
     <title><literal>xmlcomment</literal></title>
 
diff --git a/src/backend/utils/adt/xml.c b/src/backend/utils/adt/xml.c
index 1f75ffcfd9d..0192d11b96c 100644
--- a/src/backend/utils/adt/xml.c
+++ b/src/backend/utils/adt/xml.c
@@ -46,6 +46,7 @@
 #include "postgres.h"
 
 #ifdef USE_LIBXML
+#include <libxml/c14n.h>
 #include <libxml/chvalid.h>
 #include <libxml/entities.h>
 #include <libxml/parser.h>
@@ -59,6 +60,21 @@
 #include <libxml/xpath.h>
 #include <libxml/xpathInternals.h>
 
+/*
+ * Canonical XML support is an optional component of libxml2, which can be
+ * built without it, and the C14N 1.1 mode we use appeared only in libxml2
+ * 2.7.4.  Probe for both here so that xmlcanonicalize() can degrade to a clean
+ * error rather than breaking the build.
+ *
+ * Note that c14n.h is self-guarding: it includes xmlversion.h and then exposes
+ * nothing at all unless LIBXML_C14N_ENABLED is defined, so including it
+ * unconditionally above is safe, and it is what makes both symbols tested here
+ * visible.
+ */
+#if defined(LIBXML_C14N_ENABLED) && LIBXML_VERSION >= 20704
+#define PG_HAVE_XML_C14N 1
+#endif
+
 /*
  * We used to check for xmlStructuredErrorContext via a configure test; but
  * that doesn't work on Windows, so instead use this grottier method of
@@ -566,6 +582,91 @@ xmltext(PG_FUNCTION_ARGS)
 #endif							/* not USE_LIBXML */
 }
 
+/*
+ * Canonicalizes the given XML document according to the W3C Canonical XML 1.1
+ * specification, using libxml2's xmlC14NDocDumpMemory().
+ *
+ * The input XML must be a well-formed document (not a fragment). The
+ * canonical form is deterministic and useful for digital signatures and
+ * comparing logically equivalent XML.
+ *
+ * The second argument determines whether comments are preserved
+ * (true) or omitted (false) in the canonicalized output.
+ *
+ * This requires a libxml2 that was built with Canonical XML support and that
+ * is new enough to know about C14N 1.1; see PG_HAVE_XML_C14N above.
+ */
+Datum
+xmlcanonicalize(PG_FUNCTION_ARGS)
+{
+#ifdef PG_HAVE_XML_C14N
+	xmltype    *arg = PG_GETARG_XML_P(0);
+	bool		keep_comments = PG_GETARG_BOOL(1);
+	text	   *result;
+	xmlChar    *volatile xmlbuf = NULL;
+	int			nbytes = 0;
+	volatile xmlDocPtr doc = NULL;
+	PgXmlErrorContext *xmlerrcxt;
+
+	/* Set up XML error context for proper libxml2 error integration */
+	xmlerrcxt = pg_xml_init(PG_XML_STRICTNESS_ALL);
+
+	PG_TRY();
+	{
+		char	   *converted;
+
+		/* Parse the input as a full XML document */
+		doc = xml_parse(arg, XMLOPTION_DOCUMENT, true,
+						GetDatabaseEncoding(), NULL, NULL, NULL);
+
+		/* Canonicalize the entire document using C14N 1.1 */
+		nbytes = xmlC14NDocDumpMemory(doc, NULL, XML_C14N_1_1,
+									  NULL, keep_comments,
+									  (xmlChar **) &xmlbuf);
+
+		if (nbytes < 0 || xmlbuf == NULL || xmlerrcxt->err_occurred)
+			xml_ereport(xmlerrcxt, ERROR, ERRCODE_INVALID_XML_DOCUMENT,
+						"could not canonicalize XML document");
+
+		/*
+		 * C14N always produces UTF-8 output regardless of the database
+		 * encoding.  Convert to the server encoding so the result is a
+		 * valid text value.
+		 */
+		converted = pg_any_to_server((char *) xmlbuf, nbytes, PG_UTF8);
+
+		result = cstring_to_text(converted);
+		if (converted != (char *) xmlbuf)
+			pfree(converted);
+	}
+	PG_CATCH();
+	{
+		if (doc)
+			xmlFreeDoc((xmlDocPtr) doc);
+		if (xmlbuf)
+			xmlFree((xmlChar *) xmlbuf);
+
+		pg_xml_done(xmlerrcxt, true);
+		PG_RE_THROW();
+	}
+	PG_END_TRY();
+
+	xmlFreeDoc((xmlDocPtr) doc);
+	xmlFree((xmlChar *) xmlbuf);
+	pg_xml_done(xmlerrcxt, false);
+
+	PG_RETURN_TEXT_P(result);
+#elif defined(USE_LIBXML)
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("unsupported XML feature"),
+			 errdetail("This functionality requires libxml2 version 2.7.4 or later, built with Canonical XML (C14N) support.")));
+	return 0;
+#else
+	NO_XML_SUPPORT();
+	return 0;
+#endif
+}
 
 /*
  * TODO: xmlconcat needs to merge the notations and unparsed entities
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 960763ee50b..52084567f59 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -9357,6 +9357,10 @@
 { oid => '3813', descr => 'generate XML text node',
   proname => 'xmltext', prorettype => 'xml', proargtypes => 'text',
   prosrc => 'xmltext' },
+{ oid => '9447', descr => 'generate the canonical form of an XML document',
+  proname => 'xmlcanonicalize', prorettype => 'text', proargtypes => 'xml bool',
+  proargnames => '{doc,keep_comments}', proargdefaults => '{true}',
+  prosrc => 'xmlcanonicalize' },
 
 { oid => '2923', descr => 'map table contents to XML',
   proname => 'table_to_xml', procost => '100', provolatile => 's',
diff --git a/src/test/regress/expected/xml.out b/src/test/regress/expected/xml.out
index 350941f7172..79f28bf44a5 100644
--- a/src/test/regress/expected/xml.out
+++ b/src/test/regress/expected/xml.out
@@ -1902,3 +1902,140 @@ SELECT xmltext('x'|| '<P>73</P>'::xml || .42 || true || 'j'::char);
  x&lt;P&gt;73&lt;/P&gt;0.42truej
 (1 row)
 
+-- xmlcanonicalize
+CREATE TABLE xmlcanonicalize_test (doc xml);
+INSERT INTO xmlcanonicalize_test VALUES
+  ('<?xml version="1.0" encoding="ISO-8859-1"?>
+  <!DOCTYPE doc SYSTEM "doc.dtd" [
+                  <!ENTITY val "42">
+      <!ATTLIST xyz attr CDATA "default">
+  ]>
+
+  <!-- attributes and namespaces will be sorted -->
+  <foo a:attr="out" b:attr="sorted" attr2="all" attr="I am"
+      xmlns:b="http://www.ietf.org"
+      xmlns:a="http://www.w3.org"
+      xmlns="http://example.org">
+
+    <!-- Normalization of whitespace in start and end tags -->
+    <!-- Elimination of superfluous namespace declarations, as already declared in <foo> -->
+    <bar     xmlns="" xmlns:a="http://www.w3.org"     >&val;</bar     >
+
+    <!-- empty element will be converted to start-end tag pair -->
+    <empty/>
+
+    <!-- text will be transcoded to UTF-8 -->
+    <transcode>&#49;</transcode>
+
+    <!-- whitespace inside tag will be preserved -->
+    <whitespace> 321 </whitespace>
+
+    <!-- empty namespace will be removed of child tag -->
+    <emptyns  xmlns="" >
+       <emptyns_child xmlns=""></emptyns_child>
+    </emptyns>
+
+    <!-- CDATA section will be replaced by its value -->
+    <compute><![CDATA[value>"0" && value<"10" ?"valid":"error"]]></compute>
+  </foo>      <!-- comment outside root element -->          ');
+SELECT xmlcanonicalize(doc, true) FROM xmlcanonicalize_test;
+                                                                 xmlcanonicalize                                                                 
+-------------------------------------------------------------------------------------------------------------------------------------------------
+ <!-- attributes and namespaces will be sorted -->                                                                                              +
+ <foo xmlns="http://example.org" xmlns:a="http://www.w3.org" xmlns:b="http://www.ietf.org" attr="I am" attr2="all" b:attr="sorted" a:attr="out">+
+                                                                                                                                                +
+     <!-- Normalization of whitespace in start and end tags -->                                                                                 +
+     <!-- Elimination of superfluous namespace declarations, as already declared in <foo> -->                                                   +
+     <bar xmlns="">42</bar>                                                                                                                     +
+                                                                                                                                                +
+     <!-- empty element will be converted to start-end tag pair -->                                                                             +
+     <empty></empty>                                                                                                                            +
+                                                                                                                                                +
+     <!-- text will be transcoded to UTF-8 -->                                                                                                  +
+     <transcode>1</transcode>                                                                                                                   +
+                                                                                                                                                +
+     <!-- whitespace inside tag will be preserved -->                                                                                           +
+     <whitespace> 321 </whitespace>                                                                                                             +
+                                                                                                                                                +
+     <!-- empty namespace will be removed of child tag -->                                                                                      +
+     <emptyns xmlns="">                                                                                                                         +
+        <emptyns_child></emptyns_child>                                                                                                         +
+     </emptyns>                                                                                                                                 +
+                                                                                                                                                +
+     <!-- CDATA section will be replaced by its value -->                                                                                       +
+     <compute>value&gt;"0" &amp;&amp; value&lt;"10" ?"valid":"error"</compute>                                                                  +
+   </foo>                                                                                                                                       +
+ <!-- comment outside root element -->
+(1 row)
+
+SELECT xmlcanonicalize(doc, false) FROM xmlcanonicalize_test;
+                                                                 xmlcanonicalize                                                                 
+-------------------------------------------------------------------------------------------------------------------------------------------------
+ <foo xmlns="http://example.org" xmlns:a="http://www.w3.org" xmlns:b="http://www.ietf.org" attr="I am" attr2="all" b:attr="sorted" a:attr="out">+
+                                                                                                                                                +
+                                                                                                                                                +
+                                                                                                                                                +
+     <bar xmlns="">42</bar>                                                                                                                     +
+                                                                                                                                                +
+                                                                                                                                                +
+     <empty></empty>                                                                                                                            +
+                                                                                                                                                +
+                                                                                                                                                +
+     <transcode>1</transcode>                                                                                                                   +
+                                                                                                                                                +
+                                                                                                                                                +
+     <whitespace> 321 </whitespace>                                                                                                             +
+                                                                                                                                                +
+                                                                                                                                                +
+     <emptyns xmlns="">                                                                                                                         +
+        <emptyns_child></emptyns_child>                                                                                                         +
+     </emptyns>                                                                                                                                 +
+                                                                                                                                                +
+                                                                                                                                                +
+     <compute>value&gt;"0" &amp;&amp; value&lt;"10" ?"valid":"error"</compute>                                                                  +
+   </foo>
+(1 row)
+
+SELECT xmlcanonicalize(doc, true) = xmlcanonicalize(doc) FROM xmlcanonicalize_test;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT xmlcanonicalize(xmlcanonicalize(doc, true)::xml, true) = xmlcanonicalize(doc, true) FROM xmlcanonicalize_test;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT xmlcanonicalize(doc, NULL) FROM xmlcanonicalize_test;
+ xmlcanonicalize 
+-----------------
+ 
+(1 row)
+
+SELECT xmlcanonicalize(NULL, true);
+ xmlcanonicalize 
+-----------------
+ 
+(1 row)
+
+\set VERBOSITY terse
+SELECT xmlcanonicalize('', true);
+ERROR:  invalid XML document
+SELECT xmlcanonicalize('  ', true);
+ERROR:  invalid XML document
+SELECT xmlcanonicalize('foo', true);
+ERROR:  invalid XML document
+SELECT xmlcanonicalize('');
+ERROR:  invalid XML document
+SELECT xmlcanonicalize('  ');
+ERROR:  invalid XML document
+SELECT xmlcanonicalize('foo');
+ERROR:  invalid XML document
+-- C14N rejects relative namespace URIs; that is a data exception (2200M),
+-- not an internal error.
+\set VERBOSITY sqlstate
+SELECT xmlcanonicalize('<a xmlns:x="relative/uri"><x:b/></a>');
+ERROR:  2200M
+\set VERBOSITY default
diff --git a/src/test/regress/sql/xml.sql b/src/test/regress/sql/xml.sql
index ea0438aa45d..4b2149c5943 100644
--- a/src/test/regress/sql/xml.sql
+++ b/src/test/regress/sql/xml.sql
@@ -692,3 +692,60 @@ SELECT xmltext('  ');
 SELECT xmltext('foo `$_-+?=*^%!|/\()[]{}');
 SELECT xmltext('foo & <"bar">');
 SELECT xmltext('x'|| '<P>73</P>'::xml || .42 || true || 'j'::char);
+
+-- xmlcanonicalize
+CREATE TABLE xmlcanonicalize_test (doc xml);
+INSERT INTO xmlcanonicalize_test VALUES
+  ('<?xml version="1.0" encoding="ISO-8859-1"?>
+  <!DOCTYPE doc SYSTEM "doc.dtd" [
+                  <!ENTITY val "42">
+      <!ATTLIST xyz attr CDATA "default">
+  ]>
+
+  <!-- attributes and namespaces will be sorted -->
+  <foo a:attr="out" b:attr="sorted" attr2="all" attr="I am"
+      xmlns:b="http://www.ietf.org"
+      xmlns:a="http://www.w3.org"
+      xmlns="http://example.org">
+
+    <!-- Normalization of whitespace in start and end tags -->
+    <!-- Elimination of superfluous namespace declarations, as already declared in <foo> -->
+    <bar     xmlns="" xmlns:a="http://www.w3.org"     >&val;</bar     >
+
+    <!-- empty element will be converted to start-end tag pair -->
+    <empty/>
+
+    <!-- text will be transcoded to UTF-8 -->
+    <transcode>&#49;</transcode>
+
+    <!-- whitespace inside tag will be preserved -->
+    <whitespace> 321 </whitespace>
+
+    <!-- empty namespace will be removed of child tag -->
+    <emptyns  xmlns="" >
+       <emptyns_child xmlns=""></emptyns_child>
+    </emptyns>
+
+    <!-- CDATA section will be replaced by its value -->
+    <compute><![CDATA[value>"0" && value<"10" ?"valid":"error"]]></compute>
+  </foo>      <!-- comment outside root element -->          ');
+
+SELECT xmlcanonicalize(doc, true) FROM xmlcanonicalize_test;
+SELECT xmlcanonicalize(doc, false) FROM xmlcanonicalize_test;
+SELECT xmlcanonicalize(doc, true) = xmlcanonicalize(doc) FROM xmlcanonicalize_test;
+SELECT xmlcanonicalize(xmlcanonicalize(doc, true)::xml, true) = xmlcanonicalize(doc, true) FROM xmlcanonicalize_test;
+SELECT xmlcanonicalize(doc, NULL) FROM xmlcanonicalize_test;
+SELECT xmlcanonicalize(NULL, true);
+
+\set VERBOSITY terse
+SELECT xmlcanonicalize('', true);
+SELECT xmlcanonicalize('  ', true);
+SELECT xmlcanonicalize('foo', true);
+SELECT xmlcanonicalize('');
+SELECT xmlcanonicalize('  ');
+SELECT xmlcanonicalize('foo');
+-- C14N rejects relative namespace URIs; that is a data exception (2200M),
+-- not an internal error.
+\set VERBOSITY sqlstate
+SELECT xmlcanonicalize('<a xmlns:x="relative/uri"><x:b/></a>');
+\set VERBOSITY default
-- 
2.55.0

