From f8399e62a31095bf1ced01827c33f9b29494046f Mon Sep 17 00:00:00 2001
From: Daniel Garcia Moreno <daniel.garcia@suse.com>
Date: Fri, 19 Dec 2025 12:27:54 +0100
Subject: [PATCH] testcatalog: Add new tests for catalog.c

Adds a new test program to run specific tests related to catalog
parsing.

This initial version includes a couple of tests, the first one to check
the infinite recursion detection related to:
https://gitlab.gnome.org/GNOME/libxml2/-/issues/1018.

The second one tests the nextCatalog element repeated parsing, related
to:
https://gitlab.gnome.org/GNOME/libxml2/-/issues/1019
https://gitlab.gnome.org/GNOME/libxml2/-/issues/1040

CVE: CVE-2026-0992
Upstream-Status: Backport [https://gitlab.gnome.org/GNOME/libxml2/-/commit/f8399e62a31095bf1ced01827c33f9b29494046f]
Signed-off-by: Peter Marko <peter.marko@siemens.com>
---
 CMakeLists.txt                          |  2 +
 Makefile.am                             |  8 ++-
 catalog.c                               | 63 +++++++++++-----
 include/libxml/catalog.h                |  2 +
 test/catalogs/catalog-recursive.xml     |  3 +
 test/catalogs/repeated-next-catalog.xml | 10 +++
 testcatalog.c                           | 96 +++++++++++++++++++++++++
 7 files changed, 164 insertions(+), 20 deletions(-)
 create mode 100644 test/catalogs/catalog-recursive.xml
 create mode 100644 test/catalogs/repeated-next-catalog.xml
 create mode 100644 testcatalog.c

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 163661f8..7d5702df 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -555,6 +555,7 @@ if(LIBXML2_WITH_TESTS)
 		testapi
 		testAutomata
 		testC14N
+		testcatalog
 		testchar
 		testdict
 		testHTML
@@ -579,6 +580,7 @@ if(LIBXML2_WITH_TESTS)
 	if(NOT WIN32)
 		add_test(NAME testapi COMMAND testapi)
 	endif()
+	add_test(NAME testcatalog COMMAND testcatalog)
 	add_test(NAME testchar COMMAND testchar)
 	add_test(NAME testdict COMMAND testdict)
 	add_test(NAME testrecurse COMMAND testrecurse WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
diff --git a/Makefile.am b/Makefile.am
index c51dfd8e..c794eac8 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -12,7 +12,7 @@ AM_CFLAGS = $(EXTRA_CFLAGS) $(THREAD_CFLAGS) $(Z_CFLAGS) $(LZMA_CFLAGS)
 
 check_PROGRAMS=testSchemas testRelax testSAX testHTML testXPath testURI \
                testThreads testC14N testAutomata testRegexp \
-               testReader testapi testModule runtest runsuite testchar \
+               testReader testapi testModule runtest runsuite testcatalog testchar \
 	       testdict runxmlconf testrecurse testlimits
 
 bin_PROGRAMS = xmllint xmlcatalog
@@ -81,6 +81,11 @@ testlimits_LDFLAGS =
 testlimits_DEPENDENCIES = $(DEPS)
 testlimits_LDADD= $(BASE_THREAD_LIBS) $(RDL_LIBS) $(LDADDS)
 
+testcatalog_SOURCES=testcatalog.c
+testcatalog_LDFLAGS = 
+testcatalog_DEPENDENCIES = $(DEPS)
+testcatalog_LDADD= $(LDADDS)
+
 testchar_SOURCES=testchar.c
 testchar_LDFLAGS = 
 testchar_DEPENDENCIES = $(DEPS)
@@ -213,6 +218,7 @@ runtests:
 	$(CHECKER) ./runtest$(EXEEXT) && \
 	    $(CHECKER) ./testrecurse$(EXEEXT) && \
 	    ASAN_OPTIONS="$$ASAN_OPTIONS:detect_leaks=0" $(CHECKER) ./testapi$(EXEEXT) && \
+	    $(CHECKER) ./testcatalog$(EXEEXT) \
 	    $(CHECKER) ./testchar$(EXEEXT) && \
 	    $(CHECKER) ./testdict$(EXEEXT) && \
 	    $(CHECKER) ./runxmlconf$(EXEEXT)
diff --git a/catalog.c b/catalog.c
index 401dbc14..eb889162 100644
--- a/catalog.c
+++ b/catalog.c
@@ -658,43 +658,54 @@ static void xmlDumpXMLCatalogNode(xmlCatalogEntryPtr catal, xmlNodePtr catalog,
     }
 }
 
-static int
-xmlDumpXMLCatalog(FILE *out, xmlCatalogEntryPtr catal) {
-    int ret;
-    xmlDocPtr doc;
+static xmlDocPtr
+xmlDumpXMLCatalogToDoc(xmlCatalogEntryPtr catal) {
     xmlNsPtr ns;
     xmlDtdPtr dtd;
     xmlNodePtr catalog;
-    xmlOutputBufferPtr buf;
+    xmlDocPtr doc = xmlNewDoc(NULL);
+    if (doc == NULL) {
+        return(NULL);
+    }
 
-    /*
-     * Rebuild a catalog
-     */
-    doc = xmlNewDoc(NULL);
-    if (doc == NULL)
-	return(-1);
     dtd = xmlNewDtd(doc, BAD_CAST "catalog",
-	       BAD_CAST "-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN",
-BAD_CAST "http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd");
+                    BAD_CAST "-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN",
+                    BAD_CAST "http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd");
 
     xmlAddChild((xmlNodePtr) doc, (xmlNodePtr) dtd);
 
     ns = xmlNewNs(NULL, XML_CATALOGS_NAMESPACE, NULL);
     if (ns == NULL) {
-	xmlFreeDoc(doc);
-	return(-1);
+        xmlFreeDoc(doc);
+        return(NULL);
     }
     catalog = xmlNewDocNode(doc, ns, BAD_CAST "catalog", NULL);
     if (catalog == NULL) {
-	xmlFreeNs(ns);
-	xmlFreeDoc(doc);
-	return(-1);
+        xmlFreeDoc(doc);
+        xmlFreeNs(ns);
+        return(NULL);
     }
     catalog->nsDef = ns;
     xmlAddChild((xmlNodePtr) doc, catalog);
-
     xmlDumpXMLCatalogNode(catal, catalog, doc, ns, NULL);
 
+    return(doc);
+}
+
+static int
+xmlDumpXMLCatalog(FILE *out, xmlCatalogEntryPtr catal) {
+    int ret;
+    xmlDocPtr doc;
+    xmlOutputBufferPtr buf;
+
+    /*
+     * Rebuild a catalog
+     */
+    doc = xmlDumpXMLCatalogToDoc(catal);
+    if (doc == NULL) {
+        return(-1);
+    }
+
     /*
      * reserialize it
      */
@@ -3430,6 +3441,20 @@ xmlCatalogDump(FILE *out) {
 
     xmlACatalogDump(xmlDefaultCatalog, out);
 }
+
+/**
+ * Dump all the global catalog content as a xmlDoc
+ * This function is just for testing/debugging purposes
+ *
+ * @returns  The catalog as xmlDoc or NULL if failed, it must be freed by the caller.
+ */
+xmlDocPtr
+xmlCatalogDumpDoc(void) {
+    if (!xmlCatalogInitialized)
+        xmlInitializeCatalog();
+
+    return xmlDumpXMLCatalogToDoc(xmlDefaultCatalog->xml);
+}
 #endif /* LIBXML_OUTPUT_ENABLED */
 
 /**
diff --git a/include/libxml/catalog.h b/include/libxml/catalog.h
index 88a7483c..e1bc5feb 100644
--- a/include/libxml/catalog.h
+++ b/include/libxml/catalog.h
@@ -119,6 +119,8 @@ XMLPUBFUN void XMLCALL
 #ifdef LIBXML_OUTPUT_ENABLED
 XMLPUBFUN void XMLCALL
 		xmlCatalogDump		(FILE *out);
+XMLPUBFUN xmlDocPtr
+		xmlCatalogDumpDoc	(void);
 #endif /* LIBXML_OUTPUT_ENABLED */
 XMLPUBFUN xmlChar * XMLCALL
 		xmlCatalogResolve	(const xmlChar *pubID,
diff --git a/test/catalogs/catalog-recursive.xml b/test/catalogs/catalog-recursive.xml
new file mode 100644
index 00000000..3b3d03f9
--- /dev/null
+++ b/test/catalogs/catalog-recursive.xml
@@ -0,0 +1,3 @@
+<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
+    <delegateURI uriStartString="/foo" catalog="catalog-recursive.xml"/>
+</catalog>
diff --git a/test/catalogs/repeated-next-catalog.xml b/test/catalogs/repeated-next-catalog.xml
new file mode 100644
index 00000000..76d34c3c
--- /dev/null
+++ b/test/catalogs/repeated-next-catalog.xml
@@ -0,0 +1,10 @@
+<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
+  <nextCatalog catalog="registry.xml"/>
+  <nextCatalog catalog="registry.xml"/>
+  <nextCatalog catalog="./registry.xml"/>
+  <nextCatalog catalog="././registry.xml"/>
+  <nextCatalog catalog="./././registry.xml"/>
+  <nextCatalog catalog="./../catalogs/registry.xml"/>
+  <nextCatalog catalog="./../catalogs/./registry.xml"/>
+</catalog>
+
diff --git a/testcatalog.c b/testcatalog.c
new file mode 100644
index 00000000..86d33bd0
--- /dev/null
+++ b/testcatalog.c
@@ -0,0 +1,96 @@
+/*
+ * testcatalog.c: C program to run libxml2 catalog.c unit tests
+ *
+ * To compile on Unixes:
+ * cc -o testcatalog `xml2-config --cflags` testcatalog.c `xml2-config --libs` -lpthread
+ *
+ * See Copyright for the status of this software.
+ *
+ * Author: Daniel Garcia <dani@danigm.net>
+ */
+
+
+#include "libxml.h"
+#include <stdio.h>
+
+#ifdef LIBXML_CATALOG_ENABLED
+#include <libxml/catalog.h>
+
+/* Test catalog resolve uri with recursive catalog */
+static int
+testRecursiveDelegateUri(void) {
+    int ret = 0;
+    const char *cat = "test/catalogs/catalog-recursive.xml";
+    const char *entity = "/foo.ent";
+    xmlChar *resolved = NULL;
+
+    xmlInitParser();
+    xmlLoadCatalog(cat);
+
+    /* This should trigger recursive error */
+    resolved = xmlCatalogResolveURI(BAD_CAST entity);
+    if (resolved != NULL) {
+        fprintf(stderr, "CATALOG-FAILURE: Catalog %s entity should fail to resolve\n", entity);
+        ret = 1;
+    }
+    xmlCatalogCleanup();
+
+    return ret;
+}
+
+/* Test parsing repeated NextCatalog */
+static int
+testRepeatedNextCatalog(void) {
+    int ret = 0;
+    int i = 0;
+    const char *cat = "test/catalogs/repeated-next-catalog.xml";
+    const char *entity = "/foo.ent";
+    xmlDocPtr doc = NULL;
+    xmlNodePtr node = NULL;
+
+    xmlInitParser();
+
+    xmlLoadCatalog(cat);
+    /* To force the complete recursive load */
+    xmlCatalogResolveURI(BAD_CAST entity);
+    /**
+     * Ensure that the doc doesn't contain the same nextCatalog
+     */
+    doc = xmlCatalogDumpDoc();
+    xmlCatalogCleanup();
+
+    if (doc == NULL) {
+        fprintf(stderr, "CATALOG-FAILURE: Failed to dump the catalog\n");
+        return 1;
+    }
+
+    /* Just the root "catalog" node with a series of nextCatalog */
+    node = xmlDocGetRootElement(doc);
+    node = node->children;
+    for (i=0; node != NULL; node=node->next, i++) {}
+    if (i > 1) {
+        fprintf(stderr, "CATALOG-FAILURE: Found %d nextCatalog entries and should be 1\n", i);
+        ret = 1;
+    }
+
+    xmlFreeDoc(doc);
+
+    return ret;
+}
+
+int
+main(void) {
+    int err = 0;
+
+    err |= testRecursiveDelegateUri();
+    err |= testRepeatedNextCatalog();
+
+    return err;
+}
+#else
+/* No catalog, so everything okay */
+int
+main(void) {
+    return 0;
+}
+#endif
