diff --git a/include/crm/common/xml_internal.h b/include/crm/common/xml_internal.h
index 72eae57ef5..f0c87f51d9 100644
--- a/include/crm/common/xml_internal.h
+++ b/include/crm/common/xml_internal.h
@@ -1,456 +1,453 @@
 /*
  * Copyright 2017-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_XML_INTERNAL__H
 #define PCMK__CRM_COMMON_XML_INTERNAL__H
 
 /*
  * Internal-only wrappers for and extensions to libxml2 (libxslt)
  */
 
 #include <stdlib.h>
 #include <stdint.h>   // uint32_t
 #include <stdio.h>
 
 #include <crm/crm.h>  /* transitively imports qblog.h */
 #include <crm/common/output_internal.h>
 #include <crm/common/xml_names.h>             // PCMK_XA_ID, PCMK_XE_CLONE
 
 // This file is a wrapper for other xml_*_internal.h headers
 #include <crm/common/xml_comment_internal.h>
 #include <crm/common/xml_element_internal.h>
 #include <crm/common/xml_idref_internal.h>
 #include <crm/common/xml_io_internal.h>
 #include <crm/common/xml_names_internal.h>
 
 #include <libxml/relaxng.h>
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /*!
  * \brief Base for directing lib{xml2,xslt} log into standard libqb backend
  *
  * This macro implements the core of what can be needed for directing
  * libxml2 or libxslt error messaging into standard, preconfigured
  * libqb-backed log stream.
  *
  * It's a bit unfortunate that libxml2 (and more sparsely, also libxslt)
  * emits a single message by chunks (location is emitted separatedly from
  * the message itself), so we have to take the effort to combine these
  * chunks back to single message.  Whether to do this or not is driven
  * with \p dechunk toggle.
  *
  * The form of a macro was chosen for implicit deriving of __FILE__, etc.
  * and also because static dechunking buffer should be differentiated per
  * library (here we assume different functions referring to this macro
  * will not ever be using both at once), preferably also per-library
  * context of use to avoid clashes altogether.
  *
  * Note that we cannot use qb_logt, because callsite data have to be known
  * at the moment of compilation, which it is not always the case -- xml_log
  * (and unfortunately there's no clear explanation of the fail to compile).
  *
  * Also note that there's no explicit guard against said libraries producing
  * never-newline-terminated chunks (which would just keep consuming memory),
  * as it's quite improbable.  Termination of the program in between the
  * same-message chunks will raise a flag with valgrind and the likes, though.
  *
  * And lastly, regarding how dechunking combines with other non-message
  * parameters -- for \p priority, most important running specification
  * wins (possibly elevated to LOG_ERR in case of nonconformance with the
  * newline-termination "protocol"), \p dechunk is expected to always be
  * on once it was at the start, and the rest (\p postemit and \p prefix)
  * are picked directly from the last chunk entry finalizing the message
  * (also reasonable to always have it the same with all related entries).
  *
  * \param[in] priority Syslog priority for the message to be logged
  * \param[in] dechunk  Whether to dechunk new-line terminated message
  * \param[in] postemit Code to be executed once message is sent out
  * \param[in] prefix   How to prefix the message or NULL for raw passing
  * \param[in] fmt      Format string as with printf-like functions
  * \param[in] ap       Variable argument list to supplement \p fmt format string
  */
 #define PCMK__XML_LOG_BASE(priority, dechunk, postemit, prefix, fmt, ap)        \
 do {                                                                            \
     if (!(dechunk) && (prefix) == NULL) {  /* quick pass */                     \
         qb_log_from_external_source_va(__func__, __FILE__, (fmt),               \
                                        (priority), __LINE__, 0, (ap));          \
         (void) (postemit);                                                      \
     } else {                                                                    \
         int CXLB_len = 0;                                                       \
         char *CXLB_buf = NULL;                                                  \
         static int CXLB_buffer_len = 0;                                         \
         static char *CXLB_buffer = NULL;                                        \
         static uint8_t CXLB_priority = 0;                                       \
                                                                                 \
         CXLB_len = vasprintf(&CXLB_buf, (fmt), (ap));                           \
                                                                                 \
         if (CXLB_len <= 0 || CXLB_buf[CXLB_len - 1] == '\n' || !(dechunk)) {    \
             if (CXLB_len < 0) {                                                 \
                 CXLB_buf = (char *) "LOG CORRUPTION HAZARD"; /*we don't modify*/\
                 CXLB_priority = QB_MIN(CXLB_priority, LOG_ERR);                 \
             } else if (CXLB_len > 0 /* && (dechunk) */                          \
                        && CXLB_buf[CXLB_len - 1] == '\n') {                     \
                 CXLB_buf[CXLB_len - 1] = '\0';                                  \
             }                                                                   \
             if (CXLB_buffer) {                                                  \
                 qb_log_from_external_source(__func__, __FILE__, "%s%s%s",       \
                                             CXLB_priority, __LINE__, 0,         \
                                             (prefix) != NULL ? (prefix) : "",   \
                                             CXLB_buffer, CXLB_buf);             \
                 free(CXLB_buffer);                                              \
             } else {                                                            \
                 qb_log_from_external_source(__func__, __FILE__, "%s%s",         \
                                             (priority), __LINE__, 0,            \
                                             (prefix) != NULL ? (prefix) : "",   \
                                             CXLB_buf);                          \
             }                                                                   \
             if (CXLB_len < 0) {                                                 \
                 CXLB_buf = NULL;  /* restore temporary override */              \
             }                                                                   \
             CXLB_buffer = NULL;                                                 \
             CXLB_buffer_len = 0;                                                \
             (void) (postemit);                                                  \
                                                                                 \
         } else if (CXLB_buffer == NULL) {                                       \
             CXLB_buffer_len = CXLB_len;                                         \
             CXLB_buffer = CXLB_buf;                                             \
             CXLB_buf = NULL;                                                    \
             CXLB_priority = (priority);  /* remember as a running severest */   \
                                                                                 \
         } else {                                                                \
             CXLB_buffer = realloc(CXLB_buffer, 1 + CXLB_buffer_len + CXLB_len); \
             memcpy(CXLB_buffer + CXLB_buffer_len, CXLB_buf, CXLB_len);          \
             CXLB_buffer_len += CXLB_len;                                        \
             CXLB_buffer[CXLB_buffer_len] = '\0';                                \
             CXLB_priority = QB_MIN(CXLB_priority, (priority));  /* severest? */ \
         }                                                                       \
         free(CXLB_buf);                                                         \
     }                                                                           \
 } while (0)
 
 /*
  * \enum pcmk__xml_fmt_options
  * \brief Bit flags to control format in XML logs and dumps
  */
 enum pcmk__xml_fmt_options {
     //! Exclude certain XML attributes (for calculating digests)
     pcmk__xml_fmt_filtered   = (1 << 0),
 
     //! Include indentation and newlines
     pcmk__xml_fmt_pretty     = (1 << 1),
 
     //! Include the opening tag of an XML element, and include XML comments
     pcmk__xml_fmt_open       = (1 << 3),
 
     //! Include the children of an XML element
     pcmk__xml_fmt_children   = (1 << 4),
 
     //! Include the closing tag of an XML element
     pcmk__xml_fmt_close      = (1 << 5),
 
     // @COMPAT Can we start including text nodes unconditionally?
     //! Include XML text nodes
     pcmk__xml_fmt_text       = (1 << 6),
 };
 
-void pcmk__xml_init(void);
-void pcmk__xml_cleanup(void);
-
 int pcmk__xml_show(pcmk__output_t *out, const char *prefix, const xmlNode *data,
                    int depth, uint32_t options);
 int pcmk__xml_show_changes(pcmk__output_t *out, const xmlNode *xml);
 
 /* XML search strings for guest, remote and pacemaker_remote nodes */
 
 /* search string to find CIB resources entries for cluster nodes */
 #define PCMK__XP_MEMBER_NODE_CONFIG                                 \
     "//" PCMK_XE_CIB "/" PCMK_XE_CONFIGURATION "/" PCMK_XE_NODES    \
     "/" PCMK_XE_NODE                                                \
     "[not(@" PCMK_XA_TYPE ") or @" PCMK_XA_TYPE "='" PCMK_VALUE_MEMBER "']"
 
 /* search string to find CIB resources entries for guest nodes */
 #define PCMK__XP_GUEST_NODE_CONFIG \
     "//" PCMK_XE_CIB "//" PCMK_XE_CONFIGURATION "//" PCMK_XE_PRIMITIVE  \
     "//" PCMK_XE_META_ATTRIBUTES "//" PCMK_XE_NVPAIR                    \
     "[@" PCMK_XA_NAME "='" PCMK_META_REMOTE_NODE "']"
 
 /* search string to find CIB resources entries for remote nodes */
 #define PCMK__XP_REMOTE_NODE_CONFIG                                     \
     "//" PCMK_XE_CIB "//" PCMK_XE_CONFIGURATION "//" PCMK_XE_PRIMITIVE  \
     "[@" PCMK_XA_TYPE "='" PCMK_VALUE_REMOTE "']"                       \
     "[@" PCMK_XA_PROVIDER "='pacemaker']"
 
 /* search string to find CIB node status entries for pacemaker_remote nodes */
 #define PCMK__XP_REMOTE_NODE_STATUS                                 \
     "//" PCMK_XE_CIB "//" PCMK_XE_STATUS "//" PCMK__XE_NODE_STATE   \
     "[@" PCMK_XA_REMOTE_NODE "='" PCMK_VALUE_TRUE "']"
 
 enum pcmk__xml_artefact_ns {
     pcmk__xml_artefact_ns_legacy_rng = 1,
     pcmk__xml_artefact_ns_legacy_xslt,
     pcmk__xml_artefact_ns_base_rng,
     pcmk__xml_artefact_ns_base_xslt,
 };
 
 void pcmk__strip_xml_text(xmlNode *xml);
 
 GString *pcmk__element_xpath(const xmlNode *xml);
 
 /*!
  * \internal
  * \enum pcmk__xml_escape_type
  * \brief Indicators of which XML characters to escape
  *
  * XML allows the escaping of special characters by replacing them with entity
  * references (for example, <tt>"&quot;"</tt>) or character references (for
  * example, <tt>"&#13;"</tt>).
  *
  * The special characters <tt>'&'</tt> (except as the beginning of an entity
  * reference) and <tt>'<'</tt> are not allowed in their literal forms in XML
  * character data. Character data is non-markup text (for example, the content
  * of a text node). <tt>'>'</tt> is allowed under most circumstances; we escape
  * it for safety and symmetry.
  *
  * For more details, see the "Character Data and Markup" section of the XML
  * spec, currently section 2.4:
  * https://www.w3.org/TR/xml/#dt-markup
  *
  * Attribute values are handled specially.
  * * If an attribute value is delimited by single quotes, then single quotes
  *   must be escaped within the value.
  * * Similarly, if an attribute value is delimited by double quotes, then double
  *   quotes must be escaped within the value.
  * * A conformant XML processor replaces a literal whitespace character (tab,
  *   newline, carriage return, space) in an attribute value with a space
  *   (\c '#x20') character. However, a reference to a whitespace character (for
  *   example, \c "&#x0A;" for \c '\n') does not get replaced.
  *   * For more details, see the "Attribute-Value Normalization" section of the
  *     XML spec, currently section 3.3.3. Note that the default attribute type
  *     is CDATA; we don't deal with NMTOKENS, etc.:
  *     https://www.w3.org/TR/xml/#AVNormalize
  *
  * Pacemaker always delimits attribute values with double quotes, so there's no
  * need to escape single quotes.
  *
  * Newlines and tabs should be escaped in attribute values when XML is
  * serialized to text, so that future parsing preserves them rather than
  * normalizing them to spaces.
  *
  * We always escape carriage returns, so that they're not converted to spaces
  * during attribute-value normalization and because displaying them as literals
  * is messy.
  */
 enum pcmk__xml_escape_type {
     /*!
      * For text nodes.
      * * Escape \c '<', \c '>', and \c '&' using entity references.
      * * Do not escape \c '\n' and \c '\t'.
      * * Escape other non-printing characters using character references.
      */
     pcmk__xml_escape_text,
 
     /*!
      * For attribute values.
      * * Escape \c '<', \c '>', \c '&', and \c '"' using entity references.
      * * Escape \c '\n', \c '\t', and other non-printing characters using
      *   character references.
      */
     pcmk__xml_escape_attr,
 
     /* @COMPAT Drop escaping of at least '\n' and '\t' for
      * pcmk__xml_escape_attr_pretty when openstack-info, openstack-floating-ip,
      * and openstack-virtual-ip resource agents no longer depend on it.
      *
      * At time of writing, openstack-info may set a multiline value for the
      * openstack_ports node attribute. The other two agents query the value and
      * require it to be on one line with no spaces.
      */
     /*!
      * For attribute values displayed in text output delimited by double quotes.
      * * Escape \c '\n' as \c "\\n"
      * * Escape \c '\r' as \c "\\r"
      * * Escape \c '\t' as \c "\\t"
      * * Escape \c '"' as \c "\\""
      */
     pcmk__xml_escape_attr_pretty,
 };
 
 bool pcmk__xml_needs_escape(const char *text, enum pcmk__xml_escape_type type);
 char *pcmk__xml_escape(const char *text, enum pcmk__xml_escape_type type);
 
 /*!
  * \internal
  * \brief Get the root directory to scan XML artefacts of given kind for
  *
  * \param[in] ns governs the hierarchy nesting against the inherent root dir
  *
  * \return root directory to scan XML artefacts of given kind for
  */
 char *
 pcmk__xml_artefact_root(enum pcmk__xml_artefact_ns ns);
 
 /*!
  * \internal
  * \brief Get the fully unwrapped path to particular XML artifact (RNG/XSLT)
  *
  * \param[in] ns       denotes path forming details (parent dir, suffix)
  * \param[in] filespec symbolic file specification to be combined with
  *                     #artefact_ns to form the final path
  * \return unwrapped path to particular XML artifact (RNG/XSLT)
  */
 char *pcmk__xml_artefact_path(enum pcmk__xml_artefact_ns ns,
                               const char *filespec);
 
 /*!
  * \internal
  * \brief Return first non-text child node of an XML node
  *
  * \param[in] parent  XML node to check
  *
  * \return First non-text child node of \p parent (or NULL if none)
  */
 static inline xmlNode *
 pcmk__xml_first_child(const xmlNode *parent)
 {
     xmlNode *child = (parent? parent->children : NULL);
 
     while (child && (child->type == XML_TEXT_NODE)) {
         child = child->next;
     }
     return child;
 }
 
 /*!
  * \internal
  * \brief Return next non-text sibling node of an XML node
  *
  * \param[in] child  XML node to check
  *
  * \return Next non-text sibling of \p child (or NULL if none)
  */
 static inline xmlNode *
 pcmk__xml_next(const xmlNode *child)
 {
     xmlNode *next = (child? child->next : NULL);
 
     while (next && (next->type == XML_TEXT_NODE)) {
         next = next->next;
     }
     return next;
 }
 
 void pcmk__xml_free(xmlNode *xml);
 void pcmk__xml_free_doc(xmlDoc *doc);
 xmlNode *pcmk__xml_copy(xmlNode *parent, xmlNode *src);
 
 /*!
  * \internal
  * \enum pcmk__xa_flags
  * \brief Flags for operations affecting XML attributes
  */
 enum pcmk__xa_flags {
     //! Flag has no effect
     pcmk__xaf_none          = 0U,
 
     //! Don't overwrite existing values
     pcmk__xaf_no_overwrite  = (1U << 0),
 
     /*!
      * Treat values as score updates where possible (see
      * \c pcmk__xe_set_score())
      */
     pcmk__xaf_score_update  = (1U << 1),
 };
 
 void pcmk__xml_sanitize_id(char *id);
 
 /*!
  * \internal
  * \brief Extract the ID attribute from an XML element
  *
  * \param[in] xpath String to search
  * \param[in] node  Node to get the ID for
  *
  * \return ID attribute of \p node in xpath string \p xpath
  */
 char *
 pcmk__xpath_node_id(const char *xpath, const char *node);
 
 /*!
  * \internal
  * \brief Print an informational message if an xpath query returned multiple
  *        items with the same ID.
  *
  * \param[in,out] out       The output object
  * \param[in]     search    The xpath search result, most typically the result of
  *                          calling cib->cmds->query().
  * \param[in]     name      The name searched for
  */
 void
 pcmk__warn_multiple_name_matches(pcmk__output_t *out, xmlNode *search,
                                  const char *name);
 
 /* internal XML-related utilities */
 
 enum xml_private_flags {
      pcmk__xf_none        = 0x0000,
      pcmk__xf_dirty       = 0x0001,
      pcmk__xf_deleted     = 0x0002,
      pcmk__xf_created     = 0x0004,
      pcmk__xf_modified    = 0x0008,
 
      pcmk__xf_tracking    = 0x0010,
      pcmk__xf_processed   = 0x0020,
      pcmk__xf_skip        = 0x0040,
      pcmk__xf_moved       = 0x0080,
 
      pcmk__xf_acl_enabled = 0x0100,
      pcmk__xf_acl_read    = 0x0200,
      pcmk__xf_acl_write   = 0x0400,
      pcmk__xf_acl_deny    = 0x0800,
 
      pcmk__xf_acl_create  = 0x1000,
      pcmk__xf_acl_denied  = 0x2000,
      pcmk__xf_lazy        = 0x4000,
 };
 
 void pcmk__set_xml_doc_flag(xmlNode *xml, enum xml_private_flags flag);
 
 bool pcmk__xml_tree_foreach(xmlNode *xml, bool (*fn)(xmlNode *, void *),
                             void *user_data);
 
 static inline const char *
 pcmk__xml_attr_value(const xmlAttr *attr)
 {
     return ((attr == NULL) || (attr->children == NULL))? NULL
            : (const char *) attr->children->content;
 }
 
 /*!
  * \internal
  * \brief Check whether a given CIB element was modified in a CIB patchset
  *
  * \param[in] patchset  CIB XML patchset
  * \param[in] element   XML tag of CIB element to check (\c NULL is equivalent
  *                      to \c PCMK_XE_CIB). Supported values include any CIB
  *                      element supported by \c pcmk__cib_abs_xpath_for().
  *
  * \return \c true if \p element was modified, or \c false otherwise
  */
 bool pcmk__cib_element_in_patchset(const xmlNode *patchset,
                                    const char *element);
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif // PCMK__CRM_COMMON_XML_INTERNAL__H
diff --git a/lib/common/logging.c b/lib/common/logging.c
index 022a4770be..7ba407721e 100644
--- a/lib/common/logging.c
+++ b/lib/common/logging.c
@@ -1,1295 +1,1299 @@
 /*
  * Copyright 2004-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <sys/param.h>
 #include <sys/types.h>
 #include <sys/wait.h>
 #include <sys/stat.h>
 #include <sys/utsname.h>
 
 #include <stdio.h>
 #include <unistd.h>
 #include <string.h>
 #include <stdlib.h>
 #include <limits.h>
 #include <ctype.h>
 #include <pwd.h>
 #include <grp.h>
 #include <time.h>
 #include <libgen.h>
 #include <signal.h>
 #include <bzlib.h>
 
 #include <qb/qbdefs.h>
 
 #include <crm/crm.h>
 #include <crm/common/mainloop.h>
 
 // Use high-resolution (millisecond) timestamps if libqb supports them
 #ifdef QB_FEATURE_LOG_HIRES_TIMESTAMPS
 #define TIMESTAMP_FORMAT_SPEC "%%T"
 typedef struct timespec *log_time_t;
 #else
 #define TIMESTAMP_FORMAT_SPEC "%%t"
 typedef time_t log_time_t;
 #endif
 
 unsigned int crm_log_level = LOG_INFO;
 unsigned int crm_trace_nonlog = 0;
 bool pcmk__is_daemon = false;
 
 static unsigned int crm_log_priority = LOG_NOTICE;
 static guint pcmk__log_id = 0;
 static guint pcmk__glib_log_id = 0;
 static guint pcmk__gio_log_id = 0;
 static guint pcmk__gmodule_log_id = 0;
 static guint pcmk__gthread_log_id = 0;
 static pcmk__output_t *logger_out = NULL;
 
 pcmk__config_error_func pcmk__config_error_handler = NULL;
 pcmk__config_warning_func pcmk__config_warning_handler = NULL;
 void *pcmk__config_error_context = NULL;
 void *pcmk__config_warning_context = NULL;
 
 static gboolean crm_tracing_enabled(void);
 
 static void
 crm_glib_handler(const gchar * log_domain, GLogLevelFlags flags, const gchar * message,
                  gpointer user_data)
 {
     int log_level = LOG_WARNING;
     GLogLevelFlags msg_level = (flags & G_LOG_LEVEL_MASK);
     static struct qb_log_callsite *glib_cs = NULL;
 
     if (glib_cs == NULL) {
         glib_cs = qb_log_callsite_get(__func__, __FILE__, "glib-handler",
                                       LOG_DEBUG, __LINE__, crm_trace_nonlog);
     }
 
     switch (msg_level) {
         case G_LOG_LEVEL_CRITICAL:
             log_level = LOG_CRIT;
 
             if (!crm_is_callsite_active(glib_cs, LOG_DEBUG, crm_trace_nonlog)) {
                 /* log and record how we got here */
                 crm_abort(__FILE__, __func__, __LINE__, message, TRUE, TRUE);
             }
             break;
 
         case G_LOG_LEVEL_ERROR:
             log_level = LOG_ERR;
             break;
         case G_LOG_LEVEL_MESSAGE:
             log_level = LOG_NOTICE;
             break;
         case G_LOG_LEVEL_INFO:
             log_level = LOG_INFO;
             break;
         case G_LOG_LEVEL_DEBUG:
             log_level = LOG_DEBUG;
             break;
 
         case G_LOG_LEVEL_WARNING:
         case G_LOG_FLAG_RECURSION:
         case G_LOG_FLAG_FATAL:
         case G_LOG_LEVEL_MASK:
             log_level = LOG_WARNING;
             break;
     }
 
     do_crm_log(log_level, "%s: %s", log_domain, message);
 }
 
 #ifndef NAME_MAX
 #  define NAME_MAX 256
 #endif
 
 /*!
  * \internal
  * \brief Write out a blackbox (enabling blackboxes if needed)
  *
  * \param[in] nsig  Signal number that was received
  *
  * \note This is a true signal handler, and so must be async-safe.
  */
 static void
 crm_trigger_blackbox(int nsig)
 {
     if(nsig == SIGTRAP) {
         /* Turn it on if it wasn't already */
         crm_enable_blackbox(nsig);
     }
     crm_write_blackbox(nsig, NULL);
 }
 
 void
 crm_log_deinit(void)
 {
     if (pcmk__log_id == 0) {
         return;
     }
 
     g_log_remove_handler(G_LOG_DOMAIN, pcmk__log_id);
     pcmk__log_id = 0;
     g_log_remove_handler("GLib", pcmk__glib_log_id);
     pcmk__glib_log_id = 0;
     g_log_remove_handler("GLib-GIO", pcmk__gio_log_id);
     pcmk__gio_log_id = 0;
     g_log_remove_handler("GModule", pcmk__gmodule_log_id);
     pcmk__gmodule_log_id = 0;
     g_log_remove_handler("GThread", pcmk__gthread_log_id);
     pcmk__gthread_log_id = 0;
 }
 
 #define FMT_MAX 256
 
 /*!
  * \internal
  * \brief Set the log format string based on the passed-in method
  *
  * \param[in] method        The detail level of the log output
  * \param[in] daemon        The daemon ID included in error messages
  * \param[in] use_pid       Cached result of getpid() call, for efficiency
  * \param[in] use_nodename  Cached result of uname() call, for efficiency
  *
  */
 
 /* XXX __attribute__((nonnull)) for use_nodename parameter */
 static void
 set_format_string(int method, const char *daemon, pid_t use_pid,
                   const char *use_nodename)
 {
     if (method == QB_LOG_SYSLOG) {
         // The system log gets a simplified, user-friendly format
         qb_log_ctl(method, QB_LOG_CONF_EXTENDED, QB_FALSE);
         qb_log_format_set(method, "%g %p: %b");
 
     } else {
         // Everything else gets more detail, for advanced troubleshooting
 
         int offset = 0;
         char fmt[FMT_MAX];
 
         if (method > QB_LOG_STDERR) {
             // If logging to file, prefix with timestamp, node name, daemon ID
             offset += snprintf(fmt + offset, FMT_MAX - offset,
                                TIMESTAMP_FORMAT_SPEC " %s %-20s[%lu] ",
                                 use_nodename, daemon, (unsigned long) use_pid);
         }
 
         // Add function name (in parentheses)
         offset += snprintf(fmt + offset, FMT_MAX - offset, "(%%n");
         if (crm_tracing_enabled()) {
             // When tracing, add file and line number
             offset += snprintf(fmt + offset, FMT_MAX - offset, "@%%f:%%l");
         }
         offset += snprintf(fmt + offset, FMT_MAX - offset, ")");
 
         // Add tag (if any), severity, and actual message
         offset += snprintf(fmt + offset, FMT_MAX - offset, " %%g\t%%p: %%b");
 
         CRM_LOG_ASSERT(offset > 0);
         qb_log_format_set(method, fmt);
     }
 }
 
 #define DEFAULT_LOG_FILE CRM_LOG_DIR "/pacemaker.log"
 
 static bool
 logfile_disabled(const char *filename)
 {
     return pcmk__str_eq(filename, PCMK_VALUE_NONE, pcmk__str_casei)
            || pcmk__str_eq(filename, "/dev/null", pcmk__str_none);
 }
 
 /*!
  * \internal
  * \brief Fix log file ownership if group is wrong or doesn't have access
  *
  * \param[in] filename  Log file name (for logging only)
  * \param[in] logfd     Log file descriptor
  *
  * \return Standard Pacemaker return code
  */
 static int
 chown_logfile(const char *filename, int logfd)
 {
     uid_t pcmk_uid = 0;
     gid_t pcmk_gid = 0;
     struct stat st;
     int rc;
 
     // Get the log file's current ownership and permissions
     if (fstat(logfd, &st) < 0) {
         return errno;
     }
 
     // Any other errors don't prevent file from being used as log
 
     rc = pcmk_daemon_user(&pcmk_uid, &pcmk_gid);
     if (rc != pcmk_ok) {
         rc = pcmk_legacy2rc(rc);
         crm_warn("Not changing '%s' ownership because user information "
                  "unavailable: %s", filename, pcmk_rc_str(rc));
         return pcmk_rc_ok;
     }
     if ((st.st_gid == pcmk_gid)
         && ((st.st_mode & S_IRWXG) == (S_IRGRP|S_IWGRP))) {
         return pcmk_rc_ok;
     }
     if (fchown(logfd, pcmk_uid, pcmk_gid) < 0) {
         crm_warn("Couldn't change '%s' ownership to user %s gid %d: %s",
              filename, CRM_DAEMON_USER, pcmk_gid, strerror(errno));
     }
     return pcmk_rc_ok;
 }
 
 // Reset log file permissions (using environment variable if set)
 static void
 chmod_logfile(const char *filename, int logfd)
 {
     const char *modestr = pcmk__env_option(PCMK__ENV_LOGFILE_MODE);
     mode_t filemode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP;
 
     if (modestr != NULL) {
         long filemode_l = strtol(modestr, NULL, 8);
 
         if ((filemode_l != LONG_MIN) && (filemode_l != LONG_MAX)) {
             filemode = (mode_t) filemode_l;
         }
     }
     if ((filemode != 0) && (fchmod(logfd, filemode) < 0)) {
         crm_warn("Couldn't change '%s' mode to %04o: %s",
                  filename, filemode, strerror(errno));
     }
 }
 
 // If we're root, correct a log file's permissions if needed
 static int
 set_logfile_permissions(const char *filename, FILE *logfile)
 {
     if (geteuid() == 0) {
         int logfd = fileno(logfile);
         int rc = chown_logfile(filename, logfd);
 
         if (rc != pcmk_rc_ok) {
             return rc;
         }
         chmod_logfile(filename, logfd);
     }
     return pcmk_rc_ok;
 }
 
 // Enable libqb logging to a new log file
 static void
 enable_logfile(int fd)
 {
     qb_log_ctl(fd, QB_LOG_CONF_ENABLED, QB_TRUE);
 #if 0
     qb_log_ctl(fd, QB_LOG_CONF_FILE_SYNC, 1); // Turn on synchronous writes
 #endif
 
 #ifdef HAVE_qb_log_conf_QB_LOG_CONF_MAX_LINE_LEN
     // Longer than default, for logging long XML lines
     qb_log_ctl(fd, QB_LOG_CONF_MAX_LINE_LEN, 800);
 #endif
 
     crm_update_callsites();
 }
 
 static inline void
 disable_logfile(int fd)
 {
     qb_log_ctl(fd, QB_LOG_CONF_ENABLED, QB_FALSE);
 }
 
 static void
 setenv_logfile(const char *filename)
 {
     // Some resource agents will log only if environment variable is set
     if (pcmk__env_option(PCMK__ENV_LOGFILE) == NULL) {
         pcmk__set_env_option(PCMK__ENV_LOGFILE, filename, true);
     }
 }
 
 /*!
  * \brief Add a file to be used as a Pacemaker detail log
  *
  * \param[in] filename  Name of log file to use
  *
  * \return Standard Pacemaker return code
  */
 int
 pcmk__add_logfile(const char *filename)
 {
     /* No log messages from this function will be logged to the new log!
      * If another target such as syslog has already been added, the messages
      * should show up there.
      */
 
     int fd = 0;
     int rc = pcmk_rc_ok;
     FILE *logfile = NULL;
     bool is_default = false;
 
     static int default_fd = -1;
     static bool have_logfile = false;
 
     // Use default if caller didn't specify (and we don't already have one)
     if (filename == NULL) {
         if (have_logfile) {
             return pcmk_rc_ok;
         }
         filename = DEFAULT_LOG_FILE;
     }
 
     // If the user doesn't want logging, we're done
     if (logfile_disabled(filename)) {
         return pcmk_rc_ok;
     }
 
     // If the caller wants the default and we already have it, we're done
     is_default = pcmk__str_eq(filename, DEFAULT_LOG_FILE, pcmk__str_none);
     if (is_default && (default_fd >= 0)) {
         return pcmk_rc_ok;
     }
 
     // Check whether we have write access to the file
     logfile = fopen(filename, "a");
     if (logfile == NULL) {
         rc = errno;
         crm_warn("Logging to '%s' is disabled: %s " QB_XS " uid=%u gid=%u",
                  filename, strerror(rc), geteuid(), getegid());
         return rc;
     }
 
     rc = set_logfile_permissions(filename, logfile);
     if (rc != pcmk_rc_ok) {
         crm_warn("Logging to '%s' is disabled: %s " QB_XS " permissions",
                  filename, strerror(rc));
         fclose(logfile);
         return rc;
     }
 
     // Close and reopen as libqb logging target
     fclose(logfile);
     fd = qb_log_file_open(filename);
     if (fd < 0) {
         crm_warn("Logging to '%s' is disabled: %s " QB_XS " qb_log_file_open",
                  filename, strerror(-fd));
         return -fd; // == +errno
     }
 
     if (is_default) {
         default_fd = fd;
         setenv_logfile(filename);
 
     } else if (default_fd >= 0) {
         crm_notice("Switching logging to %s", filename);
         disable_logfile(default_fd);
     }
 
     crm_notice("Additional logging available in %s", filename);
     enable_logfile(fd);
     have_logfile = true;
     return pcmk_rc_ok;
 }
 
 /*!
  * \brief Add multiple additional log files
  *
  * \param[in] log_files  Array of log files to add
  * \param[in] out        Output object to use for error reporting
  *
  * \return Standard Pacemaker return code
  */
 void
 pcmk__add_logfiles(gchar **log_files, pcmk__output_t *out)
 {
     if (log_files == NULL) {
         return;
     }
 
     for (gchar **fname = log_files; *fname != NULL; fname++) {
         int rc = pcmk__add_logfile(*fname);
 
         if (rc != pcmk_rc_ok) {
             out->err(out, "Logging to %s is disabled: %s",
                      *fname, pcmk_rc_str(rc));
         }
     }
 }
 
 static int blackbox_trigger = 0;
 static volatile char *blackbox_file_prefix = NULL;
 
 static void
 blackbox_logger(int32_t t, struct qb_log_callsite *cs, log_time_t timestamp,
                 const char *msg)
 {
     if(cs && cs->priority < LOG_ERR) {
         crm_write_blackbox(SIGTRAP, cs); /* Bypass the over-dumping logic */
     } else {
         crm_write_blackbox(0, cs);
     }
 }
 
 static void
 crm_control_blackbox(int nsig, bool enable)
 {
     int lpc = 0;
 
     if (blackbox_file_prefix == NULL) {
         pid_t pid = getpid();
 
         blackbox_file_prefix = crm_strdup_printf("%s/%s-%lu",
                                                  CRM_BLACKBOX_DIR,
                                                  crm_system_name,
                                                  (unsigned long) pid);
     }
 
     if (enable && qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_STATE_GET, 0) != QB_LOG_STATE_ENABLED) {
         qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_SIZE, 5 * 1024 * 1024); /* Any size change drops existing entries */
         qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_ENABLED, QB_TRUE);      /* Setting the size seems to disable it */
 
         /* Enable synchronous logging */
         for (lpc = QB_LOG_BLACKBOX; lpc < QB_LOG_TARGET_MAX; lpc++) {
             qb_log_ctl(lpc, QB_LOG_CONF_FILE_SYNC, QB_TRUE);
         }
 
         crm_notice("Initiated blackbox recorder: %s", blackbox_file_prefix);
 
         /* Save to disk on abnormal termination */
         crm_signal_handler(SIGSEGV, crm_trigger_blackbox);
         crm_signal_handler(SIGABRT, crm_trigger_blackbox);
         crm_signal_handler(SIGILL,  crm_trigger_blackbox);
         crm_signal_handler(SIGBUS,  crm_trigger_blackbox);
         crm_signal_handler(SIGFPE,  crm_trigger_blackbox);
 
         crm_update_callsites();
 
         blackbox_trigger = qb_log_custom_open(blackbox_logger, NULL, NULL, NULL);
         qb_log_ctl(blackbox_trigger, QB_LOG_CONF_ENABLED, QB_TRUE);
         crm_trace("Trigger: %d is %d %d", blackbox_trigger,
                   qb_log_ctl(blackbox_trigger, QB_LOG_CONF_STATE_GET, 0), QB_LOG_STATE_ENABLED);
 
         crm_update_callsites();
 
     } else if (!enable && qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_STATE_GET, 0) == QB_LOG_STATE_ENABLED) {
         qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_ENABLED, QB_FALSE);
 
         /* Disable synchronous logging again when the blackbox is disabled */
         for (lpc = QB_LOG_BLACKBOX; lpc < QB_LOG_TARGET_MAX; lpc++) {
             qb_log_ctl(lpc, QB_LOG_CONF_FILE_SYNC, QB_FALSE);
         }
     }
 }
 
 void
 crm_enable_blackbox(int nsig)
 {
     crm_control_blackbox(nsig, TRUE);
 }
 
 void
 crm_disable_blackbox(int nsig)
 {
     crm_control_blackbox(nsig, FALSE);
 }
 
 /*!
  * \internal
  * \brief Write out a blackbox, if blackboxes are enabled
  *
  * \param[in] nsig  Signal that was received
  * \param[in] cs    libqb callsite
  *
  * \note This may be called via a true signal handler and so must be async-safe.
  * @TODO actually make this async-safe
  */
 void
 crm_write_blackbox(int nsig, const struct qb_log_callsite *cs)
 {
     static volatile int counter = 1;
     static volatile time_t last = 0;
 
     char buffer[NAME_MAX];
     time_t now = time(NULL);
 
     if (blackbox_file_prefix == NULL) {
         return;
     }
 
     switch (nsig) {
         case 0:
         case SIGTRAP:
             /* The graceful case - such as assertion failure or user request */
 
             if (nsig == 0 && now == last) {
                 /* Prevent over-dumping */
                 return;
             }
 
             snprintf(buffer, NAME_MAX, "%s.%d", blackbox_file_prefix, counter++);
             if (nsig == SIGTRAP) {
                 crm_notice("Blackbox dump requested, please see %s for contents", buffer);
 
             } else if (cs) {
                 syslog(LOG_NOTICE,
                        "Problem detected at %s:%d (%s), please see %s for additional details",
                        cs->function, cs->lineno, cs->filename, buffer);
             } else {
                 crm_notice("Problem detected, please see %s for additional details", buffer);
             }
 
             last = now;
             qb_log_blackbox_write_to_file(buffer);
 
             /* Flush the existing contents
              * A size change would also work
              */
             qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_ENABLED, QB_FALSE);
             qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_ENABLED, QB_TRUE);
             break;
 
         default:
             /* Do as little as possible, just try to get what we have out
              * We logged the filename when the blackbox was enabled
              */
             crm_signal_handler(nsig, SIG_DFL);
             qb_log_blackbox_write_to_file((const char *)blackbox_file_prefix);
             qb_log_ctl(QB_LOG_BLACKBOX, QB_LOG_CONF_ENABLED, QB_FALSE);
             raise(nsig);
             break;
     }
 }
 
 static const char *
 crm_quark_to_string(uint32_t tag)
 {
     const char *text = g_quark_to_string(tag);
 
     if (text) {
         return text;
     }
     return "";
 }
 
 static void
 crm_log_filter_source(int source, const char *trace_files, const char *trace_fns,
                       const char *trace_fmts, const char *trace_tags, const char *trace_blackbox,
                       struct qb_log_callsite *cs)
 {
     if (qb_log_ctl(source, QB_LOG_CONF_STATE_GET, 0) != QB_LOG_STATE_ENABLED) {
         return;
     } else if (cs->tags != crm_trace_nonlog && source == QB_LOG_BLACKBOX) {
         /* Blackbox gets everything if enabled */
         qb_bit_set(cs->targets, source);
 
     } else if (source == blackbox_trigger && blackbox_trigger > 0) {
         /* Should this log message result in the blackbox being dumped */
         if (cs->priority <= LOG_ERR) {
             qb_bit_set(cs->targets, source);
 
         } else if (trace_blackbox) {
             char *key = crm_strdup_printf("%s:%d", cs->function, cs->lineno);
 
             if (strstr(trace_blackbox, key) != NULL) {
                 qb_bit_set(cs->targets, source);
             }
             free(key);
         }
 
     } else if (source == QB_LOG_SYSLOG) {       /* No tracing to syslog */
         if (cs->priority <= crm_log_priority && cs->priority <= crm_log_level) {
             qb_bit_set(cs->targets, source);
         }
         /* Log file tracing options... */
     } else if (cs->priority <= crm_log_level) {
         qb_bit_set(cs->targets, source);
     } else if (trace_files && strstr(trace_files, cs->filename) != NULL) {
         qb_bit_set(cs->targets, source);
     } else if (trace_fns && strstr(trace_fns, cs->function) != NULL) {
         qb_bit_set(cs->targets, source);
     } else if (trace_fmts && strstr(trace_fmts, cs->format) != NULL) {
         qb_bit_set(cs->targets, source);
     } else if (trace_tags
                && cs->tags != 0
                && cs->tags != crm_trace_nonlog && g_quark_to_string(cs->tags) != NULL) {
         qb_bit_set(cs->targets, source);
     }
 }
 
 #ifndef HAVE_STRCHRNUL
 /* strchrnul() is a GNU extension. If not present, use our own definition.
  * The GNU version returns char*, but we only need it to be const char*.
  */
 static const char *
 strchrnul(const char *s, int c)
 {
     while ((*s != c) && (*s != '\0')) {
         ++s;
     }
     return s;
 }
 #endif
 
 static void
 crm_log_filter(struct qb_log_callsite *cs)
 {
     int lpc = 0;
     static int need_init = 1;
     static const char *trace_fns = NULL;
     static const char *trace_tags = NULL;
     static const char *trace_fmts = NULL;
     static const char *trace_files = NULL;
     static const char *trace_blackbox = NULL;
 
     if (need_init) {
         need_init = 0;
         trace_fns = pcmk__env_option(PCMK__ENV_TRACE_FUNCTIONS);
         trace_fmts = pcmk__env_option(PCMK__ENV_TRACE_FORMATS);
         trace_tags = pcmk__env_option(PCMK__ENV_TRACE_TAGS);
         trace_files = pcmk__env_option(PCMK__ENV_TRACE_FILES);
         trace_blackbox = pcmk__env_option(PCMK__ENV_TRACE_BLACKBOX);
 
         if (trace_tags != NULL) {
             uint32_t tag;
             char token[500];
             const char *offset = NULL;
             const char *next = trace_tags;
 
             do {
                 offset = next;
                 next = strchrnul(offset, ',');
                 snprintf(token, sizeof(token), "%.*s", (int)(next - offset), offset);
 
                 tag = g_quark_from_string(token);
                 crm_info("Created GQuark %u from token '%s' in '%s'", tag, token, trace_tags);
 
                 if (next[0] != 0) {
                     next++;
                 }
 
             } while (next != NULL && next[0] != 0);
         }
     }
 
     cs->targets = 0;            /* Reset then find targets to enable */
     for (lpc = QB_LOG_SYSLOG; lpc < QB_LOG_TARGET_MAX; lpc++) {
         crm_log_filter_source(lpc, trace_files, trace_fns, trace_fmts, trace_tags, trace_blackbox,
                               cs);
     }
 }
 
 gboolean
 crm_is_callsite_active(struct qb_log_callsite *cs, uint8_t level, uint32_t tags)
 {
     gboolean refilter = FALSE;
 
     if (cs == NULL) {
         return FALSE;
     }
 
     if (cs->priority != level) {
         cs->priority = level;
         refilter = TRUE;
     }
 
     if (cs->tags != tags) {
         cs->tags = tags;
         refilter = TRUE;
     }
 
     if (refilter) {
         crm_log_filter(cs);
     }
 
     if (cs->targets == 0) {
         return FALSE;
     }
     return TRUE;
 }
 
 void
 crm_update_callsites(void)
 {
     static gboolean log = TRUE;
 
     if (log) {
         log = FALSE;
         crm_debug
             ("Enabling callsites based on priority=%d, files=%s, functions=%s, formats=%s, tags=%s",
              crm_log_level, pcmk__env_option(PCMK__ENV_TRACE_FILES),
              pcmk__env_option(PCMK__ENV_TRACE_FUNCTIONS),
              pcmk__env_option(PCMK__ENV_TRACE_FORMATS),
              pcmk__env_option(PCMK__ENV_TRACE_TAGS));
     }
     qb_log_filter_fn_set(crm_log_filter);
 }
 
 static gboolean
 crm_tracing_enabled(void)
 {
     return (crm_log_level == LOG_TRACE)
             || (pcmk__env_option(PCMK__ENV_TRACE_FILES) != NULL)
             || (pcmk__env_option(PCMK__ENV_TRACE_FUNCTIONS) != NULL)
             || (pcmk__env_option(PCMK__ENV_TRACE_FORMATS) != NULL)
             || (pcmk__env_option(PCMK__ENV_TRACE_TAGS) != NULL);
 }
 
 static int
 crm_priority2int(const char *name)
 {
     struct syslog_names {
         const char *name;
         int priority;
     };
     static struct syslog_names p_names[] = {
         {"emerg", LOG_EMERG},
         {"alert", LOG_ALERT},
         {"crit", LOG_CRIT},
         {"error", LOG_ERR},
         {"warning", LOG_WARNING},
         {"notice", LOG_NOTICE},
         {"info", LOG_INFO},
         {"debug", LOG_DEBUG},
         {NULL, -1}
     };
     int lpc;
 
     for (lpc = 0; name != NULL && p_names[lpc].name != NULL; lpc++) {
         if (pcmk__str_eq(p_names[lpc].name, name, pcmk__str_none)) {
             return p_names[lpc].priority;
         }
     }
     return crm_log_priority;
 }
 
 
 /*!
  * \internal
  * \brief Set the identifier for the current process
  *
  * If the identifier crm_system_name is not already set, then it is set as follows:
  * - it is passed to the function via the "entity" parameter, or
  * - it is derived from the executable name
  *
  * The identifier can be used in logs, IPC, and more.
  *
  * This method also sets the PCMK_service environment variable.
  *
  * \param[in] entity  If not NULL, will be assigned to the identifier
  * \param[in] argc    The number of command line parameters
  * \param[in] argv    The command line parameter values
  */
 static void
 set_identity(const char *entity, int argc, char *const *argv)
 {
     if (crm_system_name != NULL) {
         return; // Already set, don't overwrite
     }
 
     if (entity != NULL) {
         crm_system_name = pcmk__str_copy(entity);
 
     } else if ((argc > 0) && (argv != NULL)) {
         char *mutable = strdup(argv[0]);
         char *modified = basename(mutable);
 
         if (strstr(modified, "lt-") == modified) {
             modified += 3;
         }
         crm_system_name = pcmk__str_copy(modified);
         free(mutable);
 
     } else {
         crm_system_name = pcmk__str_copy("Unknown");
     }
 
     // Used by fencing.py.py (in fence-agents)
     pcmk__set_env_option(PCMK__ENV_SERVICE, crm_system_name, false);
 }
 
 void
 crm_log_preinit(const char *entity, int argc, char *const *argv)
 {
     /* Configure libqb logging with nothing turned on */
 
     struct utsname res;
     int lpc = 0;
     int32_t qb_facility = 0;
     pid_t pid = getpid();
     const char *nodename = "localhost";
     static bool have_logging = false;
     GLogLevelFlags log_levels;
 
     if (have_logging) {
         return;
     }
 
     have_logging = true;
 
-    pcmk__xml_init();
+    /* @TODO Try to create a more obvious "global Pacemaker initializer"
+     * function than crm_log_preinit(), and call pcmk__schema_init() there.
+     * See also https://projects.clusterlabs.org/T840.
+     */
+    pcmk__schema_init();
 
     if (crm_trace_nonlog == 0) {
         crm_trace_nonlog = g_quark_from_static_string("Pacemaker non-logging tracepoint");
     }
 
     umask(S_IWGRP | S_IWOTH | S_IROTH);
 
     /* Add a log handler for messages from our log domain at any log level. */
     log_levels = G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION;
     pcmk__log_id = g_log_set_handler(G_LOG_DOMAIN, log_levels, crm_glib_handler, NULL);
     /* Add a log handler for messages from the GLib domains at any log level. */
     pcmk__glib_log_id = g_log_set_handler("GLib", log_levels, crm_glib_handler, NULL);
     pcmk__gio_log_id = g_log_set_handler("GLib-GIO", log_levels, crm_glib_handler, NULL);
     pcmk__gmodule_log_id = g_log_set_handler("GModule", log_levels, crm_glib_handler, NULL);
     pcmk__gthread_log_id = g_log_set_handler("GThread", log_levels, crm_glib_handler, NULL);
 
     /* glib should not abort for any messages from the Pacemaker domain, but
      * other domains are still free to specify their own behavior.  However,
      * note that G_LOG_LEVEL_ERROR is always fatal regardless of what we do
      * here.
      */
     g_log_set_fatal_mask(G_LOG_DOMAIN, 0);
 
     /* Set crm_system_name, which is used as the logging name. It may also
      * be used for other purposes such as an IPC client name.
      */
     set_identity(entity, argc, argv);
 
     qb_facility = qb_log_facility2int("local0");
     qb_log_init(crm_system_name, qb_facility, LOG_ERR);
     crm_log_level = LOG_CRIT;
 
     /* Nuke any syslog activity until it's asked for */
     qb_log_ctl(QB_LOG_SYSLOG, QB_LOG_CONF_ENABLED, QB_FALSE);
 #ifdef HAVE_qb_log_conf_QB_LOG_CONF_MAX_LINE_LEN
     // Shorter than default, generous for what we *should* send to syslog
     qb_log_ctl(QB_LOG_SYSLOG, QB_LOG_CONF_MAX_LINE_LEN, 256);
 #endif
     if (uname(memset(&res, 0, sizeof(res))) == 0 && *res.nodename != '\0') {
         nodename = res.nodename;
     }
 
     /* Set format strings and disable threading
      * Pacemaker and threads do not mix well (due to the amount of forking)
      */
     qb_log_tags_stringify_fn_set(crm_quark_to_string);
     for (lpc = QB_LOG_SYSLOG; lpc < QB_LOG_TARGET_MAX; lpc++) {
         qb_log_ctl(lpc, QB_LOG_CONF_THREADED, QB_FALSE);
 #ifdef HAVE_qb_log_conf_QB_LOG_CONF_ELLIPSIS
         // End truncated lines with '...'
         qb_log_ctl(lpc, QB_LOG_CONF_ELLIPSIS, QB_TRUE);
 #endif
         set_format_string(lpc, crm_system_name, pid, nodename);
     }
 
 #ifdef ENABLE_NLS
     /* Enable translations (experimental). Currently we only have a few
      * proof-of-concept translations for some option help. The goal would be to
      * offer translations for option help and man pages rather than logs or
      * documentation, to reduce the burden of maintaining them.
      */
 
     // Load locale information for the local host from the environment
     setlocale(LC_ALL, "");
 
     // Tell gettext where to find Pacemaker message catalogs
     pcmk__assert(bindtextdomain(PACKAGE, PCMK__LOCALE_DIR) != NULL);
 
     // Tell gettext to use the Pacemaker message catalogs
     pcmk__assert(textdomain(PACKAGE) != NULL);
 
     // Tell gettext that the translated strings are stored in UTF-8
     bind_textdomain_codeset(PACKAGE, "UTF-8");
 #endif
 }
 
 gboolean
 crm_log_init(const char *entity, uint8_t level, gboolean daemon, gboolean to_stderr,
              int argc, char **argv, gboolean quiet)
 {
     const char *syslog_priority = NULL;
     const char *facility = pcmk__env_option(PCMK__ENV_LOGFACILITY);
     const char *f_copy = facility;
 
     pcmk__is_daemon = daemon;
     crm_log_preinit(entity, argc, argv);
 
     if (level > LOG_TRACE) {
         level = LOG_TRACE;
     }
     if(level > crm_log_level) {
         crm_log_level = level;
     }
 
     /* Should we log to syslog */
     if (facility == NULL) {
         if (pcmk__is_daemon) {
             facility = "daemon";
         } else {
             facility = PCMK_VALUE_NONE;
         }
         pcmk__set_env_option(PCMK__ENV_LOGFACILITY, facility, true);
     }
 
     if (pcmk__str_eq(facility, PCMK_VALUE_NONE, pcmk__str_casei)) {
         quiet = TRUE;
 
 
     } else {
         qb_log_ctl(QB_LOG_SYSLOG, QB_LOG_CONF_FACILITY, qb_log_facility2int(facility));
     }
 
     if (pcmk__env_option_enabled(crm_system_name, PCMK__ENV_DEBUG)) {
         /* Override the default setting */
         crm_log_level = LOG_DEBUG;
     }
 
     /* What lower threshold do we have for sending to syslog */
     syslog_priority = pcmk__env_option(PCMK__ENV_LOGPRIORITY);
     if (syslog_priority) {
         crm_log_priority = crm_priority2int(syslog_priority);
     }
     qb_log_filter_ctl(QB_LOG_SYSLOG, QB_LOG_FILTER_ADD, QB_LOG_FILTER_FILE, "*",
                       crm_log_priority);
 
     // Log to syslog unless requested to be quiet
     if (!quiet) {
         qb_log_ctl(QB_LOG_SYSLOG, QB_LOG_CONF_ENABLED, QB_TRUE);
     }
 
     /* Should we log to stderr */ 
     if (pcmk__env_option_enabled(crm_system_name, PCMK__ENV_STDERR)) {
         /* Override the default setting */
         to_stderr = TRUE;
     }
     crm_enable_stderr(to_stderr);
 
     // Log to a file if we're a daemon or user asked for one
     {
         const char *logfile = pcmk__env_option(PCMK__ENV_LOGFILE);
 
         if (!pcmk__str_eq(PCMK_VALUE_NONE, logfile, pcmk__str_casei)
             && (pcmk__is_daemon || (logfile != NULL))) {
             // Daemons always get a log file, unless explicitly set to "none"
             pcmk__add_logfile(logfile);
         }
     }
 
     if (pcmk__is_daemon
         && pcmk__env_option_enabled(crm_system_name, PCMK__ENV_BLACKBOX)) {
         crm_enable_blackbox(0);
     }
 
     /* Summary */
     crm_trace("Quiet: %d, facility %s", quiet, f_copy);
     pcmk__env_option(PCMK__ENV_LOGFILE);
     pcmk__env_option(PCMK__ENV_LOGFACILITY);
 
     crm_update_callsites();
 
     /* Ok, now we can start logging... */
 
     // Disable daemon request if user isn't root or Pacemaker daemon user
     if (pcmk__is_daemon) {
         const char *user = getenv("USER");
 
         if (user != NULL && !pcmk__strcase_any_of(user, "root", CRM_DAEMON_USER, NULL)) {
             crm_trace("Not switching to corefile directory for %s", user);
             pcmk__is_daemon = false;
         }
     }
 
     if (pcmk__is_daemon) {
         int user = getuid();
         struct passwd *pwent = getpwuid(user);
 
         if (pwent == NULL) {
             crm_perror(LOG_ERR, "Cannot get name for uid: %d", user);
 
         } else if (!pcmk__strcase_any_of(pwent->pw_name, "root", CRM_DAEMON_USER, NULL)) {
             crm_trace("Don't change active directory for regular user: %s", pwent->pw_name);
 
         } else if (chdir(CRM_CORE_DIR) < 0) {
             crm_perror(LOG_INFO, "Cannot change active directory to " CRM_CORE_DIR);
 
         } else {
             crm_info("Changed active directory to " CRM_CORE_DIR);
         }
 
         /* Original meanings from signal(7)
          *
          * Signal       Value     Action   Comment
          * SIGTRAP        5        Core    Trace/breakpoint trap
          * SIGUSR1     30,10,16    Term    User-defined signal 1
          * SIGUSR2     31,12,17    Term    User-defined signal 2
          *
          * Our usage is as similar as possible
          */
         mainloop_add_signal(SIGUSR1, crm_enable_blackbox);
         mainloop_add_signal(SIGUSR2, crm_disable_blackbox);
         mainloop_add_signal(SIGTRAP, crm_trigger_blackbox);
 
     } else if (!quiet) {
         crm_log_args(argc, argv);
     }
 
     return TRUE;
 }
 
 /* returns the old value */
 unsigned int
 set_crm_log_level(unsigned int level)
 {
     unsigned int old = crm_log_level;
 
     if (level > LOG_TRACE) {
         level = LOG_TRACE;
     }
     crm_log_level = level;
     crm_update_callsites();
     crm_trace("New log level: %d", level);
     return old;
 }
 
 void
 crm_enable_stderr(int enable)
 {
     if (enable && qb_log_ctl(QB_LOG_STDERR, QB_LOG_CONF_STATE_GET, 0) != QB_LOG_STATE_ENABLED) {
         qb_log_ctl(QB_LOG_STDERR, QB_LOG_CONF_ENABLED, QB_TRUE);
         crm_update_callsites();
 
     } else if (enable == FALSE) {
         qb_log_ctl(QB_LOG_STDERR, QB_LOG_CONF_ENABLED, QB_FALSE);
     }
 }
 
 /*!
  * \brief Make logging more verbose
  *
  * If logging to stderr is not already enabled when this function is called,
  * enable it. Otherwise, increase the log level by 1.
  *
  * \param[in] argc  Ignored
  * \param[in] argv  Ignored
  */
 void
 crm_bump_log_level(int argc, char **argv)
 {
     if (qb_log_ctl(QB_LOG_STDERR, QB_LOG_CONF_STATE_GET, 0)
         != QB_LOG_STATE_ENABLED) {
         crm_enable_stderr(TRUE);
     } else {
         set_crm_log_level(crm_log_level + 1);
     }
 }
 
 unsigned int
 get_crm_log_level(void)
 {
     return crm_log_level;
 }
 
 /*!
  * \brief Log the command line (once)
  *
  * \param[in]  Number of values in \p argv
  * \param[in]  Command-line arguments (including command name)
  *
  * \note This function will only log once, even if called with different
  *       arguments.
  */
 void
 crm_log_args(int argc, char **argv)
 {
     static bool logged = false;
     gchar *arg_string = NULL;
 
     if ((argc == 0) || (argv == NULL) || logged) {
         return;
     }
     logged = true;
     arg_string = g_strjoinv(" ", argv);
     crm_notice("Invoked: %s", arg_string);
     g_free(arg_string);
 }
 
 void
 crm_log_output_fn(const char *file, const char *function, int line, int level, const char *prefix,
                   const char *output)
 {
     const char *next = NULL;
     const char *offset = NULL;
 
     if (level == LOG_NEVER) {
         return;
     }
 
     if (output == NULL) {
         if (level != LOG_STDOUT) {
             level = LOG_TRACE;
         }
         output = "-- empty --";
     }
 
     next = output;
     do {
         offset = next;
         next = strchrnul(offset, '\n');
         do_crm_log_alias(level, file, function, line, "%s [ %.*s ]", prefix,
                          (int)(next - offset), offset);
         if (next[0] != 0) {
             next++;
         }
 
     } while (next != NULL && next[0] != 0);
 }
 
 void
 pcmk__cli_init_logging(const char *name, unsigned int verbosity)
 {
     crm_log_init(name, LOG_ERR, FALSE, FALSE, 0, NULL, TRUE);
 
     for (int i = 0; i < verbosity; i++) {
         /* These arguments are ignored, so pass placeholders. */
         crm_bump_log_level(0, NULL);
     }
 }
 
 /*!
  * \brief Log XML line-by-line in a formatted fashion
  *
  * \param[in] file      File name to use for log filtering
  * \param[in] function  Function name to use for log filtering
  * \param[in] line      Line number to use for log filtering
  * \param[in] tags      Logging tags to use for log filtering
  * \param[in] level     Priority at which to log the messages
  * \param[in] text      Prefix for each line
  * \param[in] xml       XML to log
  *
  * \note This does nothing when \p level is \p LOG_STDOUT.
  * \note Do not call this function directly. It should be called only from the
  *       \p do_crm_log_xml() macro.
  */
 void
 pcmk_log_xml_as(const char *file, const char *function, uint32_t line,
                 uint32_t tags, uint8_t level, const char *text, const xmlNode *xml)
 {
     if (xml == NULL) {
         do_crm_log(level, "%s%sNo data to dump as XML",
                    pcmk__s(text, ""), pcmk__str_empty(text)? "" : " ");
 
     } else {
         if (logger_out == NULL) {
             CRM_CHECK(pcmk__log_output_new(&logger_out) == pcmk_rc_ok, return);
         }
 
         pcmk__output_set_log_level(logger_out, level);
         pcmk__output_set_log_filter(logger_out, file, function, line, tags);
         pcmk__xml_show(logger_out, text, xml, 1,
                        pcmk__xml_fmt_pretty
                        |pcmk__xml_fmt_open
                        |pcmk__xml_fmt_children
                        |pcmk__xml_fmt_close);
         pcmk__output_set_log_filter(logger_out, NULL, NULL, 0U, 0U);
     }
 }
 
 /*!
  * \internal
  * \brief Log XML changes line-by-line in a formatted fashion
  *
  * \param[in] file      File name to use for log filtering
  * \param[in] function  Function name to use for log filtering
  * \param[in] line      Line number to use for log filtering
  * \param[in] tags      Logging tags to use for log filtering
  * \param[in] level     Priority at which to log the messages
  * \param[in] xml       XML whose changes to log
  *
  * \note This does nothing when \p level is \c LOG_STDOUT.
  */
 void
 pcmk__log_xml_changes_as(const char *file, const char *function, uint32_t line,
                          uint32_t tags, uint8_t level, const xmlNode *xml)
 {
     if (xml == NULL) {
         do_crm_log(level, "No XML to dump");
         return;
     }
 
     if (logger_out == NULL) {
         CRM_CHECK(pcmk__log_output_new(&logger_out) == pcmk_rc_ok, return);
     }
     pcmk__output_set_log_level(logger_out, level);
     pcmk__output_set_log_filter(logger_out, file, function, line, tags);
     pcmk__xml_show_changes(logger_out, xml);
     pcmk__output_set_log_filter(logger_out, NULL, NULL, 0U, 0U);
 }
 
 /*!
  * \internal
  * \brief Log an XML patchset line-by-line in a formatted fashion
  *
  * \param[in] file      File name to use for log filtering
  * \param[in] function  Function name to use for log filtering
  * \param[in] line      Line number to use for log filtering
  * \param[in] tags      Logging tags to use for log filtering
  * \param[in] level     Priority at which to log the messages
  * \param[in] patchset  XML patchset to log
  *
  * \note This does nothing when \p level is \c LOG_STDOUT.
  */
 void
 pcmk__log_xml_patchset_as(const char *file, const char *function, uint32_t line,
                           uint32_t tags, uint8_t level, const xmlNode *patchset)
 {
     if (patchset == NULL) {
         do_crm_log(level, "No patchset to dump");
         return;
     }
 
     if (logger_out == NULL) {
         CRM_CHECK(pcmk__log_output_new(&logger_out) == pcmk_rc_ok, return);
     }
     pcmk__output_set_log_level(logger_out, level);
     pcmk__output_set_log_filter(logger_out, file, function, line, tags);
     logger_out->message(logger_out, "xml-patchset", patchset);
     pcmk__output_set_log_filter(logger_out, NULL, NULL, 0U, 0U);
 }
 
 /*!
  * \internal
  * \brief Free the logging library's internal log output object
  */
 void
 pcmk__free_common_logger(void)
 {
     if (logger_out != NULL) {
         logger_out->finish(logger_out, CRM_EX_OK, true, NULL);
         pcmk__output_free(logger_out);
         logger_out = NULL;
     }
 }
 
 void pcmk__set_config_error_handler(pcmk__config_error_func error_handler, void *error_context)
 {
     pcmk__config_error_handler = error_handler;
     pcmk__config_error_context = error_context;    
 }
 
 void pcmk__set_config_warning_handler(pcmk__config_warning_func warning_handler, void *warning_context)
 {
     pcmk__config_warning_handler = warning_handler;
     pcmk__config_warning_context = warning_context;   
 }
diff --git a/lib/common/tests/schemas/pcmk__build_schema_xml_node_test.c b/lib/common/tests/schemas/pcmk__build_schema_xml_node_test.c
index 9cb29ad7b2..fca6182d09 100644
--- a/lib/common/tests/schemas/pcmk__build_schema_xml_node_test.c
+++ b/lib/common/tests/schemas/pcmk__build_schema_xml_node_test.c
@@ -1,159 +1,157 @@
 /*
- * Copyright 2023-2024 the Pacemaker project contributors
+ * Copyright 2023-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/unittest_internal.h>
 #include <crm/common/lists_internal.h>
 
 #include <glib.h>
 
 const char *rngs1[] = { "pacemaker-3.0.rng", "status-1.0.rng", "alerts-2.10.rng",
                         "nvset-2.9.rng", "score.rng", "rule-2.9.rng",
                         "tags-1.3.rng", "acls-2.0.rng", "fencing-2.4.rng",
                         "constraints-3.0.rng", "resources-3.0.rng", "nvset-3.0.rng",
                         "nodes-3.0.rng", "options-3.0.rng", NULL };
 
 const char *rngs2[] = { "pacemaker-2.0.rng", "status-1.0.rng", "tags-1.3.rng",
                         "acls-2.0.rng", "fencing-1.2.rng", "constraints-1.2.rng",
                         "rule.rng", "score.rng", "resources-1.3.rng",
                         "nvset-1.3.rng", "nodes-1.3.rng", "options-1.0.rng",
                         "nvset.rng", "cib-1.2.rng", NULL };
 
 const char *rngs3[] = { "pacemaker-2.1.rng", "constraints-2.1.rng", NULL };
 
 static int
 setup(void **state)
 {
     setenv("PCMK_schema_directory", PCMK__TEST_SCHEMA_DIR, 1);
-    pcmk__schema_init();
     pcmk__xml_test_setup_group(state);
     return 0;
 }
 
 static int
 teardown(void **state)
 {
     pcmk__xml_test_teardown_group(state);
-    pcmk__schema_cleanup();
     unsetenv("PCMK_schema_directory");
     return 0;
 }
 
 static void
 invalid_name(void **state)
 {
     GList *already_included = NULL;
     xmlNode *parent = pcmk__xe_create(NULL, PCMK__XA_SCHEMAS);
 
     pcmk__build_schema_xml_node(parent, "pacemaker-9.0", &already_included);
     assert_null(parent->children);
     assert_null(already_included);
     pcmk__xml_free(parent);
 }
 
 static void
 single_schema(void **state)
 {
     GList *already_included = NULL;
     xmlNode *parent = pcmk__xe_create(NULL, PCMK__XA_SCHEMAS);
     xmlNode *schema_node = NULL;
     xmlNode *file_node = NULL;
     int i = 0;
 
     pcmk__build_schema_xml_node(parent, "pacemaker-3.0", &already_included);
 
     assert_non_null(already_included);
     assert_non_null(parent->children);
 
     /* Test that the result looks like this:
      *
      * <schemas>
      *   <schema version="pacemaker-3.0">
      *     <file path="pacemaker-3.0.rng">CDATA</file>
      *     <file path="status-1.0.rng">CDATA</file>
      *     ...
      *   </schema>
      * </schemas>
      */
     schema_node = pcmk__xe_first_child(parent, NULL, NULL, NULL);
     assert_string_equal("pacemaker-3.0",
                         crm_element_value(schema_node, PCMK_XA_VERSION));
 
     file_node = pcmk__xe_first_child(schema_node, NULL, NULL, NULL);
     while (file_node != NULL && rngs1[i] != NULL) {
         assert_string_equal(rngs1[i],
                             crm_element_value(file_node, PCMK_XA_PATH));
         assert_int_equal(pcmk__xml_first_child(file_node)->type, XML_CDATA_SECTION_NODE);
 
         file_node = pcmk__xe_next(file_node, NULL);
         i++;
     }
 
     g_list_free_full(already_included, free);
     pcmk__xml_free(parent);
 }
 
 static void
 multiple_schemas(void **state)
 {
     GList *already_included = NULL;
     xmlNode *parent = pcmk__xe_create(NULL, PCMK__XA_SCHEMAS);
     xmlNode *schema_node = NULL;
     xmlNode *file_node = NULL;
     int i = 0;
 
     pcmk__build_schema_xml_node(parent, "pacemaker-2.0", &already_included);
     pcmk__build_schema_xml_node(parent, "pacemaker-2.1", &already_included);
 
     assert_non_null(already_included);
     assert_non_null(parent->children);
 
     /* Like single_schema, but make sure files aren't included multiple times
      * when the function is called repeatedly.
      */
     schema_node = pcmk__xe_first_child(parent, NULL, NULL, NULL);
     assert_string_equal("pacemaker-2.0",
                         crm_element_value(schema_node, PCMK_XA_VERSION));
 
     file_node = pcmk__xe_first_child(schema_node, NULL, NULL, NULL);
     while (file_node != NULL && rngs2[i] != NULL) {
         assert_string_equal(rngs2[i],
                             crm_element_value(file_node, PCMK_XA_PATH));
         assert_int_equal(pcmk__xml_first_child(file_node)->type, XML_CDATA_SECTION_NODE);
 
         file_node = pcmk__xe_next(file_node, NULL);
         i++;
     }
 
     schema_node = pcmk__xe_next(schema_node, NULL);
     assert_string_equal("pacemaker-2.1",
                         crm_element_value(schema_node, PCMK_XA_VERSION));
 
     file_node = pcmk__xe_first_child(schema_node, NULL, NULL, NULL);
     i = 0;
 
     while (file_node != NULL && rngs3[i] != NULL) {
         assert_string_equal(rngs3[i],
                             crm_element_value(file_node, PCMK_XA_PATH));
         assert_int_equal(pcmk__xml_first_child(file_node)->type, XML_CDATA_SECTION_NODE);
 
         file_node = pcmk__xe_next(file_node, NULL);
         i++;
     }
 
     g_list_free_full(already_included, free);
     pcmk__xml_free(parent);
 }
 
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(invalid_name),
                 cmocka_unit_test(single_schema),
                 cmocka_unit_test(multiple_schemas))
diff --git a/lib/common/tests/schemas/pcmk__cmp_schemas_by_name_test.c b/lib/common/tests/schemas/pcmk__cmp_schemas_by_name_test.c
index fa5d76f6f6..74e0ba42b6 100644
--- a/lib/common/tests/schemas/pcmk__cmp_schemas_by_name_test.c
+++ b/lib/common/tests/schemas/pcmk__cmp_schemas_by_name_test.c
@@ -1,93 +1,93 @@
 /*
- * Copyright 2024 the Pacemaker project contributors
+ * Copyright 2024-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/unittest_internal.h>
 #include <crm/common/xml_internal.h>
 #include "crmcommon_private.h"
 
 static int
 setup(void **state)
 {
     setenv("PCMK_schema_directory", PCMK__TEST_SCHEMA_DIR, 1);
-    pcmk__schema_init();
+    pcmk__xml_test_setup_group(state);
     return 0;
 }
 
 static int
 teardown(void **state)
 {
-    pcmk__schema_cleanup();
+    pcmk__xml_test_teardown_group(state);
     unsetenv("PCMK_schema_directory");
     return 0;
 }
 
 // Unknown schemas (including NULL) are unsupported, but sort first as failsafe
 static void
 unknown_is_lesser(void **state)
 {
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-0.1",
                                           "pacemaker-0.2") == 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-0.1",
                                           "pacemaker-1.0") < 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-1.0",
                                           "pacemaker-0.1") > 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-0.1",
                                           PCMK_VALUE_NONE) < 0);
     assert_true(pcmk__cmp_schemas_by_name(PCMK_VALUE_NONE,
                                           "pacemaker-0.1") > 0);
     assert_true(pcmk__cmp_schemas_by_name(NULL, NULL) == 0);
     assert_true(pcmk__cmp_schemas_by_name(NULL, "pacemaker-0.0") == 0);
     assert_true(pcmk__cmp_schemas_by_name(NULL, "pacemaker-2.0") < 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-1.3", NULL) > 0);
     assert_true(pcmk__cmp_schemas_by_name(NULL, PCMK_VALUE_NONE) < 0);
     assert_true(pcmk__cmp_schemas_by_name(PCMK_VALUE_NONE, NULL) > 0);
 }
 
 // @COMPAT none is deprecated since 2.1.8
 static void
 none_is_greater(void **state)
 {
     assert_true(pcmk__cmp_schemas_by_name(PCMK_VALUE_NONE,
                                           PCMK_VALUE_NONE) == 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-3.10",
                                           PCMK_VALUE_NONE) < 0);
     assert_true(pcmk__cmp_schemas_by_name(PCMK_VALUE_NONE,
                                           "pacemaker-1.0") > 0);
 }
 
 static void
 known_numeric(void **state)
 {
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-1.0",
                                           "pacemaker-1.0") == 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-1.2",
                                           "pacemaker-1.0") > 0);
     assert_true(pcmk__cmp_schemas_by_name("pacemaker-1.2",
                                           "pacemaker-2.0") < 0);
 }
 
 static void
 case_sensitive(void **state)
 {
     assert_true(pcmk__cmp_schemas_by_name("Pacemaker-1.0",
                                           "pacemaker-1.0") != 0);
     assert_true(pcmk__cmp_schemas_by_name("PACEMAKER-1.2",
                                           "pacemaker-1.2") != 0);
     assert_true(pcmk__cmp_schemas_by_name("PaceMaker-2.0",
                                           "pacemaker-2.0") != 0);
 }
 
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(unknown_is_lesser),
                 cmocka_unit_test(none_is_greater),
                 cmocka_unit_test(known_numeric),
                 cmocka_unit_test(case_sensitive));
diff --git a/lib/common/tests/schemas/pcmk__get_schema_test.c b/lib/common/tests/schemas/pcmk__get_schema_test.c
index 3990171ff5..09b74fc41d 100644
--- a/lib/common/tests/schemas/pcmk__get_schema_test.c
+++ b/lib/common/tests/schemas/pcmk__get_schema_test.c
@@ -1,79 +1,79 @@
 /*
- * Copyright 2023-2024 the Pacemaker project contributors
+ * Copyright 2023-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/unittest_internal.h>
 #include <crm/common/xml_internal.h>
 #include "crmcommon_private.h"
 
 static int
 setup(void **state)
 {
     setenv("PCMK_schema_directory", PCMK__TEST_SCHEMA_DIR, 1);
-    pcmk__schema_init();
+    pcmk__xml_test_setup_group(state);
     return 0;
 }
 
 static int
 teardown(void **state)
 {
-    pcmk__schema_cleanup();
+    pcmk__xml_test_teardown_group(state);
     unsetenv("PCMK_schema_directory");
     return 0;
 }
 
 static void
 assert_schema(const char *name, int expected_index)
 {
     GList *schema_entry = NULL;
     pcmk__schema_t *schema = NULL;
 
     schema_entry = pcmk__get_schema(name);
     assert_non_null(schema_entry);
 
     schema = schema_entry->data;
     assert_non_null(schema);
 
     assert_int_equal(schema->schema_index, expected_index);
 }
 
 static void
 unknown_schema(void **state)
 {
     assert_null(pcmk__get_schema(NULL));
     assert_null(pcmk__get_schema(""));
     assert_null(pcmk__get_schema("blahblah"));
     assert_null(pcmk__get_schema("pacemaker-2.47"));
     assert_null(pcmk__get_schema("pacemaker-47.0"));
 }
 
 static void
 known_schema(void **state)
 {
     assert_schema("pacemaker-1.0", 0);
     assert_schema("pacemaker-1.2", 1);
     assert_schema("pacemaker-2.0", 3);
     assert_schema("pacemaker-2.5", 8);
     assert_schema("pacemaker-3.0", 14);
 }
 
 static void
 case_sensitive(void **state)
 {
     assert_null(pcmk__get_schema("PACEMAKER-1.0"));
     assert_null(pcmk__get_schema("pAcEmAkEr-2.0"));
     assert_null(pcmk__get_schema("paceMAKER-3.0"));
 }
 
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(unknown_schema),
                 cmocka_unit_test(known_schema),
                 cmocka_unit_test(case_sensitive));
diff --git a/lib/common/tests/schemas/pcmk__schema_files_later_than_test.c b/lib/common/tests/schemas/pcmk__schema_files_later_than_test.c
index 76ae519a56..c48944da3e 100644
--- a/lib/common/tests/schemas/pcmk__schema_files_later_than_test.c
+++ b/lib/common/tests/schemas/pcmk__schema_files_later_than_test.c
@@ -1,106 +1,106 @@
 /*
- * Copyright 2023-2024 the Pacemaker project contributors
+ * Copyright 2023-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 #include <crm/common/lists_internal.h>
 
 #include <glib.h>
 
 static int
 setup(void **state)
 {
     setenv("PCMK_schema_directory", PCMK__TEST_SCHEMA_DIR, 1);
-    pcmk__schema_init();
+    pcmk__xml_test_setup_group(state);
     return 0;
 }
 
 static int
 teardown(void **state)
 {
-    pcmk__schema_cleanup();
+    pcmk__xml_test_teardown_group(state);
     unsetenv("PCMK_schema_directory");
     return 0;
 }
 
 static void
 invalid_name(void **state)
 {
     assert_null(pcmk__schema_files_later_than("xyz"));
     assert_null(pcmk__schema_files_later_than("pacemaker-"));
 }
 
 static void
 valid_name(void **state)
 {
     GList *schemas = NULL;
 
     schemas = pcmk__schema_files_later_than("pacemaker-1.0");
     assert_int_equal(g_list_length(schemas), 18);
     /* There is no "pacemaker-1.1". */
     assert_string_equal("pacemaker-1.2.rng", g_list_nth_data(schemas, 0));
     assert_string_equal("pacemaker-1.3.rng", g_list_nth_data(schemas, 1));
     assert_string_equal("upgrade-1.3-0.xsl", g_list_nth_data(schemas, 2));
     assert_string_equal("pacemaker-2.0.rng", g_list_nth_data(schemas, 3));
     assert_string_equal("pacemaker-2.1.rng", g_list_nth_data(schemas, 4));
     assert_string_equal("pacemaker-2.2.rng", g_list_nth_data(schemas, 5));
     assert_string_equal("pacemaker-2.3.rng", g_list_nth_data(schemas, 6));
     assert_string_equal("pacemaker-2.4.rng", g_list_nth_data(schemas, 7));
     assert_string_equal("pacemaker-2.5.rng", g_list_nth_data(schemas, 8));
     assert_string_equal("pacemaker-2.6.rng", g_list_nth_data(schemas, 9));
     assert_string_equal("pacemaker-2.7.rng", g_list_nth_data(schemas, 10));
     assert_string_equal("pacemaker-2.8.rng", g_list_nth_data(schemas, 11));
     assert_string_equal("pacemaker-2.9.rng", g_list_nth_data(schemas, 12));
     assert_string_equal("pacemaker-2.10.rng", g_list_nth_data(schemas, 13));
     assert_string_equal("upgrade-2.10-0.xsl", g_list_nth_data(schemas, 14));
     assert_string_equal("upgrade-2.10-1.xsl", g_list_nth_data(schemas, 15));
     assert_string_equal("upgrade-2.10-2.xsl", g_list_nth_data(schemas, 16));
     assert_string_equal("pacemaker-3.0.rng", g_list_nth_data(schemas, 17));
     g_list_free_full(schemas, free);
 
     /* Adding .rng to the end of the schema we're requesting is also valid. */
     schemas = pcmk__schema_files_later_than("pacemaker-2.0.rng");
     assert_int_equal(g_list_length(schemas), 14);
     assert_string_equal("pacemaker-2.1.rng", g_list_nth_data(schemas, 0));
     assert_string_equal("pacemaker-2.2.rng", g_list_nth_data(schemas, 1));
     assert_string_equal("pacemaker-2.3.rng", g_list_nth_data(schemas, 2));
     assert_string_equal("pacemaker-2.4.rng", g_list_nth_data(schemas, 3));
     assert_string_equal("pacemaker-2.5.rng", g_list_nth_data(schemas, 4));
     assert_string_equal("pacemaker-2.6.rng", g_list_nth_data(schemas, 5));
     assert_string_equal("pacemaker-2.7.rng", g_list_nth_data(schemas, 6));
     assert_string_equal("pacemaker-2.8.rng", g_list_nth_data(schemas, 7));
     assert_string_equal("pacemaker-2.9.rng", g_list_nth_data(schemas, 8));
     assert_string_equal("pacemaker-2.10.rng", g_list_nth_data(schemas, 9));
     assert_string_equal("upgrade-2.10-0.xsl", g_list_nth_data(schemas, 10));
     assert_string_equal("upgrade-2.10-1.xsl", g_list_nth_data(schemas, 11));
     assert_string_equal("upgrade-2.10-2.xsl", g_list_nth_data(schemas, 12));
     assert_string_equal("pacemaker-3.0.rng", g_list_nth_data(schemas, 13));
     g_list_free_full(schemas, free);
 
     /* Check that "pacemaker-2.10" counts as later than "pacemaker-2.9". */
     schemas = pcmk__schema_files_later_than("pacemaker-2.9");
     assert_int_equal(g_list_length(schemas), 5);
     assert_string_equal("pacemaker-2.10.rng", g_list_nth_data(schemas, 0));
     assert_string_equal("upgrade-2.10-0.xsl", g_list_nth_data(schemas, 1));
     assert_string_equal("upgrade-2.10-1.xsl", g_list_nth_data(schemas, 2));
     assert_string_equal("upgrade-2.10-2.xsl", g_list_nth_data(schemas, 3));
     assert_string_equal("pacemaker-3.0.rng", g_list_nth_data(schemas, 4));
     g_list_free_full(schemas, free);
 
     /* And then something way in the future that will never apply due to our
      * special schema directory.
      */
     schemas = pcmk__schema_files_later_than("pacemaker-9.0");
     assert_null(schemas);
 }
 
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(invalid_name),
                 cmocka_unit_test(valid_name))
diff --git a/lib/common/tests/schemas/pcmk__schema_init_test.c b/lib/common/tests/schemas/pcmk__schema_init_test.c
index 19c20cc7da..8df9e6940a 100644
--- a/lib/common/tests/schemas/pcmk__schema_init_test.c
+++ b/lib/common/tests/schemas/pcmk__schema_init_test.c
@@ -1,149 +1,149 @@
 /*
- * Copyright 2023-2024 the Pacemaker project contributors
+ * Copyright 2023-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <ftw.h>
 #include <unistd.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/unittest_internal.h>
 #include <crm/common/xml_internal.h>
 #include "crmcommon_private.h"
 
 static char *remote_schema_dir = NULL;
 
 static int
 symlink_schema(const char *tmpdir, const char *target_file, const char *link_file)
 {
     int rc = 0;
     char *oldpath = NULL;
     char *newpath = NULL;
 
     oldpath = crm_strdup_printf("%s/%s", PCMK__TEST_SCHEMA_DIR, target_file);
     newpath = crm_strdup_printf("%s/%s", tmpdir, link_file);
 
     rc = symlink(oldpath, newpath);
 
     free(oldpath);
     free(newpath);
     return rc;
 }
 
 static int
 rm_files(const char *pathname, const struct stat *sbuf, int type, struct FTW *ftwb)
 {
     return remove(pathname);
 }
 
 static int
 rmtree(const char *dir)
 {
     return nftw(dir, rm_files, 10, FTW_DEPTH|FTW_MOUNT|FTW_PHYS);
 }
 
 static int
 setup(void **state)
 {
     char *dir = NULL;
 
     /* Create a directory to hold additional schema files.  These don't need
      * to be anything special - we can just copy existing schemas but give
      * them new names.
      */
     dir = crm_strdup_printf("%s/test-schemas.XXXXXX", pcmk__get_tmpdir());
     remote_schema_dir = mkdtemp(dir);
 
     if (remote_schema_dir == NULL) {
         free(dir);
         return -1;
     }
 
     /* Add new files to simulate a remote node not being up-to-date.  We can't
      * add a new major version here without also creating an XSL transform, and
      * we can't add an older version (like 1.1 or 2.11 or something) because
      * remotes will only ever ask for stuff newer than their newest.
      */
     if (symlink_schema(dir, "pacemaker-3.0.rng", "pacemaker-3.1.rng") != 0) {
         rmdir(dir);
         free(dir);
         return -1;
     }
 
     if (symlink_schema(dir, "pacemaker-3.0.rng", "pacemaker-3.2.rng") != 0) {
         rmdir(dir);
         free(dir);
         return -1;
     }
 
     setenv("PCMK_remote_schema_directory", remote_schema_dir, 1);
     setenv("PCMK_schema_directory", PCMK__TEST_SCHEMA_DIR, 1);
 
     /* Do not call pcmk__schema_init() here because that is the function we're
      * testing. It needs to be called in each unit test. However, we can call
-     * pcmk__schema_cleanup() in teardown().
+     * pcmk__schema_cleanup() via the XML teardown function in teardown().
      */
 
     return 0;
 }
 
 static int
 teardown(void **state)
 {
     int rc = 0;
     char *f = NULL;
 
-    pcmk__schema_cleanup();
+    pcmk__xml_test_teardown_group(state);
     unsetenv("PCMK_remote_schema_directory");
     unsetenv("PCMK_schema_directory");
 
     rc = rmtree(remote_schema_dir);
 
     free(remote_schema_dir);
     free(f);
     return rc;
 }
 
 static void
 assert_schema(const char *schema_name, int schema_index)
 {
     GList *entry = NULL;
     pcmk__schema_t *schema = NULL;
 
     entry = pcmk__get_schema(schema_name);
     assert_non_null(entry);
 
     schema = entry->data;
     assert_non_null(schema);
 
     assert_int_equal(schema_index, schema->schema_index);
 }
 
 static void
 extra_schema_files(void **state)
 {
     pcmk__schema_init();
 
     /* Just iterate through the list of schemas and make sure everything
      * (including the new schemas we loaded from a second directory) is in
      * the right order.
      */
     assert_schema("pacemaker-1.0", 0);
     assert_schema("pacemaker-1.2", 1);
     assert_schema("pacemaker-2.0", 3);
     assert_schema("pacemaker-3.0", 14);
     assert_schema("pacemaker-3.1", 15);
     assert_schema("pacemaker-3.2", 16);
 
     // @COMPAT none is deprecated since 2.1.8
     assert_schema(PCMK_VALUE_NONE, 17);
 }
 
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(extra_schema_files));
diff --git a/lib/common/tests/xml/Makefile.am b/lib/common/tests/xml/Makefile.am
index 89586faadc..bc180f36e3 100644
--- a/lib/common/tests/xml/Makefile.am
+++ b/lib/common/tests/xml/Makefile.am
@@ -1,24 +1,23 @@
 #
-# Copyright 2022-2024 the Pacemaker project contributors
+# Copyright 2022-2025 the Pacemaker project contributors
 #
 # The version control history for this file may have further details.
 #
 # This source code is licensed under the GNU General Public License version 2
 # or later (GPLv2+) WITHOUT ANY WARRANTY.
 #
 
 include $(top_srcdir)/mk/common.mk
 include $(top_srcdir)/mk/tap.mk
 include $(top_srcdir)/mk/unittest.mk
 
 # Add "_test" to the end of all test program names to simplify .gitignore.
 check_PROGRAMS = \
 		 pcmk__xml_escape_test		\
-		 pcmk__xml_init_test		\
 		 pcmk__xml_is_name_char_test	\
 		 pcmk__xml_is_name_start_char_test	\
 		 pcmk__xml_needs_escape_test	\
 		 pcmk__xml_new_doc_test		\
 		 pcmk__xml_sanitize_id_test
 
 TESTS = $(check_PROGRAMS)
diff --git a/lib/common/tests/xml/pcmk__xml_init_test.c b/lib/common/tests/xml/pcmk__xml_init_test.c
deleted file mode 100644
index 301e64e2ad..0000000000
--- a/lib/common/tests/xml/pcmk__xml_init_test.c
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright 2023-2024 the Pacemaker project contributors
- *
- * The version control history for this file may have further details.
- *
- * This source code is licensed under the GNU General Public License version 2
- * or later (GPLv2+) WITHOUT ANY WARRANTY.
- */
-
-#include <crm_internal.h>
-
-#include <crm/common/unittest_internal.h>
-
-#include "crmcommon_private.h"
-
-static void
-buffer_scheme_test(void **state)
-{
-    assert_int_equal(XML_BUFFER_ALLOC_DOUBLEIT, xmlGetBufferAllocationScheme());
-}
-
-static void
-schemas_initialized(void **state)
-{
-    assert_non_null(pcmk__find_x_0_schema());
-}
-
-// The group setup/teardown functions call pcmk__xml_init()/pcmk__cml_xleanup()
-PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
-                cmocka_unit_test(buffer_scheme_test),
-                cmocka_unit_test(schemas_initialized))
diff --git a/lib/common/unittest.c b/lib/common/unittest.c
index 6bf4d94e97..ceafc9e77c 100644
--- a/lib/common/unittest.c
+++ b/lib/common/unittest.c
@@ -1,173 +1,174 @@
 /*
- * Copyright 2024 the Pacemaker project contributors
+ * Copyright 2024-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 
 #include <stdlib.h>
 #include <unistd.h>
 
+#include <libxml/parser.h>                  // xmlCleanupParser()
+
 // LCOV_EXCL_START
 
 void
 pcmk__assert_validates(xmlNode *xml)
 {
     const char *schema_dir = NULL;
     char *cmd = NULL;
     gchar *out = NULL;
     gchar *err = NULL;
     gint status;
     GError *gerr = NULL;
     char *xmllint_input = crm_strdup_printf("%s/test-xmllint.XXXXXX",
                                             pcmk__get_tmpdir());
     int fd;
     int rc;
 
     fd = mkstemp(xmllint_input);
     if (fd < 0) {
         fail_msg("Could not create temp file: %s", strerror(errno));
     }
 
     rc = pcmk__xml2fd(fd, xml);
     if (rc != pcmk_rc_ok) {
         unlink(xmllint_input);
         fail_msg("Could not write temp file: %s", pcmk_rc_str(rc));
     }
 
     close(fd);
 
     /* This should be set as part of AM_TESTS_ENVIRONMENT in Makefile.am. */
     schema_dir = getenv("PCMK_schema_directory");
     if (schema_dir == NULL) {
         unlink(xmllint_input);
         fail_msg("PCMK_schema_directory is not set in test environment");
     }
 
     cmd = crm_strdup_printf("xmllint --relaxng %s/api/api-result.rng %s",
                             schema_dir, xmllint_input);
 
     if (!g_spawn_command_line_sync(cmd, &out, &err, &status, &gerr)) {
         unlink(xmllint_input);
         fail_msg("Error occurred when performing validation: %s", gerr->message);
     }
 
     if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
         unlink(xmllint_input);
         fail_msg("XML validation failed: %s\n%s\n", out, err);
     }
 
     free(cmd);
     g_free(out);
     g_free(err);
     unlink(xmllint_input);
     free(xmllint_input);
 }
 
 /*!
  * \internal
  * \brief Perform setup for a group of unit tests that manipulate XML
  *
  * This function is suitable for being passed as the first argument to the
  * \c PCMK__UNIT_TEST macro.
  *
  * \param[in] state  Ignored
  *
  * \return 0
  */
 int
 pcmk__xml_test_setup_group(void **state)
 {
-    // Load schemas and set libxml2 buffer allocation scheme
-    pcmk__xml_init();
+    pcmk__schema_init();
     return 0;
 }
 
 /*!
  * \internal
  * \brief Perform teardown for a group of unit tests that manipulate XML
  *
  * This function is suitable for being passed as the second argument to the
  * \c PCMK__UNIT_TEST macro.
  *
  * \param[in] state  Ignored
  *
  * \return 0
  */
 int
 pcmk__xml_test_teardown_group(void **state)
 {
-    // Clean up schemas and libxml2 global memory
-    pcmk__xml_cleanup();
+    pcmk__schema_cleanup();
+    xmlCleanupParser();
     return 0;
 }
 
 char *
 pcmk__cib_test_copy_cib(const char *in_file)
 {
     char *in_path = crm_strdup_printf("%s/%s", getenv("PCMK_CTS_CLI_DIR"), in_file);
     char *out_path = NULL;
     char *contents = NULL;
     int fd;
 
     /* Copy the CIB over to a temp location so we can modify it. */
     out_path = crm_strdup_printf("%s/test-cib.XXXXXX", pcmk__get_tmpdir());
 
     fd = mkstemp(out_path);
     if (fd < 0) {
         free(out_path);
         return NULL;
     }
 
     if (pcmk__file_contents(in_path, &contents) != pcmk_rc_ok) {
         free(out_path);
         close(fd);
         return NULL;
     }
 
     if (pcmk__write_sync(fd, contents) != pcmk_rc_ok) {
         free(out_path);
         free(in_path);
         free(contents);
         close(fd);
         return NULL;
     }
 
     setenv("CIB_file", out_path, 1);
     return out_path;
 }
 
 void
 pcmk__cib_test_cleanup(char *out_path)
 {
     unlink(out_path);
     free(out_path);
     unsetenv("CIB_file");
 }
 
 /*!
  * \internal
  * \brief Initialize logging for unit testing purposes
  *
  * \param[in] name      What to use as system name for logging
  * \param[in] filename  If not NULL, enable debug logs to this file (intended
  *                      for debugging during development rather than committed
  *                      unit tests)
  */
 void
 pcmk__test_init_logging(const char *name, const char *filename)
 {
     pcmk__cli_init_logging(name, 0);
     if (filename != NULL) {
         pcmk__add_logfile(filename);
         set_crm_log_level(LOG_DEBUG);
     }
 }
 
 // LCOV_EXCL_STOP
diff --git a/lib/common/utils.c b/lib/common/utils.c
index f49f5c0b1b..774b468de1 100644
--- a/lib/common/utils.c
+++ b/lib/common/utils.c
@@ -1,500 +1,504 @@
 /*
- * Copyright 2004-2024 the Pacemaker project contributors
+ * Copyright 2004-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <sys/stat.h>
 #include <sys/utsname.h>
 
 #include <stdio.h>
 #include <unistd.h>
 #include <string.h>
 #include <stdlib.h>
 #include <limits.h>
 #include <pwd.h>
 #include <time.h>
 #include <libgen.h>
 #include <signal.h>
 #include <grp.h>
 
 #include <qb/qbdefs.h>
 
 #include <crm/crm.h>
 #include <crm/services.h>
 #include <crm/cib/internal.h>
 #include <crm/common/xml.h>
 #include <crm/common/util.h>
 #include <crm/common/ipc.h>
 #include <crm/common/iso8601.h>
 #include <crm/common/mainloop.h>
+#include <libxml/parser.h>              // xmlCleanupParser()
 #include <libxml2/libxml/relaxng.h>
 
 #include "crmcommon_private.h"
 
 CRM_TRACE_INIT_DATA(common);
 
 bool pcmk__config_has_error = false;
 bool pcmk__config_has_warning = false;
 char *crm_system_name = NULL;
 
 /*!
  * \brief Free all memory used by libcrmcommon
  *
  * Free all global memory allocated by the libcrmcommon library. This should be
  * called before exiting a process that uses the library, and the process should
  * not call any libcrmcommon or libxml2 APIs after calling this one.
  */
 void
 pcmk_common_cleanup(void)
 {
     // @TODO This isn't really everything, move all cleanup here
     mainloop_cleanup();
-    pcmk__xml_cleanup();
+    pcmk__schema_cleanup();
     pcmk__free_common_logger();
-    qb_log_fini(); // Don't log anything after this point
 
     free(crm_system_name);
     crm_system_name = NULL;
+
+    // Clean up external library global state
+    qb_log_fini(); // Don't log anything after this point
+    xmlCleanupParser();
 }
 
 bool
 pcmk__is_user_in_group(const char *user, const char *group)
 {
     struct group *grent;
     char **gr_mem;
 
     if (user == NULL || group == NULL) {
         return false;
     }
     
     setgrent();
     while ((grent = getgrent()) != NULL) {
         if (grent->gr_mem == NULL) {
             continue;
         }
 
         if(strcmp(group, grent->gr_name) != 0) {
             continue;
         }
 
         gr_mem = grent->gr_mem;
         while (*gr_mem != NULL) {
             if (!strcmp(user, *gr_mem++)) {
                 endgrent();
                 return true;
             }
         }
     }
     endgrent();
     return false;
 }
 
 int
 crm_user_lookup(const char *name, uid_t * uid, gid_t * gid)
 {
     int rc = pcmk_ok;
     char *buffer = NULL;
     struct passwd pwd;
     struct passwd *pwentry = NULL;
 
     buffer = calloc(1, PCMK__PW_BUFFER_LEN);
     if (buffer == NULL) {
         return -ENOMEM;
     }
 
     rc = getpwnam_r(name, &pwd, buffer, PCMK__PW_BUFFER_LEN, &pwentry);
     if (pwentry) {
         if (uid) {
             *uid = pwentry->pw_uid;
         }
         if (gid) {
             *gid = pwentry->pw_gid;
         }
         crm_trace("User %s has uid=%d gid=%d", name, pwentry->pw_uid, pwentry->pw_gid);
 
     } else {
         rc = rc? -rc : -EINVAL;
         crm_info("User %s lookup: %s", name, pcmk_strerror(rc));
     }
 
     free(buffer);
     return rc;
 }
 
 /*!
  * \brief Get user and group IDs of pacemaker daemon user
  *
  * \param[out] uid  If non-NULL, where to store daemon user ID
  * \param[out] gid  If non-NULL, where to store daemon group ID
  *
  * \return pcmk_ok on success, -errno otherwise
  */
 int
 pcmk_daemon_user(uid_t *uid, gid_t *gid)
 {
     static uid_t daemon_uid;
     static gid_t daemon_gid;
     static bool found = false;
     int rc = pcmk_ok;
 
     if (!found) {
         rc = crm_user_lookup(CRM_DAEMON_USER, &daemon_uid, &daemon_gid);
         if (rc == pcmk_ok) {
             found = true;
         }
     }
     if (found) {
         if (uid) {
             *uid = daemon_uid;
         }
         if (gid) {
             *gid = daemon_gid;
         }
     }
     return rc;
 }
 
 /*!
  * \internal
  * \brief Return the integer equivalent of a portion of a string
  *
  * \param[in]  text      Pointer to beginning of string portion
  * \param[out] end_text  This will point to next character after integer
  */
 static int
 version_helper(const char *text, const char **end_text)
 {
     int atoi_result = -1;
 
     pcmk__assert(end_text != NULL);
 
     errno = 0;
 
     if (text != NULL && text[0] != 0) {
         /* seemingly sacrificing const-correctness -- because while strtol
            doesn't modify the input, it doesn't want to artificially taint the
            "end_text" pointer-to-pointer-to-first-char-in-string with constness
            in case the input wasn't actually constant -- by semantic definition
            not a single character will get modified so it shall be perfectly
            safe to make compiler happy with dropping "const" qualifier here */
         atoi_result = (int) strtol(text, (char **) end_text, 10);
 
         if (errno == EINVAL) {
             crm_err("Conversion of '%s' %c failed", text, text[0]);
             atoi_result = -1;
         }
     }
     return atoi_result;
 }
 
 /*
  * version1 < version2 : -1
  * version1 = version2 :  0
  * version1 > version2 :  1
  */
 int
 compare_version(const char *version1, const char *version2)
 {
     int rc = 0;
     int lpc = 0;
     const char *ver1_iter, *ver2_iter;
 
     if (version1 == NULL && version2 == NULL) {
         return 0;
     } else if (version1 == NULL) {
         return -1;
     } else if (version2 == NULL) {
         return 1;
     }
 
     ver1_iter = version1;
     ver2_iter = version2;
 
     while (1) {
         int digit1 = 0;
         int digit2 = 0;
 
         lpc++;
 
         if (ver1_iter == ver2_iter) {
             break;
         }
 
         if (ver1_iter != NULL) {
             digit1 = version_helper(ver1_iter, &ver1_iter);
         }
 
         if (ver2_iter != NULL) {
             digit2 = version_helper(ver2_iter, &ver2_iter);
         }
 
         if (digit1 < digit2) {
             rc = -1;
             break;
 
         } else if (digit1 > digit2) {
             rc = 1;
             break;
         }
 
         if (ver1_iter != NULL && *ver1_iter == '.') {
             ver1_iter++;
         }
         if (ver1_iter != NULL && *ver1_iter == '\0') {
             ver1_iter = NULL;
         }
 
         if (ver2_iter != NULL && *ver2_iter == '.') {
             ver2_iter++;
         }
         if (ver2_iter != NULL && *ver2_iter == 0) {
             ver2_iter = NULL;
         }
     }
 
     if (rc == 0) {
         crm_trace("%s == %s (%d)", version1, version2, lpc);
     } else if (rc < 0) {
         crm_trace("%s < %s (%d)", version1, version2, lpc);
     } else if (rc > 0) {
         crm_trace("%s > %s (%d)", version1, version2, lpc);
     }
 
     return rc;
 }
 
 /*!
  * \internal
  * \brief Convert the current process to a daemon process
  *
  * Fork a child process, exit the parent, create a PID file with the current
  * process ID, and close the standard input/output/error file descriptors.
  * Exit instead if a daemon is already running and using the PID file.
  *
  * \param[in] name     Daemon executable name
  * \param[in] pidfile  File name to use as PID file
  */
 void
 pcmk__daemonize(const char *name, const char *pidfile)
 {
     int rc;
     pid_t pid;
 
     /* Check before we even try... */
     rc = pcmk__pidfile_matches(pidfile, 1, name, &pid);
     if ((rc != pcmk_rc_ok) && (rc != ENOENT)) {
         crm_err("%s: already running [pid %lld in %s]",
                 name, (long long) pid, pidfile);
         printf("%s: already running [pid %lld in %s]\n",
                name, (long long) pid, pidfile);
         crm_exit(CRM_EX_ERROR);
     }
 
     pid = fork();
     if (pid < 0) {
         fprintf(stderr, "%s: could not start daemon\n", name);
         crm_perror(LOG_ERR, "fork");
         crm_exit(CRM_EX_OSERR);
 
     } else if (pid > 0) {
         crm_exit(CRM_EX_OK);
     }
 
     rc = pcmk__lock_pidfile(pidfile, name);
     if (rc != pcmk_rc_ok) {
         crm_err("Could not lock '%s' for %s: %s " QB_XS " rc=%d",
                 pidfile, name, pcmk_rc_str(rc), rc);
         printf("Could not lock '%s' for %s: %s (%d)\n",
                pidfile, name, pcmk_rc_str(rc), rc);
         crm_exit(CRM_EX_ERROR);
     }
 
     umask(S_IWGRP | S_IWOTH | S_IROTH);
 
     close(STDIN_FILENO);
     pcmk__open_devnull(O_RDONLY);   // stdin (fd 0)
 
     close(STDOUT_FILENO);
     pcmk__open_devnull(O_WRONLY);   // stdout (fd 1)
 
     close(STDERR_FILENO);
     pcmk__open_devnull(O_WRONLY);   // stderr (fd 2)
 }
 
 #ifdef HAVE_UUID_UUID_H
 #  include <uuid/uuid.h>
 #endif
 
 char *
 crm_generate_uuid(void)
 {
     unsigned char uuid[16];
     char *buffer = malloc(37);  /* Including NUL byte */
 
     pcmk__mem_assert(buffer);
     uuid_generate(uuid);
     uuid_unparse(uuid, buffer);
     return buffer;
 }
 
 /*!
  * \internal
  * \brief Sleep for given milliseconds
  *
  * \param[in] ms  Time to sleep
  *
  * \note The full time might not be slept if a signal is received.
  */
 void
 pcmk__sleep_ms(unsigned int ms)
 {
     // @TODO Impose a sane maximum sleep to avoid hanging a process for long
     //CRM_CHECK(ms <= MAX_SLEEP, ms = MAX_SLEEP);
 
     // Use sleep() for any whole seconds
     if (ms >= 1000) {
         sleep(ms / 1000);
         ms -= ms / 1000;
     }
 
     if (ms == 0) {
         return;
     }
 
 #if defined(HAVE_NANOSLEEP)
     // nanosleep() is POSIX-2008, so prefer that
     {
         struct timespec req = { .tv_sec = 0, .tv_nsec = (long) (ms * 1000000) };
 
         nanosleep(&req, NULL);
     }
 #elif defined(HAVE_USLEEP)
     // usleep() is widely available, though considered obsolete
     usleep((useconds_t) ms);
 #else
     // Otherwise use a trick with select() timeout
     {
         struct timeval tv = { .tv_sec = 0, .tv_usec = (suseconds_t) ms };
 
         select(0, NULL, NULL, NULL, &tv);
     }
 #endif
 }
 
 /*!
  * \internal
  * \brief Add a timer
  *
  * \param[in] interval_ms The interval for the function to be called, in ms
  * \param[in] fn          The function to be called
  * \param[in] data        Data to be passed to fn (can be NULL)
  *
  * \return The ID of the event source
  */
 guint
 pcmk__create_timer(guint interval_ms, GSourceFunc fn, gpointer data)
 {
     pcmk__assert(interval_ms != 0 && fn != NULL);
 
     if (interval_ms % 1000 == 0) {
         /* In case interval_ms is 0, the call to pcmk__timeout_ms2s ensures
          * an interval of one second.
          */
         return g_timeout_add_seconds(pcmk__timeout_ms2s(interval_ms), fn, data);
     } else {
         return g_timeout_add(interval_ms, fn, data);
     }
 }
 
 /*!
  * \internal
  * \brief Convert milliseconds to seconds
  *
  * \param[in] timeout_ms The interval, in ms
  *
  * \return If \p timeout_ms is 0, return 0.  Otherwise, return the number of
  *         seconds, rounded to the nearest integer, with a minimum of 1.
  */
 guint
 pcmk__timeout_ms2s(guint timeout_ms)
 {
     guint quot, rem;
 
     if (timeout_ms == 0) {
         return 0;
     } else if (timeout_ms < 1000) {
         return 1;
     }
 
     quot = timeout_ms / 1000;
     rem = timeout_ms % 1000;
 
     if (rem >= 500) {
         quot += 1;
     }
 
     return quot;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #include <crm/common/util_compat.h>
 
 static void
 _gnutls_log_func(int level, const char *msg)
 {
     crm_trace("%s", msg);
 }
 
 void
 crm_gnutls_global_init(void)
 {
     signal(SIGPIPE, SIG_IGN);
     gnutls_global_init();
     gnutls_global_set_log_level(8);
     gnutls_global_set_log_function(_gnutls_log_func);
 }
 
 /*!
  * \brief Check whether string represents a client name used by cluster daemons
  *
  * \param[in] name  String to check
  *
  * \return true if name is standard client name used by daemons, false otherwise
  *
  * \note This is provided by the client, and so cannot be used by itself as a
  *       secure means of authentication.
  */
 bool
 crm_is_daemon_name(const char *name)
 {
     return pcmk__str_any_of(name,
                             "attrd",
                             CRM_SYSTEM_CIB,
                             CRM_SYSTEM_CRMD,
                             CRM_SYSTEM_DC,
                             CRM_SYSTEM_LRMD,
                             CRM_SYSTEM_MCP,
                             CRM_SYSTEM_PENGINE,
                             CRM_SYSTEM_TENGINE,
                             "pacemaker-attrd",
                             "pacemaker-based",
                             "pacemaker-controld",
                             "pacemaker-execd",
                             "pacemaker-fenced",
                             "pacemaker-remoted",
                             "pacemaker-schedulerd",
                             "stonith-ng",
                             "stonithd",
                             NULL);
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API
diff --git a/lib/common/xml.c b/lib/common/xml.c
index c3597511ab..68e14ac566 100644
--- a/lib/common/xml.c
+++ b/lib/common/xml.c
@@ -1,1597 +1,1559 @@
 /*
  * Copyright 2004-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <stdarg.h>
 #include <stdint.h>                     // uint32_t
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/stat.h>                   // stat(), S_ISREG, etc.
 #include <sys/types.h>
 
 #include <glib.h>                       // gboolean, GString
-#include <libxml/parser.h>              // xmlCleanupParser()
 #include <libxml/tree.h>                // xmlNode, etc.
 #include <libxml/xmlstring.h>           // xmlGetUTF8Char()
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>    // PCMK__XML_LOG_BASE, etc.
 #include "crmcommon_private.h"
 
 //! libxml2 supports only XML version 1.0, at least as of libxml2-2.12.5
 #define XML_VERSION ((pcmkXmlStr) "1.0")
 
 /*!
  * \internal
  * \brief Apply a function to each XML node in a tree (pre-order, depth-first)
  *
  * \param[in,out] xml        XML tree to traverse
  * \param[in,out] fn         Function to call for each node (returns \c true to
  *                           continue traversing the tree or \c false to stop)
  * \param[in,out] user_data  Argument to \p fn
  *
  * \return \c false if any \p fn call returned \c false, or \c true otherwise
  *
  * \note This function is recursive.
  */
 bool
 pcmk__xml_tree_foreach(xmlNode *xml, bool (*fn)(xmlNode *, void *),
                        void *user_data)
 {
     if (xml == NULL) {
         return true;
     }
 
     if (!fn(xml, user_data)) {
         return false;
     }
 
     for (xml = pcmk__xml_first_child(xml); xml != NULL;
          xml = pcmk__xml_next(xml)) {
 
         if (!pcmk__xml_tree_foreach(xml, fn, user_data)) {
             return false;
         }
     }
     return true;
 }
 
 bool
 pcmk__tracking_xml_changes(xmlNode *xml, bool lazy)
 {
     if(xml == NULL || xml->doc == NULL || xml->doc->_private == NULL) {
         return FALSE;
     } else if (!pcmk_is_set(((xml_doc_private_t *)xml->doc->_private)->flags,
                             pcmk__xf_tracking)) {
         return FALSE;
     } else if (lazy && !pcmk_is_set(((xml_doc_private_t *)xml->doc->_private)->flags,
                                     pcmk__xf_lazy)) {
         return FALSE;
     }
     return TRUE;
 }
 
 void
 pcmk__xml_set_parent_flags(xmlNode *xml, uint64_t flags)
 {
     for (; xml != NULL; xml = xml->parent) {
         xml_node_private_t *nodepriv = xml->_private;
 
         if (nodepriv != NULL) {
             pcmk__set_xml_flags(nodepriv, flags);
         }
     }
 }
 
 void
 pcmk__set_xml_doc_flag(xmlNode *xml, enum xml_private_flags flag)
 {
     if (xml != NULL) {
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         pcmk__set_xml_flags(docpriv, flag);
     }
 }
 
 // Mark document, element, and all element's parents as changed
 void
 pcmk__mark_xml_node_dirty(xmlNode *xml)
 {
     pcmk__set_xml_doc_flag(xml, pcmk__xf_dirty);
     pcmk__xml_set_parent_flags(xml, pcmk__xf_dirty);
 }
 
 /*!
  * \internal
  * \brief Clear flags on an XML node
  *
  * \param[in,out] xml        XML node whose flags to reset
  * \param[in,out] user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 bool
 pcmk__xml_reset_node_flags(xmlNode *xml, void *user_data)
 {
     xml_node_private_t *nodepriv = xml->_private;
 
     if (nodepriv != NULL) {
         nodepriv->flags = pcmk__xf_none;
     }
     return true;
 }
 
 /*!
  * \internal
  * \brief Set the \c pcmk__xf_dirty and \c pcmk__xf_created flags on an XML node
  *
  * \param[in,out] xml        Node whose flags to set
  * \param[in]     user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 mark_xml_dirty_created(xmlNode *xml, void *user_data)
 {
     xml_node_private_t *nodepriv = xml->_private;
 
     if (nodepriv != NULL) {
         pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_created);
     }
     return true;
 }
 
 /*!
  * \internal
  * \brief Mark an XML tree as dirty and created, and mark its parents dirty
  *
  * Also mark the document dirty.
  *
  * \param[in,out] xml  Tree to mark as dirty and created
  */
 static void
 mark_xml_tree_dirty_created(xmlNode *xml)
 {
     pcmk__assert(xml != NULL);
 
     if (!pcmk__tracking_xml_changes(xml, false)) {
         // Tracking is disabled for entire document
         return;
     }
 
     // Mark all parents and document dirty
     pcmk__mark_xml_node_dirty(xml);
 
     pcmk__xml_tree_foreach(xml, mark_xml_dirty_created, NULL);
 }
 
 // Free an XML object previously marked as deleted
 static void
 free_deleted_object(void *data)
 {
     if(data) {
         pcmk__deleted_xml_t *deleted_obj = data;
 
         g_free(deleted_obj->path);
         free(deleted_obj);
     }
 }
 
 // Free and NULL user, ACLs, and deleted objects in an XML node's private data
 static void
 reset_xml_private_data(xml_doc_private_t *docpriv)
 {
     if (docpriv != NULL) {
         pcmk__assert(docpriv->check == PCMK__XML_DOC_PRIVATE_MAGIC);
 
         free(docpriv->user);
         docpriv->user = NULL;
 
         if (docpriv->acls != NULL) {
             pcmk__free_acls(docpriv->acls);
             docpriv->acls = NULL;
         }
 
         if(docpriv->deleted_objs) {
             g_list_free_full(docpriv->deleted_objs, free_deleted_object);
             docpriv->deleted_objs = NULL;
         }
     }
 }
 
 /*!
  * \internal
  * \brief Allocate and initialize private data for an XML node
  *
  * \param[in,out] node       XML node whose private data to initialize
  * \param[in]     user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 new_private_data(xmlNode *node, void *user_data)
 {
     CRM_CHECK(node != NULL, return true);
 
     if (node->_private != NULL) {
         return true;
     }
 
     switch (node->type) {
         case XML_DOCUMENT_NODE:
             {
                 xml_doc_private_t *docpriv =
                     pcmk__assert_alloc(1, sizeof(xml_doc_private_t));
 
                 docpriv->check = PCMK__XML_DOC_PRIVATE_MAGIC;
                 node->_private = docpriv;
                 pcmk__set_xml_flags(docpriv, pcmk__xf_dirty|pcmk__xf_created);
             }
             break;
 
         case XML_ELEMENT_NODE:
         case XML_ATTRIBUTE_NODE:
         case XML_COMMENT_NODE:
             {
                 xml_node_private_t *nodepriv =
                     pcmk__assert_alloc(1, sizeof(xml_node_private_t));
 
                 nodepriv->check = PCMK__XML_NODE_PRIVATE_MAGIC;
                 node->_private = nodepriv;
                 pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_created);
 
                 for (xmlAttr *iter = pcmk__xe_first_attr(node); iter != NULL;
                      iter = iter->next) {
 
                     new_private_data((xmlNode *) iter, user_data);
                 }
             }
             break;
 
         case XML_TEXT_NODE:
         case XML_DTD_NODE:
         case XML_CDATA_SECTION_NODE:
             return true;
 
         default:
             CRM_LOG_ASSERT(node->type == XML_ELEMENT_NODE);
             return true;
     }
 
     if (pcmk__tracking_xml_changes(node, false)) {
         pcmk__mark_xml_node_dirty(node);
     }
     return true;
 }
 
 /*!
  * \internal
  * \brief Free private data for an XML node
  *
  * \param[in,out] node       XML node whose private data to free
  * \param[in]     user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 free_private_data(xmlNode *node, void *user_data)
 {
     CRM_CHECK(node != NULL, return true);
 
     if (node->_private == NULL) {
         return true;
     }
 
     if (node->type == XML_DOCUMENT_NODE) {
         reset_xml_private_data((xml_doc_private_t *) node->_private);
 
     } else {
         xml_node_private_t *nodepriv = node->_private;
 
         pcmk__assert(nodepriv->check == PCMK__XML_NODE_PRIVATE_MAGIC);
 
         for (xmlAttr *iter = pcmk__xe_first_attr(node); iter != NULL;
              iter = iter->next) {
 
             free_private_data((xmlNode *) iter, user_data);
         }
     }
     free(node->_private);
     node->_private = NULL;
     return true;
 }
 
 /*!
  * \internal
  * \brief Allocate and initialize private data recursively for an XML tree
  *
  * \param[in,out] node  XML node whose private data to initialize
  */
 void
 pcmk__xml_new_private_data(xmlNode *xml)
 {
     pcmk__xml_tree_foreach(xml, new_private_data, NULL);
 }
 
 /*!
  * \internal
  * \brief Free private data recursively for an XML tree
  *
  * \param[in,out] node  XML node whose private data to free
  */
 void
 pcmk__xml_free_private_data(xmlNode *xml)
 {
     pcmk__xml_tree_foreach(xml, free_private_data, NULL);
 }
 
 void
 xml_track_changes(xmlNode * xml, const char *user, xmlNode *acl_source, bool enforce_acls) 
 {
     xml_accept_changes(xml);
     crm_trace("Tracking changes%s to %p", enforce_acls?" with ACLs":"", xml);
     pcmk__set_xml_doc_flag(xml, pcmk__xf_tracking);
     if(enforce_acls) {
         if(acl_source == NULL) {
             acl_source = xml;
         }
         pcmk__set_xml_doc_flag(xml, pcmk__xf_acl_enabled);
         pcmk__unpack_acl(acl_source, xml, user);
         pcmk__apply_acl(xml);
     }
 }
 
 bool xml_tracking_changes(xmlNode * xml)
 {
     return (xml != NULL) && (xml->doc != NULL) && (xml->doc->_private != NULL)
            && pcmk_is_set(((xml_doc_private_t *)(xml->doc->_private))->flags,
                           pcmk__xf_tracking);
 }
 
 bool xml_document_dirty(xmlNode *xml) 
 {
     return (xml != NULL) && (xml->doc != NULL) && (xml->doc->_private != NULL)
            && pcmk_is_set(((xml_doc_private_t *)(xml->doc->_private))->flags,
                           pcmk__xf_dirty);
 }
 
 /*!
  * \internal
  * \brief Return ordinal position of an XML node among its siblings
  *
  * \param[in] xml            XML node to check
  * \param[in] ignore_if_set  Don't count siblings with this flag set
  *
  * \return Ordinal position of \p xml (starting with 0)
  */
 int
 pcmk__xml_position(const xmlNode *xml, enum xml_private_flags ignore_if_set)
 {
     int position = 0;
 
     for (const xmlNode *cIter = xml; cIter->prev; cIter = cIter->prev) {
         xml_node_private_t *nodepriv = ((xmlNode*)cIter->prev)->_private;
 
         if (!pcmk_is_set(nodepriv->flags, ignore_if_set)) {
             position++;
         }
     }
 
     return position;
 }
 
 /*!
  * \internal
  * \brief Remove all attributes marked as deleted from an XML node
  *
  * \param[in,out] xml        XML node whose deleted attributes to remove
  * \param[in,out] user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 accept_attr_deletions(xmlNode *xml, void *user_data)
 {
     pcmk__xml_reset_node_flags(xml, NULL);
     pcmk__xe_remove_matching_attrs(xml, pcmk__marked_as_deleted, NULL);
     return true;
 }
 
 /*!
  * \internal
  * \brief Find first child XML node matching another given XML node
  *
  * \param[in] haystack  XML whose children should be checked
  * \param[in] needle    XML to match (comment content or element name and ID)
  * \param[in] exact     If true and needle is a comment, position must match
  */
 xmlNode *
 pcmk__xml_match(const xmlNode *haystack, const xmlNode *needle, bool exact)
 {
     CRM_CHECK(needle != NULL, return NULL);
 
     if (needle->type == XML_COMMENT_NODE) {
         return pcmk__xc_match(haystack, needle, exact);
 
     } else {
         const char *id = pcmk__xe_id(needle);
         const char *attr = (id == NULL)? NULL : PCMK_XA_ID;
 
         return pcmk__xe_first_child(haystack, (const char *) needle->name, attr,
                                     id);
     }
 }
 
 void
 xml_accept_changes(xmlNode * xml)
 {
     xmlNode *top = NULL;
     xml_doc_private_t *docpriv = NULL;
 
     if(xml == NULL) {
         return;
     }
 
     crm_trace("Accepting changes to %p", xml);
     docpriv = xml->doc->_private;
     top = xmlDocGetRootElement(xml->doc);
 
     reset_xml_private_data(xml->doc->_private);
 
     if (!pcmk_is_set(docpriv->flags, pcmk__xf_dirty)) {
         docpriv->flags = pcmk__xf_none;
         return;
     }
 
     docpriv->flags = pcmk__xf_none;
     pcmk__xml_tree_foreach(top, accept_attr_deletions, NULL);
 }
 
 /*!
  * \internal
  * \brief Create a new XML document
  *
  * \return Newly allocated XML document (guaranteed not to be \c NULL)
  *
  * \note The caller is responsible for freeing the return value using
  *       \c pcmk__xml_free_doc().
  */
 xmlDoc *
 pcmk__xml_new_doc(void)
 {
     xmlDoc *doc = xmlNewDoc(XML_VERSION);
 
     pcmk__mem_assert(doc);
     pcmk__xml_new_private_data((xmlNode *) doc);
     return doc;
 }
 
 /*!
  * \internal
  * \brief Free a new XML document
  *
  * \param[in,out] doc  XML document to free
  */
 void
 pcmk__xml_free_doc(xmlDoc *doc)
 {
     if (doc != NULL) {
         pcmk__xml_free_private_data((xmlNode *) doc);
         xmlFreeDoc(doc);
     }
 }
 
 /*!
  * \internal
  * \brief Check whether the first character of a string is an XML NameStartChar
  *
  * See https://www.w3.org/TR/xml/#NT-NameStartChar.
  *
  * This is almost identical to libxml2's \c xmlIsDocNameStartChar(), but they
  * don't expose it as part of the public API.
  *
  * \param[in]  utf8  UTF-8 encoded string
  * \param[out] len   If not \c NULL, where to store size in bytes of first
  *                   character in \p utf8
  *
  * \return \c true if \p utf8 begins with a valid XML NameStartChar, or \c false
  *         otherwise
  */
 bool
 pcmk__xml_is_name_start_char(const char *utf8, int *len)
 {
     int c = 0;
     int local_len = 0;
 
     if (len == NULL) {
         len = &local_len;
     }
 
     /* xmlGetUTF8Char() abuses the len argument. At call time, it must be set to
      * "the minimum number of bytes present in the sequence... to assure the
      * next character is completely contained within the sequence." It's similar
      * to the "n" in the strn*() functions. However, this doesn't make any sense
      * for null-terminated strings, and there's no value that indicates "keep
      * going until '\0'." So we set it to 4, the max number of bytes in a UTF-8
      * character.
      *
      * At return, it's set to the actual number of bytes in the char, or 0 on
      * error.
      */
     *len = 4;
 
     // Note: xmlGetUTF8Char() assumes a 32-bit int
     c = xmlGetUTF8Char((pcmkXmlStr) utf8, len);
     if (c < 0) {
         GString *buf = g_string_sized_new(32);
 
         for (int i = 0; (i < 4) && (utf8[i] != '\0'); i++) {
             g_string_append_printf(buf, " 0x%.2X", utf8[i]);
         }
         crm_info("Invalid UTF-8 character (bytes:%s)",
                  (pcmk__str_empty(buf->str)? " <none>" : buf->str));
         g_string_free(buf, TRUE);
         return false;
     }
 
     return (c == '_')
            || (c == ':')
            || ((c >= 'a') && (c <= 'z'))
            || ((c >= 'A') && (c <= 'Z'))
            || ((c >= 0xC0) && (c <= 0xD6))
            || ((c >= 0xD8) && (c <= 0xF6))
            || ((c >= 0xF8) && (c <= 0x2FF))
            || ((c >= 0x370) && (c <= 0x37D))
            || ((c >= 0x37F) && (c <= 0x1FFF))
            || ((c >= 0x200C) && (c <= 0x200D))
            || ((c >= 0x2070) && (c <= 0x218F))
            || ((c >= 0x2C00) && (c <= 0x2FEF))
            || ((c >= 0x3001) && (c <= 0xD7FF))
            || ((c >= 0xF900) && (c <= 0xFDCF))
            || ((c >= 0xFDF0) && (c <= 0xFFFD))
            || ((c >= 0x10000) && (c <= 0xEFFFF));
 }
 
 /*!
  * \internal
  * \brief Check whether the first character of a string is an XML NameChar
  *
  * See https://www.w3.org/TR/xml/#NT-NameChar.
  *
  * This is almost identical to libxml2's \c xmlIsDocNameChar(), but they don't
  * expose it as part of the public API.
  *
  * \param[in]  utf8  UTF-8 encoded string
  * \param[out] len   If not \c NULL, where to store size in bytes of first
  *                   character in \p utf8
  *
  * \return \c true if \p utf8 begins with a valid XML NameChar, or \c false
  *         otherwise
  */
 bool
 pcmk__xml_is_name_char(const char *utf8, int *len)
 {
     int c = 0;
     int local_len = 0;
 
     if (len == NULL) {
         len = &local_len;
     }
 
     // See comment regarding len in pcmk__xml_is_name_start_char()
     *len = 4;
 
     // Note: xmlGetUTF8Char() assumes a 32-bit int
     c = xmlGetUTF8Char((pcmkXmlStr) utf8, len);
     if (c < 0) {
         GString *buf = g_string_sized_new(32);
 
         for (int i = 0; (i < 4) && (utf8[i] != '\0'); i++) {
             g_string_append_printf(buf, " 0x%.2X", utf8[i]);
         }
         crm_info("Invalid UTF-8 character (bytes:%s)",
                  (pcmk__str_empty(buf->str)? " <none>" : buf->str));
         g_string_free(buf, TRUE);
         return false;
     }
 
     return ((c >= 'a') && (c <= 'z'))
            || ((c >= 'A') && (c <= 'Z'))
            || ((c >= '0') && (c <= '9'))
            || (c == '_')
            || (c == ':')
            || (c == '-')
            || (c == '.')
            || (c == 0xB7)
            || ((c >= 0xC0) && (c <= 0xD6))
            || ((c >= 0xD8) && (c <= 0xF6))
            || ((c >= 0xF8) && (c <= 0x2FF))
            || ((c >= 0x300) && (c <= 0x36F))
            || ((c >= 0x370) && (c <= 0x37D))
            || ((c >= 0x37F) && (c <= 0x1FFF))
            || ((c >= 0x200C) && (c <= 0x200D))
            || ((c >= 0x203F) && (c <= 0x2040))
            || ((c >= 0x2070) && (c <= 0x218F))
            || ((c >= 0x2C00) && (c <= 0x2FEF))
            || ((c >= 0x3001) && (c <= 0xD7FF))
            || ((c >= 0xF900) && (c <= 0xFDCF))
            || ((c >= 0xFDF0) && (c <= 0xFFFD))
            || ((c >= 0x10000) && (c <= 0xEFFFF));
 }
 
 /*!
  * \internal
  * \brief Sanitize a string so it is usable as an XML ID
  *
  * An ID must match the Name production as defined here:
  * https://www.w3.org/TR/xml/#NT-Name.
  *
  * Convert an invalid start character to \c '_'. Convert an invalid character
  * after the start character to \c '.'.
  *
  * \param[in,out] id  String to sanitize
  */
 void
 pcmk__xml_sanitize_id(char *id)
 {
     bool valid = true;
     int len = 0;
 
     // If id is empty or NULL, there's no way to make it a valid XML ID
     pcmk__assert(!pcmk__str_empty(id));
 
     /* @TODO Suppose there are two strings and each has an invalid ID character
      * in the same position. The strings are otherwise identical. Both strings
      * will be sanitized to the same valid ID, which is incorrect.
      *
      * The caller is responsible for ensuring the sanitized ID does not already
      * exist in a given XML document before using it, if uniqueness is desired.
      */
     valid = pcmk__xml_is_name_start_char(id, &len);
     CRM_CHECK(len > 0, return); // UTF-8 encoding error
     if (!valid) {
         *id = '_';
         for (int i = 1; i < len; i++) {
             id[i] = '.';
         }
     }
 
     for (id += len; *id != '\0'; id += len) {
         valid = pcmk__xml_is_name_char(id, &len);
         CRM_CHECK(len > 0, return); // UTF-8 encoding error
         if (!valid) {
             for (int i = 0; i < len; i++) {
                 id[i] = '.';
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Free an XML tree without ACL checks or change tracking
  *
  * \param[in,out] xml  XML node to free
  */
 void
 pcmk__xml_free_node(xmlNode *xml)
 {
     pcmk__xml_free_private_data(xml);
     xmlUnlinkNode(xml);
     xmlFreeNode(xml);
 }
 
 /*!
  * \internal
  * \brief Free an XML tree if ACLs allow; track deletion if tracking is enabled
  *
  * If \p node is the root of its document, free the entire document.
  *
  * \param[in,out] node      XML node to free
  * \param[in]     position  Position of \p node among its siblings for change
  *                          tracking (negative to calculate automatically if
  *                          needed)
  */
 static void
 free_xml_with_position(xmlNode *node, int position)
 {
     xmlDoc *doc = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     if (node == NULL) {
         return;
     }
     doc = node->doc;
     nodepriv = node->_private;
 
     if ((doc != NULL) && (xmlDocGetRootElement(doc) == node)) {
         /* @TODO Should we check ACLs first? Otherwise it seems like we could
          * free the root element without write permission.
          */
         pcmk__xml_free_doc(doc);
         return;
     }
 
     if (!pcmk__check_acl(node, NULL, pcmk__xf_acl_write)) {
         GString *xpath = NULL;
 
         pcmk__if_tracing({}, return);
         xpath = pcmk__element_xpath(node);
         qb_log_from_external_source(__func__, __FILE__,
                                     "Cannot remove %s %x", LOG_TRACE,
                                     __LINE__, 0, xpath->str, nodepriv->flags);
         g_string_free(xpath, TRUE);
         return;
     }
 
     if ((doc != NULL) && pcmk__tracking_xml_changes(node, false)
         && !pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
 
         xml_doc_private_t *docpriv = doc->_private;
         GString *xpath = pcmk__element_xpath(node);
 
         if (xpath != NULL) {
             pcmk__deleted_xml_t *deleted_obj = NULL;
 
             crm_trace("Deleting %s %p from %p", xpath->str, node, doc);
 
             deleted_obj = pcmk__assert_alloc(1, sizeof(pcmk__deleted_xml_t));
             deleted_obj->path = g_string_free(xpath, FALSE);
             deleted_obj->position = -1;
 
             // Record the position only for XML comments for now
             if (node->type == XML_COMMENT_NODE) {
                 if (position >= 0) {
                     deleted_obj->position = position;
 
                 } else {
                     deleted_obj->position = pcmk__xml_position(node,
                                                                pcmk__xf_skip);
                 }
             }
 
             docpriv->deleted_objs = g_list_append(docpriv->deleted_objs,
                                                   deleted_obj);
             pcmk__set_xml_doc_flag(node, pcmk__xf_dirty);
         }
     }
     pcmk__xml_free_node(node);
 }
 
 /*!
  * \internal
  * \brief Free an XML tree if ACLs allow; track deletion if tracking is enabled
  *
  * If \p xml is the root of its document, free the entire document.
  *
  * \param[in,out] xml  XML node to free
  */
 void
 pcmk__xml_free(xmlNode *xml)
 {
     free_xml_with_position(xml, -1);
 }
 
 /*!
  * \internal
  * \brief Make a deep copy of an XML node under a given parent
  *
  * \param[in,out] parent  XML element that will be the copy's parent (\c NULL
  *                        to create a new XML document with the copy as root)
  * \param[in]     src     XML node to copy
  *
  * \return Deep copy of \p src, or \c NULL if \p src is \c NULL
  */
 xmlNode *
 pcmk__xml_copy(xmlNode *parent, xmlNode *src)
 {
     xmlNode *copy = NULL;
 
     if (src == NULL) {
         return NULL;
     }
 
     if (parent == NULL) {
         xmlDoc *doc = NULL;
 
         // The copy will be the root element of a new document
         pcmk__assert(src->type == XML_ELEMENT_NODE);
 
         doc = pcmk__xml_new_doc();
         copy = xmlDocCopyNode(src, doc, 1);
         pcmk__mem_assert(copy);
 
         xmlDocSetRootElement(doc, copy);
 
     } else {
         copy = xmlDocCopyNode(src, parent->doc, 1);
         pcmk__mem_assert(copy);
 
         xmlAddChild(parent, copy);
     }
 
     pcmk__xml_new_private_data(copy);
     return copy;
 }
 
 /*!
  * \internal
  * \brief Remove XML text nodes from specified XML and all its children
  *
  * \param[in,out] xml  XML to strip text from
  */
 void
 pcmk__strip_xml_text(xmlNode *xml)
 {
     xmlNode *iter = xml->children;
 
     while (iter) {
         xmlNode *next = iter->next;
 
         switch (iter->type) {
             case XML_TEXT_NODE:
                 pcmk__xml_free_node(iter);
                 break;
 
             case XML_ELEMENT_NODE:
                 /* Search it */
                 pcmk__strip_xml_text(iter);
                 break;
 
             default:
                 /* Leave it */
                 break;
         }
 
         iter = next;
     }
 }
 
 /*!
  * \internal
  * \brief Check whether a string has XML special characters that must be escaped
  *
  * See \c pcmk__xml_escape() and \c pcmk__xml_escape_type for more details.
  *
  * \param[in] text  String to check
  * \param[in] type  Type of escaping
  *
  * \return \c true if \p text has special characters that need to be escaped, or
  *         \c false otherwise
  */
 bool
 pcmk__xml_needs_escape(const char *text, enum pcmk__xml_escape_type type)
 {
     if (text == NULL) {
         return false;
     }
 
     while (*text != '\0') {
         switch (type) {
             case pcmk__xml_escape_text:
                 switch (*text) {
                     case '<':
                     case '>':
                     case '&':
                         return true;
                     case '\n':
                     case '\t':
                         break;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             return true;
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr:
                 switch (*text) {
                     case '<':
                     case '>':
                     case '&':
                     case '"':
                         return true;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             return true;
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr_pretty:
                 switch (*text) {
                     case '\n':
                     case '\r':
                     case '\t':
                     case '"':
                         return true;
                     default:
                         break;
                 }
                 break;
 
             default:    // Invalid enum value
                 pcmk__assert(false);
                 break;
         }
 
         text = g_utf8_next_char(text);
     }
     return false;
 }
 
 /*!
  * \internal
  * \brief Replace special characters with their XML escape sequences
  *
  * \param[in] text  Text to escape
  * \param[in] type  Type of escaping
  *
  * \return Newly allocated string equivalent to \p text but with special
  *         characters replaced with XML escape sequences (or \c NULL if \p text
  *         is \c NULL). If \p text is not \c NULL, the return value is
  *         guaranteed not to be \c NULL.
  *
  * \note There are libxml functions that purport to do this:
  *       \c xmlEncodeEntitiesReentrant() and \c xmlEncodeSpecialChars().
  *       However, their escaping is incomplete. See:
  *       https://discourse.gnome.org/t/intended-use-of-xmlencodeentitiesreentrant-vs-xmlencodespecialchars/19252
  * \note The caller is responsible for freeing the return value using
  *       \c g_free().
  */
 gchar *
 pcmk__xml_escape(const char *text, enum pcmk__xml_escape_type type)
 {
     GString *copy = NULL;
 
     if (text == NULL) {
         return NULL;
     }
     copy = g_string_sized_new(strlen(text));
 
     while (*text != '\0') {
         // Don't escape any non-ASCII characters
         if ((*text & 0x80) != 0) {
             size_t bytes = g_utf8_next_char(text) - text;
 
             g_string_append_len(copy, text, bytes);
             text += bytes;
             continue;
         }
 
         switch (type) {
             case pcmk__xml_escape_text:
                 switch (*text) {
                     case '<':
                         g_string_append(copy, PCMK__XML_ENTITY_LT);
                         break;
                     case '>':
                         g_string_append(copy, PCMK__XML_ENTITY_GT);
                         break;
                     case '&':
                         g_string_append(copy, PCMK__XML_ENTITY_AMP);
                         break;
                     case '\n':
                     case '\t':
                         g_string_append_c(copy, *text);
                         break;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             g_string_append_printf(copy, "&#x%.2X;", *text);
                         } else {
                             g_string_append_c(copy, *text);
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr:
                 switch (*text) {
                     case '<':
                         g_string_append(copy, PCMK__XML_ENTITY_LT);
                         break;
                     case '>':
                         g_string_append(copy, PCMK__XML_ENTITY_GT);
                         break;
                     case '&':
                         g_string_append(copy, PCMK__XML_ENTITY_AMP);
                         break;
                     case '"':
                         g_string_append(copy, PCMK__XML_ENTITY_QUOT);
                         break;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             g_string_append_printf(copy, "&#x%.2X;", *text);
                         } else {
                             g_string_append_c(copy, *text);
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr_pretty:
                 switch (*text) {
                     case '"':
                         g_string_append(copy, "\\\"");
                         break;
                     case '\n':
                         g_string_append(copy, "\\n");
                         break;
                     case '\r':
                         g_string_append(copy, "\\r");
                         break;
                     case '\t':
                         g_string_append(copy, "\\t");
                         break;
                     default:
                         g_string_append_c(copy, *text);
                         break;
                 }
                 break;
 
             default:    // Invalid enum value
                 pcmk__assert(false);
                 break;
         }
 
         text = g_utf8_next_char(text);
     }
     return g_string_free(copy, FALSE);
 }
 
 /*!
  * \internal
  * \brief Set a flag on all attributes of an XML element
  *
  * \param[in,out] xml   XML node to set flags on
  * \param[in]     flag  XML private flag to set
  */
 static void
 set_attrs_flag(xmlNode *xml, enum xml_private_flags flag)
 {
     for (xmlAttr *attr = pcmk__xe_first_attr(xml); attr; attr = attr->next) {
         pcmk__set_xml_flags((xml_node_private_t *) (attr->_private), flag);
     }
 }
 
 /*!
  * \internal
  * \brief Add an XML attribute to a node, marked as deleted
  *
  * When calculating XML changes, we need to know when an attribute has been
  * deleted. Add the attribute back to the new XML, so that we can check the
  * removal against ACLs, and mark it as deleted for later removal after
  * differences have been calculated.
  *
  * \param[in,out] new_xml     XML to modify
  * \param[in]     element     Name of XML element that changed (for logging)
  * \param[in]     attr_name   Name of attribute that was deleted
  * \param[in]     old_value   Value of attribute that was deleted
  */
 static void
 mark_attr_deleted(xmlNode *new_xml, const char *element, const char *attr_name,
                   const char *old_value)
 {
     xml_doc_private_t *docpriv = new_xml->doc->_private;
     xmlAttr *attr = NULL;
     xml_node_private_t *nodepriv;
 
     /* Restore the old value (without setting dirty flag recursively upwards or
      * checking ACLs)
      */
     pcmk__clear_xml_flags(docpriv, pcmk__xf_tracking);
     crm_xml_add(new_xml, attr_name, old_value);
     pcmk__set_xml_flags(docpriv, pcmk__xf_tracking);
 
     // Reset flags (so the attribute doesn't appear as newly created)
     attr = xmlHasProp(new_xml, (pcmkXmlStr) attr_name);
     nodepriv = attr->_private;
     nodepriv->flags = 0;
 
     // Check ACLs and mark restored value for later removal
     pcmk__xa_remove(attr, false);
 
     crm_trace("XML attribute %s=%s was removed from %s",
               attr_name, old_value, element);
 }
 
 /*
  * \internal
  * \brief Check ACLs for a changed XML attribute
  */
 static void
 mark_attr_changed(xmlNode *new_xml, const char *element, const char *attr_name,
                   const char *old_value)
 {
     xml_doc_private_t *docpriv = new_xml->doc->_private;
     char *vcopy = crm_element_value_copy(new_xml, attr_name);
 
     crm_trace("XML attribute %s was changed from '%s' to '%s' in %s",
               attr_name, old_value, vcopy, element);
 
     // Restore the original value (without checking ACLs)
     pcmk__clear_xml_flags(docpriv, pcmk__xf_tracking);
     crm_xml_add(new_xml, attr_name, old_value);
     pcmk__set_xml_flags(docpriv, pcmk__xf_tracking);
 
     // Change it back to the new value, to check ACLs
     crm_xml_add(new_xml, attr_name, vcopy);
     free(vcopy);
 }
 
 /*!
  * \internal
  * \brief Mark an XML attribute as having changed position
  *
  * \param[in,out] new_xml     XML to modify
  * \param[in]     element     Name of XML element that changed (for logging)
  * \param[in,out] old_attr    Attribute that moved, in original XML
  * \param[in,out] new_attr    Attribute that moved, in \p new_xml
  * \param[in]     p_old       Ordinal position of \p old_attr in original XML
  * \param[in]     p_new       Ordinal position of \p new_attr in \p new_xml
  */
 static void
 mark_attr_moved(xmlNode *new_xml, const char *element, xmlAttr *old_attr,
                 xmlAttr *new_attr, int p_old, int p_new)
 {
     xml_node_private_t *nodepriv = new_attr->_private;
 
     crm_trace("XML attribute %s moved from position %d to %d in %s",
               old_attr->name, p_old, p_new, element);
 
     // Mark document, element, and all element's parents as changed
     pcmk__mark_xml_node_dirty(new_xml);
 
     // Mark attribute as changed
     pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_moved);
 
     nodepriv = (p_old > p_new)? old_attr->_private : new_attr->_private;
     pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 }
 
 /*!
  * \internal
  * \brief Calculate differences in all previously existing XML attributes
  *
  * \param[in,out] old_xml  Original XML to compare
  * \param[in,out] new_xml  New XML to compare
  */
 static void
 xml_diff_old_attrs(xmlNode *old_xml, xmlNode *new_xml)
 {
     xmlAttr *attr_iter = pcmk__xe_first_attr(old_xml);
 
     while (attr_iter != NULL) {
         const char *name = (const char *) attr_iter->name;
         xmlAttr *old_attr = attr_iter;
         xmlAttr *new_attr = xmlHasProp(new_xml, attr_iter->name);
         const char *old_value = pcmk__xml_attr_value(attr_iter);
 
         attr_iter = attr_iter->next;
         if (new_attr == NULL) {
             mark_attr_deleted(new_xml, (const char *) old_xml->name, name,
                               old_value);
 
         } else {
             xml_node_private_t *nodepriv = new_attr->_private;
             int new_pos = pcmk__xml_position((xmlNode*) new_attr,
                                              pcmk__xf_skip);
             int old_pos = pcmk__xml_position((xmlNode*) old_attr,
                                              pcmk__xf_skip);
             const char *new_value = crm_element_value(new_xml, name);
 
             // This attribute isn't new
             pcmk__clear_xml_flags(nodepriv, pcmk__xf_created);
 
             if (strcmp(new_value, old_value) != 0) {
                 mark_attr_changed(new_xml, (const char *) old_xml->name, name,
                                   old_value);
 
             } else if ((old_pos != new_pos)
                        && !pcmk__tracking_xml_changes(new_xml, TRUE)) {
                 mark_attr_moved(new_xml, (const char *) old_xml->name,
                                 old_attr, new_attr, old_pos, new_pos);
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Check all attributes in new XML for creation
  *
  * For each of a given XML element's attributes marked as newly created, accept
  * (and mark as dirty) or reject the creation according to ACLs.
  *
  * \param[in,out] new_xml  XML to check
  */
 static void
 mark_created_attrs(xmlNode *new_xml)
 {
     xmlAttr *attr_iter = pcmk__xe_first_attr(new_xml);
 
     while (attr_iter != NULL) {
         xmlAttr *new_attr = attr_iter;
         xml_node_private_t *nodepriv = attr_iter->_private;
 
         attr_iter = attr_iter->next;
         if (pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
             const char *attr_name = (const char *) new_attr->name;
 
             crm_trace("Created new attribute %s=%s in %s",
                       attr_name, pcmk__xml_attr_value(new_attr),
                       new_xml->name);
 
             /* Check ACLs (we can't use the remove-then-create trick because it
              * would modify the attribute position).
              */
             if (pcmk__check_acl(new_xml, attr_name, pcmk__xf_acl_write)) {
                 pcmk__mark_xml_attr_dirty(new_attr);
             } else {
                 // Creation was not allowed, so remove the attribute
                 pcmk__xa_remove(new_attr, true);
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Calculate differences in attributes between two XML nodes
  *
  * \param[in,out] old_xml  Original XML to compare
  * \param[in,out] new_xml  New XML to compare
  */
 static void
 xml_diff_attrs(xmlNode *old_xml, xmlNode *new_xml)
 {
     set_attrs_flag(new_xml, pcmk__xf_created); // cleared later if not really new
     xml_diff_old_attrs(old_xml, new_xml);
     mark_created_attrs(new_xml);
 }
 
 /*!
  * \internal
  * \brief Add an XML child element to a node, marked as deleted
  *
  * When calculating XML changes, we need to know when a child element has been
  * deleted. Add the child back to the new XML, so that we can check the removal
  * against ACLs, and mark it as deleted for later removal after differences have
  * been calculated.
  *
  * \param[in,out] old_child    Child element from original XML
  * \param[in,out] new_parent   New XML to add marked copy to
  */
 static void
 mark_child_deleted(xmlNode *old_child, xmlNode *new_parent)
 {
     // Re-create the child element so we can check ACLs
     xmlNode *candidate = pcmk__xml_copy(new_parent, old_child);
 
     // Clear flags on new child and its children
     pcmk__xml_tree_foreach(candidate, pcmk__xml_reset_node_flags, NULL);
 
     // Check whether ACLs allow the deletion
     pcmk__apply_acl(xmlDocGetRootElement(candidate->doc));
 
     // Remove the child again (which will track it in document's deleted_objs)
     free_xml_with_position(candidate,
                            pcmk__xml_position(old_child, pcmk__xf_skip));
 
     if (pcmk__xml_match(new_parent, old_child, true) == NULL) {
         pcmk__set_xml_flags((xml_node_private_t *) (old_child->_private),
                             pcmk__xf_skip);
     }
 }
 
 static void
 mark_child_moved(xmlNode *old_child, xmlNode *new_parent, xmlNode *new_child,
                  int p_old, int p_new)
 {
     xml_node_private_t *nodepriv = new_child->_private;
 
     crm_trace("Child element %s with "
               PCMK_XA_ID "='%s' moved from position %d to %d under %s",
               new_child->name, pcmk__s(pcmk__xe_id(new_child), "<no id>"),
               p_old, p_new, new_parent->name);
     pcmk__mark_xml_node_dirty(new_parent);
     pcmk__set_xml_flags(nodepriv, pcmk__xf_moved);
 
     if (p_old > p_new) {
         nodepriv = old_child->_private;
     } else {
         nodepriv = new_child->_private;
     }
     pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 }
 
 // Given original and new XML, mark new XML portions that have changed
 static void
 mark_xml_changes(xmlNode *old_xml, xmlNode *new_xml, bool check_top)
 {
     xmlNode *old_child = NULL;
     xmlNode *new_child = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     CRM_CHECK(new_xml != NULL, return);
     if (old_xml == NULL) {
         mark_xml_tree_dirty_created(new_xml);
         pcmk__apply_creation_acl(new_xml, check_top);
         return;
     }
 
     nodepriv = new_xml->_private;
     CRM_CHECK(nodepriv != NULL, return);
 
     if(nodepriv->flags & pcmk__xf_processed) {
         /* Avoid re-comparing nodes */
         return;
     }
     pcmk__set_xml_flags(nodepriv, pcmk__xf_processed);
 
     xml_diff_attrs(old_xml, new_xml);
 
     // Check for differences in the original children
     for (old_child = pcmk__xml_first_child(old_xml); old_child != NULL;
          old_child = pcmk__xml_next(old_child)) {
 
         new_child = pcmk__xml_match(new_xml, old_child, true);
 
         if (new_child != NULL) {
             mark_xml_changes(old_child, new_child, true);
 
         } else {
             mark_child_deleted(old_child, new_xml);
         }
     }
 
     // Check for moved or created children
     new_child = pcmk__xml_first_child(new_xml);
     while (new_child != NULL) {
         xmlNode *next = pcmk__xml_next(new_child);
 
         old_child = pcmk__xml_match(old_xml, new_child, true);
 
         if (old_child == NULL) {
             // This is a newly created child
             nodepriv = new_child->_private;
             pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 
             // May free new_child
             mark_xml_changes(old_child, new_child, true);
 
         } else {
             /* Check for movement, we already checked for differences */
             int p_new = pcmk__xml_position(new_child, pcmk__xf_skip);
             int p_old = pcmk__xml_position(old_child, pcmk__xf_skip);
 
             if(p_old != p_new) {
                 mark_child_moved(old_child, new_xml, new_child, p_old, p_new);
             }
         }
 
         new_child = next;
     }
 }
 
 void
 xml_calculate_significant_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
     pcmk__set_xml_doc_flag(new_xml, pcmk__xf_lazy);
     xml_calculate_changes(old_xml, new_xml);
 }
 
 // Called functions may set the \p pcmk__xf_skip flag on parts of \p old_xml
 void
 xml_calculate_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
     CRM_CHECK((old_xml != NULL) && (new_xml != NULL)
               && pcmk__xe_is(old_xml, (const char *) new_xml->name)
               && pcmk__str_eq(pcmk__xe_id(old_xml), pcmk__xe_id(new_xml),
                               pcmk__str_none),
               return);
 
     if(xml_tracking_changes(new_xml) == FALSE) {
         xml_track_changes(new_xml, NULL, NULL, FALSE);
     }
 
     mark_xml_changes(old_xml, new_xml, FALSE);
 }
 
-/*!
- * \internal
- * \brief Initialize the Pacemaker XML environment
- *
- * Set an XML buffer allocation scheme, set XML node create and destroy
- * callbacks, and load schemas into the cache.
- */
-void
-pcmk__xml_init(void)
-{
-    // @TODO Try to find a better caller than crm_log_preinit()
-    static bool initialized = false;
-
-    if (!initialized) {
-        initialized = true;
-
-        /* Double the buffer size when the buffer needs to grow. The default
-         * allocator XML_BUFFER_ALLOC_EXACT was found to cause poor performance
-         * due to the number of reallocs.
-         */
-        xmlSetBufferAllocationScheme(XML_BUFFER_ALLOC_DOUBLEIT);
-
-        // Load schemas into the cache
-        pcmk__schema_init();
-    }
-}
-
-/*!
- * \internal
- * \brief Tear down the Pacemaker XML environment
- *
- * Destroy schema cache and clean up memory allocated by libxml2.
- */
-void
-pcmk__xml_cleanup(void)
-{
-    pcmk__schema_cleanup();
-    xmlCleanupParser();
-}
-
 char *
 pcmk__xml_artefact_root(enum pcmk__xml_artefact_ns ns)
 {
     static const char *base = NULL;
     char *ret = NULL;
 
     if (base == NULL) {
         base = pcmk__env_option(PCMK__ENV_SCHEMA_DIRECTORY);
     }
     if (pcmk__str_empty(base)) {
         base = PCMK_SCHEMA_DIR;
     }
 
     switch (ns) {
         case pcmk__xml_artefact_ns_legacy_rng:
         case pcmk__xml_artefact_ns_legacy_xslt:
             ret = strdup(base);
             break;
         case pcmk__xml_artefact_ns_base_rng:
         case pcmk__xml_artefact_ns_base_xslt:
             ret = crm_strdup_printf("%s/base", base);
             break;
         default:
             crm_err("XML artefact family specified as %u not recognized", ns);
     }
     return ret;
 }
 
 static char *
 find_artefact(enum pcmk__xml_artefact_ns ns, const char *path, const char *filespec)
 {
     char *ret = NULL;
 
     switch (ns) {
         case pcmk__xml_artefact_ns_legacy_rng:
         case pcmk__xml_artefact_ns_base_rng:
             if (pcmk__ends_with(filespec, ".rng")) {
                 ret = crm_strdup_printf("%s/%s", path, filespec);
             } else {
                 ret = crm_strdup_printf("%s/%s.rng", path, filespec);
             }
             break;
         case pcmk__xml_artefact_ns_legacy_xslt:
         case pcmk__xml_artefact_ns_base_xslt:
             if (pcmk__ends_with(filespec, ".xsl")) {
                 ret = crm_strdup_printf("%s/%s", path, filespec);
             } else {
                 ret = crm_strdup_printf("%s/%s.xsl", path, filespec);
             }
             break;
         default:
             crm_err("XML artefact family specified as %u not recognized", ns);
     }
 
     return ret;
 }
 
 char *
 pcmk__xml_artefact_path(enum pcmk__xml_artefact_ns ns, const char *filespec)
 {
     struct stat sb;
     char *base = pcmk__xml_artefact_root(ns);
     char *ret = NULL;
 
     ret = find_artefact(ns, base, filespec);
     free(base);
 
     if (stat(ret, &sb) != 0 || !S_ISREG(sb.st_mode)) {
         const char *remote_schema_dir = pcmk__remote_schema_dir();
 
         free(ret);
         ret = find_artefact(ns, remote_schema_dir, filespec);
     }
 
     return ret;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
+#include <libxml/parser.h>              // xmlCleanupParser()
+
 #include <crm/common/xml_compat.h>
 
 xmlNode *
 copy_xml(xmlNode *src)
 {
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlNode *copy = NULL;
 
     copy = xmlDocCopyNode(src, doc, 1);
     pcmk__mem_assert(copy);
 
     xmlDocSetRootElement(doc, copy);
     pcmk__xml_new_private_data(copy);
     return copy;
 }
 
 void
 crm_xml_init(void)
 {
-    pcmk__xml_init();
+    pcmk__schema_init();
 }
 
 void
 crm_xml_cleanup(void)
 {
-    pcmk__xml_cleanup();
+    pcmk__schema_cleanup();
+    xmlCleanupParser();
 }
 
 void
 pcmk_free_xml_subtree(xmlNode *xml)
 {
     pcmk__xml_free_node(xml);
 }
 
 void
 free_xml(xmlNode *child)
 {
     pcmk__xml_free(child);
 }
 
 void
 crm_xml_sanitize_id(char *id)
 {
     char *c;
 
     for (c = id; *c; ++c) {
         switch (*c) {
             case ':':
             case '#':
                 *c = '.';
         }
     }
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API
diff --git a/lib/pengine/tests/native/native_find_rsc_test.c b/lib/pengine/tests/native/native_find_rsc_test.c
index 676d22a88d..b5c6a4bac3 100644
--- a/lib/pengine/tests/native/native_find_rsc_test.c
+++ b/lib/pengine/tests/native/native_find_rsc_test.c
@@ -1,933 +1,933 @@
 /*
- * Copyright 2022-2024 the Pacemaker project contributors
+ * Copyright 2022-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 #include <crm/common/scheduler.h>
 #include <crm/common/xml.h>
 #include <crm/pengine/internal.h>
 #include <crm/pengine/status.h>
 
 xmlNode *input = NULL;
 pcmk_scheduler_t *scheduler = NULL;
 
 pcmk_node_t *cluster01, *cluster02, *httpd_bundle_0;
 pcmk_resource_t *exim_group, *inactive_group;
 pcmk_resource_t *promotable_clone, *inactive_clone;
 pcmk_resource_t *httpd_bundle, *mysql_clone_group;
 
 static int
 setup(void **state) {
     char *path = NULL;
 
-    pcmk__xml_init();
+    pcmk__xml_test_setup_group(state);
 
     path = crm_strdup_printf("%s/crm_mon.xml", getenv("PCMK_CTS_CLI_DIR"));
     input = pcmk__xml_read(path);
     free(path);
 
     if (input == NULL) {
         return 1;
     }
 
     scheduler = pe_new_working_set();
 
     if (scheduler == NULL) {
         return 1;
     }
 
     pcmk__set_scheduler_flags(scheduler, pcmk__sched_no_counts);
     scheduler->input = input;
 
     cluster_status(scheduler);
 
     /* Get references to the cluster nodes so we don't have to find them repeatedly. */
     cluster01 = pcmk_find_node(scheduler, "cluster01");
     cluster02 = pcmk_find_node(scheduler, "cluster02");
     httpd_bundle_0 = pcmk_find_node(scheduler, "httpd-bundle-0");
 
     /* Get references to several resources we use frequently. */
     for (GList *iter = scheduler->priv->resources;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "exim-group") == 0) {
             exim_group = rsc;
         } else if (strcmp(rsc->id, "httpd-bundle") == 0) {
             httpd_bundle = rsc;
         } else if (strcmp(rsc->id, "inactive-clone") == 0) {
             inactive_clone = rsc;
         } else if (strcmp(rsc->id, "inactive-group") == 0) {
             inactive_group = rsc;
         } else if (strcmp(rsc->id, "mysql-clone-group") == 0) {
             mysql_clone_group = rsc;
         } else if (strcmp(rsc->id, "promotable-clone") == 0) {
             promotable_clone = rsc;
         }
     }
 
     return 0;
 }
 
 static int
 teardown(void **state) {
     pe_free_working_set(scheduler);
-    pcmk__xml_cleanup();
+    pcmk__xml_test_teardown_group(state);
     return 0;
 }
 
 static void
 bad_args(void **state) {
     pcmk_resource_t *rsc = g_list_first(scheduler->priv->resources)->data;
     char *id = rsc->id;
     char *name = NULL;
 
     assert_non_null(rsc);
 
     assert_null(native_find_rsc(NULL, "dummy", NULL, 0));
     assert_null(native_find_rsc(rsc, NULL, NULL, 0));
 
     /* No resources exist with these names. */
     name = crm_strdup_printf("%sX", rsc->id);
     assert_null(native_find_rsc(rsc, name, NULL, 0));
     free(name);
 
     name = crm_strdup_printf("x%s", rsc->id);
     assert_null(native_find_rsc(rsc, name, NULL, 0));
     free(name);
 
     name = g_ascii_strup(rsc->id, -1);
     assert_null(native_find_rsc(rsc, name, NULL, 0));
     g_free(name);
 
     /* Fails because resource ID is NULL. */
     rsc->id = NULL;
     assert_null(native_find_rsc(rsc, id, NULL, 0));
     rsc->id = id;
 }
 
 static void
 primitive_rsc(void **state) {
     pcmk_resource_t *dummy = NULL;
 
     /* Find the "dummy" resource, which is the only one with that ID in the set. */
     for (GList *iter = scheduler->priv->resources;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "dummy") == 0) {
             dummy = rsc;
             break;
         }
     }
 
     assert_non_null(dummy);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(dummy, native_find_rsc(dummy, "dummy", NULL, 0));
     assert_ptr_equal(dummy,
                      native_find_rsc(dummy, "dummy", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Fails because resource is not a clone (nor cloned). */
     assert_null(native_find_rsc(dummy, "dummy", NULL,
                                 pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(dummy, "dummy", cluster02,
                                 pcmk_rsc_match_clone_only));
 
     /* Fails because dummy is not running on cluster01, even with the right flags. */
     assert_null(native_find_rsc(dummy, "dummy", cluster01,
                                 pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(dummy, "dummy", cluster02, 0));
 
     /* Passes because dummy is running on cluster02. */
     assert_ptr_equal(dummy,
                      native_find_rsc(dummy, "dummy", cluster02,
                                      pcmk_rsc_match_current_node));
 }
 
 static void
 group_rsc(void **state) {
     assert_non_null(exim_group);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(exim_group, native_find_rsc(exim_group, "exim-group", NULL, 0));
     assert_ptr_equal(exim_group,
                      native_find_rsc(exim_group, "exim-group", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Fails because resource is not a clone (nor cloned). */
     assert_null(native_find_rsc(exim_group, "exim-group", NULL,
                                 pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(exim_group, "exim-group", cluster01,
                                 pcmk_rsc_match_clone_only));
 
     /* Fails because none of exim-group's children are running on cluster01, even with the right flags. */
     assert_null(native_find_rsc(exim_group, "exim-group", cluster01,
                                 pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(exim_group, "exim-group", cluster01, 0));
 
     /* Passes because one of exim-group's children is running on cluster02. */
     assert_ptr_equal(exim_group,
                      native_find_rsc(exim_group, "exim-group", cluster02,
                                      pcmk_rsc_match_current_node));
 }
 
 static void
 inactive_group_rsc(void **state) {
     assert_non_null(inactive_group);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(inactive_group, native_find_rsc(inactive_group, "inactive-group", NULL, 0));
     assert_ptr_equal(inactive_group,
                      native_find_rsc(inactive_group, "inactive-group", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Fails because resource is not a clone (nor cloned). */
     assert_null(native_find_rsc(inactive_group, "inactive-group", NULL,
                                 pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(inactive_group, "inactive-group", cluster01,
                                 pcmk_rsc_match_clone_only));
 
     /* Fails because none of inactive-group's children are running. */
     assert_null(native_find_rsc(inactive_group, "inactive-group", cluster01,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(inactive_group, "inactive-group", cluster02,
                                 pcmk_rsc_match_current_node));
 }
 
 static void
 group_member_rsc(void **state) {
     pcmk_resource_t *public_ip = NULL;
 
     /* Find the "Public-IP" resource, a member of "exim-group". */
     for (GList *iter = exim_group->priv->children;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "Public-IP") == 0) {
             public_ip = rsc;
             break;
         }
     }
 
     assert_non_null(public_ip);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(public_ip, native_find_rsc(public_ip, "Public-IP", NULL, 0));
     assert_ptr_equal(public_ip,
                      native_find_rsc(public_ip, "Public-IP", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Fails because resource is not a clone (nor cloned). */
     assert_null(native_find_rsc(public_ip, "Public-IP", NULL,
                                 pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(public_ip, "Public-IP", cluster02,
                                 pcmk_rsc_match_clone_only));
 
     /* Fails because Public-IP is not running on cluster01, even with the right flags. */
     assert_null(native_find_rsc(public_ip, "Public-IP", cluster01,
                                 pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(public_ip, "Public-IP", cluster02, 0));
 
     /* Passes because Public-IP is running on cluster02. */
     assert_ptr_equal(public_ip,
                      native_find_rsc(public_ip, "Public-IP", cluster02,
                                      pcmk_rsc_match_current_node));
 }
 
 static void
 inactive_group_member_rsc(void **state) {
     pcmk_resource_t *inactive_dummy_1 = NULL;
 
     /* Find the "inactive-dummy-1" resource, a member of "inactive-group". */
     for (GList *iter = inactive_group->priv->children;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "inactive-dummy-1") == 0) {
             inactive_dummy_1 = rsc;
             break;
         }
     }
 
     assert_non_null(inactive_dummy_1);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(inactive_dummy_1, native_find_rsc(inactive_dummy_1, "inactive-dummy-1", NULL, 0));
     assert_ptr_equal(inactive_dummy_1,
                      native_find_rsc(inactive_dummy_1, "inactive-dummy-1", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Fails because resource is not a clone (nor cloned). */
     assert_null(native_find_rsc(inactive_dummy_1, "inactive-dummy-1", NULL,
                                 pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(inactive_dummy_1, "inactive-dummy-1", cluster01,
                                 pcmk_rsc_match_clone_only));
 
     /* Fails because inactive-dummy-1 is not running. */
     assert_null(native_find_rsc(inactive_dummy_1, "inactive-dummy-1", cluster01,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(inactive_dummy_1, "inactive-dummy-1", cluster02,
                                 pcmk_rsc_match_current_node));
 }
 
 static void
 clone_rsc(void **state) {
     assert_non_null(promotable_clone);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(promotable_clone, native_find_rsc(promotable_clone, "promotable-clone", NULL, 0));
     assert_ptr_equal(promotable_clone,
                      native_find_rsc(promotable_clone, "promotable-clone", NULL,
                                      pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_clone,
                      native_find_rsc(promotable_clone, "promotable-clone", NULL,
                                      pcmk_rsc_match_clone_only));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(promotable_clone, "promotable-clone", cluster01, 0));
 
     /* Passes because one of ping-clone's children is running on cluster01. */
     assert_ptr_equal(promotable_clone,
                      native_find_rsc(promotable_clone, "promotable-clone",
                                      cluster01, pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(promotable_clone, "promotable-clone", cluster02, 0));
 
     /* Passes because one of ping_clone's children is running on cluster02. */
     assert_ptr_equal(promotable_clone,
                      native_find_rsc(promotable_clone, "promotable-clone",
                                      cluster02, pcmk_rsc_match_current_node));
 
     // Passes for previous reasons, plus includes pcmk_rsc_match_clone_only
     assert_ptr_equal(promotable_clone,
                      native_find_rsc(promotable_clone, "promotable-clone",
                                      cluster01,
                                      pcmk_rsc_match_clone_only
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_clone,
                      native_find_rsc(promotable_clone, "promotable-clone",
                                      cluster02,
                                      pcmk_rsc_match_clone_only
                                      |pcmk_rsc_match_current_node));
 }
 
 static void
 inactive_clone_rsc(void **state) {
     assert_non_null(inactive_clone);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(inactive_clone, native_find_rsc(inactive_clone, "inactive-clone", NULL, 0));
     assert_ptr_equal(inactive_clone,
                      native_find_rsc(inactive_clone, "inactive-clone", NULL,
                                      pcmk_rsc_match_current_node));
     assert_ptr_equal(inactive_clone,
                      native_find_rsc(inactive_clone, "inactive-clone", NULL,
                                      pcmk_rsc_match_clone_only));
 
     /* Fails because none of inactive-clone's children are running. */
     assert_null(native_find_rsc(inactive_clone, "inactive-clone", cluster01,
                                 pcmk_rsc_match_current_node
                                 |pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(inactive_clone, "inactive-clone", cluster02,
                                 pcmk_rsc_match_current_node
                                 |pcmk_rsc_match_clone_only));
 }
 
 static void
 clone_instance_rsc(void **state) {
     pcmk_resource_t *promotable_0 = NULL;
     pcmk_resource_t *promotable_1 = NULL;
 
     /* Find the "promotable-rsc:0" and "promotable-rsc:1" resources, members of "promotable-clone". */
     for (GList *iter = promotable_clone->priv->children;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "promotable-rsc:0") == 0) {
             promotable_0 = rsc;
         } else if (strcmp(rsc->id, "promotable-rsc:1") == 0) {
             promotable_1 = rsc;
         }
     }
 
     assert_non_null(promotable_0);
     assert_non_null(promotable_1);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(promotable_0, native_find_rsc(promotable_0, "promotable-rsc:0", NULL, 0));
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc:0", NULL,
                                      pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1, native_find_rsc(promotable_1, "promotable-rsc:1", NULL, 0));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc:1", NULL,
                                      pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(promotable_0, "promotable-rsc:0", cluster02, 0));
     assert_null(native_find_rsc(promotable_1, "promotable-rsc:1", cluster01, 0));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc:0",
                                      cluster02, pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_0, "promotable-rsc:0", cluster01,
                                 pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc:1",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_1, "promotable-rsc:1", cluster02,
                                 pcmk_rsc_match_current_node));
 
     /* Passes because NULL was passed for node and primitive name was given, with correct flags. */
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc", NULL,
                                      pcmk_rsc_match_clone_only));
 
     // Passes because pcmk_rsc_match_basename matches any instance's base name
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc", NULL,
                                      pcmk_rsc_match_basename));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc", NULL,
                                      pcmk_rsc_match_basename));
 
     // Passes because pcmk_rsc_match_anon_basename matches
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc", NULL,
                                      pcmk_rsc_match_anon_basename));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc", NULL,
                                      pcmk_rsc_match_anon_basename));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc", cluster02,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc", cluster02,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_0, "promotable-rsc", cluster01,
                                 pcmk_rsc_match_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_0, "promotable-rsc", cluster01,
                                 pcmk_rsc_match_anon_basename
                                 |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc", cluster01,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc", cluster01,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_1, "promotable-rsc", cluster02,
                                 pcmk_rsc_match_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_1, "promotable-rsc", cluster02,
                                 pcmk_rsc_match_anon_basename
                                 |pcmk_rsc_match_current_node));
 
     /* Fails because incorrect flags were given along with primitive name. */
     assert_null(native_find_rsc(promotable_0, "promotable-rsc", NULL,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(promotable_1, "promotable-rsc", NULL,
                                 pcmk_rsc_match_current_node));
 
     /* And then we check failure possibilities again, except passing promotable_clone
      * instead of promotable_X as the first argument to native_find_rsc.
      */
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(promotable_clone, "promotable-rsc:0", cluster02, 0));
     assert_null(native_find_rsc(promotable_clone, "promotable-rsc:1", cluster01, 0));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_clone, "promotable-rsc:0",
                                      cluster02, pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_clone, "promotable-rsc",
                                      cluster02,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_clone, "promotable-rsc",
                                      cluster02,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_clone, "promotable-rsc:1",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_clone, "promotable-rsc",
                                      cluster01,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_clone, "promotable-rsc",
                                      cluster01,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
 }
 
 static void
 renamed_rsc(void **state) {
     pcmk_resource_t *promotable_0 = NULL;
     pcmk_resource_t *promotable_1 = NULL;
 
     /* Find the "promotable-rsc:0" and "promotable-rsc:1" resources, members of "promotable-clone". */
     for (GList *iter = promotable_clone->priv->children;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "promotable-rsc:0") == 0) {
             promotable_0 = rsc;
         } else if (strcmp(rsc->id, "promotable-rsc:1") == 0) {
             promotable_1 = rsc;
         }
     }
 
     assert_non_null(promotable_0);
     assert_non_null(promotable_1);
 
     // Passes because pcmk_rsc_match_history means base name matches history_id
     assert_ptr_equal(promotable_0,
                      native_find_rsc(promotable_0, "promotable-rsc", NULL,
                                      pcmk_rsc_match_history));
     assert_ptr_equal(promotable_1,
                      native_find_rsc(promotable_1, "promotable-rsc", NULL,
                                      pcmk_rsc_match_history));
 }
 
 static void
 bundle_rsc(void **state) {
     assert_non_null(httpd_bundle);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(httpd_bundle, native_find_rsc(httpd_bundle, "httpd-bundle", NULL, 0));
     assert_ptr_equal(httpd_bundle,
                      native_find_rsc(httpd_bundle, "httpd-bundle", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Fails because resource is not a clone (nor cloned). */
     assert_null(native_find_rsc(httpd_bundle, "httpd-bundle", NULL,
                                 pcmk_rsc_match_clone_only));
     assert_null(native_find_rsc(httpd_bundle, "httpd-bundle", cluster01,
                                 pcmk_rsc_match_clone_only));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(httpd_bundle, "httpd-bundle", cluster01, 0));
 
     /* Passes because one of httpd_bundle's children is running on cluster01. */
     assert_ptr_equal(httpd_bundle,
                      native_find_rsc(httpd_bundle, "httpd-bundle", cluster01,
                                      pcmk_rsc_match_current_node));
 }
 
 static bool
 bundle_first_replica(pcmk__bundle_replica_t *replica, void *user_data)
 {
     pcmk_resource_t *ip_0 = replica->ip;
     pcmk_resource_t *child_0 = replica->child;
     pcmk_resource_t *container_0 = replica->container;
     pcmk_resource_t *remote_0 = replica->remote;
 
     assert_non_null(ip_0);
     assert_non_null(child_0);
     assert_non_null(container_0);
     assert_non_null(remote_0);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(ip_0, native_find_rsc(ip_0, "httpd-bundle-ip-192.168.122.131", NULL, 0));
     assert_ptr_equal(child_0, native_find_rsc(child_0, "httpd:0", NULL, 0));
     assert_ptr_equal(container_0, native_find_rsc(container_0, "httpd-bundle-docker-0", NULL, 0));
     assert_ptr_equal(remote_0, native_find_rsc(remote_0, "httpd-bundle-0", NULL, 0));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(ip_0, "httpd-bundle-ip-192.168.122.131", cluster01, 0));
     assert_null(native_find_rsc(child_0, "httpd:0", httpd_bundle_0, 0));
     assert_null(native_find_rsc(container_0, "httpd-bundle-docker-0", cluster01, 0));
     assert_null(native_find_rsc(remote_0, "httpd-bundle-0", cluster01, 0));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(ip_0,
                      native_find_rsc(ip_0, "httpd-bundle-ip-192.168.122.131",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(ip_0, "httpd-bundle-ip-192.168.122.131",
                                 cluster02, pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(ip_0, "httpd-bundle-ip-192.168.122.131",
                                 httpd_bundle_0, pcmk_rsc_match_current_node));
     assert_ptr_equal(child_0,
                      native_find_rsc(child_0, "httpd:0", httpd_bundle_0,
                                      pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(child_0, "httpd:0", cluster01,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(child_0, "httpd:0", cluster02,
                                 pcmk_rsc_match_current_node));
     assert_ptr_equal(container_0,
                      native_find_rsc(container_0, "httpd-bundle-docker-0",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(container_0, "httpd-bundle-docker-0", cluster02,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(container_0, "httpd-bundle-docker-0",
                                 httpd_bundle_0, pcmk_rsc_match_current_node));
     assert_ptr_equal(remote_0,
                      native_find_rsc(remote_0, "httpd-bundle-0", cluster01,
                                      pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(remote_0, "httpd-bundle-0", cluster02,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(remote_0, "httpd-bundle-0", httpd_bundle_0,
                                 pcmk_rsc_match_current_node));
 
     // Passes because pcmk_rsc_match_basename matches any replica's base name
     assert_ptr_equal(child_0,
                      native_find_rsc(child_0, "httpd", NULL,
                                      pcmk_rsc_match_basename));
 
     // Passes because pcmk_rsc_match_anon_basename matches
     assert_ptr_equal(child_0,
                      native_find_rsc(child_0, "httpd", NULL,
                                      pcmk_rsc_match_anon_basename));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(child_0,
                      native_find_rsc(child_0, "httpd", httpd_bundle_0,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(child_0,
                      native_find_rsc(child_0, "httpd", httpd_bundle_0,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(child_0, "httpd", cluster01,
                                 pcmk_rsc_match_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(child_0, "httpd", cluster01,
                                 pcmk_rsc_match_anon_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(child_0, "httpd", cluster02,
                                 pcmk_rsc_match_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(child_0, "httpd", cluster02,
                                 pcmk_rsc_match_anon_basename
                                 |pcmk_rsc_match_current_node));
 
     /* Fails because incorrect flags were given along with base name. */
     assert_null(native_find_rsc(child_0, "httpd", NULL,
                                 pcmk_rsc_match_current_node));
 
     /* And then we check failure possibilities again, except passing httpd-bundle
      * instead of X_0 as the first argument to native_find_rsc.
      */
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(httpd_bundle, "httpd-bundle-ip-192.168.122.131", cluster01, 0));
     assert_null(native_find_rsc(httpd_bundle, "httpd:0", httpd_bundle_0, 0));
     assert_null(native_find_rsc(httpd_bundle, "httpd-bundle-docker-0", cluster01, 0));
     assert_null(native_find_rsc(httpd_bundle, "httpd-bundle-0", cluster01, 0));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(ip_0,
                      native_find_rsc(httpd_bundle,
                                      "httpd-bundle-ip-192.168.122.131",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_ptr_equal(child_0,
                      native_find_rsc(httpd_bundle, "httpd:0", httpd_bundle_0,
                                      pcmk_rsc_match_current_node));
     assert_ptr_equal(container_0,
                      native_find_rsc(httpd_bundle, "httpd-bundle-docker-0",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_ptr_equal(remote_0,
                      native_find_rsc(httpd_bundle, "httpd-bundle-0", cluster01,
                                      pcmk_rsc_match_current_node));
     return false; // Do not iterate through any further replicas
 }
 
 static void
 bundle_replica_rsc(void **state)
 {
     pe__foreach_bundle_replica(httpd_bundle, bundle_first_replica, NULL);
 }
 
 static void
 clone_group_rsc(void **rsc) {
     assert_non_null(mysql_clone_group);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(mysql_clone_group, native_find_rsc(mysql_clone_group, "mysql-clone-group", NULL, 0));
     assert_ptr_equal(mysql_clone_group,
                      native_find_rsc(mysql_clone_group, "mysql-clone-group",
                                      NULL, pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_clone_group,
                      native_find_rsc(mysql_clone_group, "mysql-clone-group",
                                      NULL, pcmk_rsc_match_clone_only));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(mysql_clone_group, "mysql-clone-group", cluster01, 0));
 
     /* Passes because one of mysql-clone-group's children is running on cluster01. */
     assert_ptr_equal(mysql_clone_group,
                      native_find_rsc(mysql_clone_group, "mysql-clone-group",
                                      cluster01, pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(mysql_clone_group, "mysql-clone-group", cluster02, 0));
 
     /* Passes because one of mysql-clone-group's children is running on cluster02. */
     assert_ptr_equal(mysql_clone_group,
                      native_find_rsc(mysql_clone_group, "mysql-clone-group",
                                      cluster02, pcmk_rsc_match_current_node));
 
     // Passes for previous reasons, plus includes pcmk_rsc_match_clone_only
     assert_ptr_equal(mysql_clone_group,
                      native_find_rsc(mysql_clone_group, "mysql-clone-group",
                                      cluster01,
                                      pcmk_rsc_match_clone_only
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_clone_group,
                      native_find_rsc(mysql_clone_group, "mysql-clone-group",
                                      cluster02,
                                      pcmk_rsc_match_clone_only
                                      |pcmk_rsc_match_current_node));
 }
 
 static void
 clone_group_instance_rsc(void **rsc) {
     pcmk_resource_t *mysql_group_0 = NULL;
     pcmk_resource_t *mysql_group_1 = NULL;
 
     /* Find the "mysql-group:0" and "mysql-group:1" resources, members of "mysql-clone-group". */
     for (GList *iter = mysql_clone_group->priv->children;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "mysql-group:0") == 0) {
             mysql_group_0 = rsc;
         } else if (strcmp(rsc->id, "mysql-group:1") == 0) {
             mysql_group_1 = rsc;
         }
     }
 
     assert_non_null(mysql_group_0);
     assert_non_null(mysql_group_1);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(mysql_group_0, native_find_rsc(mysql_group_0, "mysql-group:0", NULL, 0));
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group:0", NULL,
                                      pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1, native_find_rsc(mysql_group_1, "mysql-group:1", NULL, 0));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_group_1, "mysql-group:1", NULL,
                                      pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(mysql_group_0, "mysql-group:0", cluster02, 0));
     assert_null(native_find_rsc(mysql_group_1, "mysql-group:1", cluster01, 0));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group:0", cluster02,
                                      pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_0, "mysql-group:0", cluster01,
                                 pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_group_1, "mysql-group:1", cluster01,
                                      pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_1, "mysql-group:1", cluster02,
                                 pcmk_rsc_match_current_node));
 
     /* Passes because NULL was passed for node and base name was given, with correct flags. */
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group" , NULL,
                                      pcmk_rsc_match_clone_only));
 
     // Passes because pcmk_rsc_match_basename matches any base name
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group" , NULL,
                                      pcmk_rsc_match_basename));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_group_1, "mysql-group" , NULL,
                                      pcmk_rsc_match_basename));
 
     // Passes because pcmk_rsc_match_anon_basename matches
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group" , NULL,
                                      pcmk_rsc_match_anon_basename));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_group_1, "mysql-group" , NULL,
                                      pcmk_rsc_match_anon_basename));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group", cluster02,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_group_0, "mysql-group", cluster02,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_0, "mysql-group", cluster01,
                                 pcmk_rsc_match_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_0, "mysql-group", cluster01,
                                 pcmk_rsc_match_anon_basename
                                 |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_group_1, "mysql-group", cluster01,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_group_1, "mysql-group", cluster01,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_1, "mysql-group", cluster02,
                                 pcmk_rsc_match_basename
                                 |pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_1, "mysql-group", cluster02,
                                 pcmk_rsc_match_anon_basename
                                 |pcmk_rsc_match_current_node));
 
     /* Fails because incorrect flags were given along with base name. */
     assert_null(native_find_rsc(mysql_group_0, "mysql-group", NULL,
                                 pcmk_rsc_match_current_node));
     assert_null(native_find_rsc(mysql_group_1, "mysql-group", NULL,
                                 pcmk_rsc_match_current_node));
 
     /* And then we check failure possibilities again, except passing mysql_clone_group
      * instead of mysql_group_X as the first argument to native_find_rsc.
      */
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(mysql_clone_group, "mysql-group:0", cluster02, 0));
     assert_null(native_find_rsc(mysql_clone_group, "mysql-group:1", cluster01, 0));
 
     /* Check that the resource is running on the node we expect. */
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_clone_group, "mysql-group:0",
                                      cluster02, pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_clone_group, "mysql-group",
                                      cluster02,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_0,
                      native_find_rsc(mysql_clone_group, "mysql-group",
                                      cluster02,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_clone_group, "mysql-group:1",
                                      cluster01, pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_clone_group, "mysql-group",
                                      cluster01,
                                      pcmk_rsc_match_basename
                                      |pcmk_rsc_match_current_node));
     assert_ptr_equal(mysql_group_1,
                      native_find_rsc(mysql_clone_group, "mysql-group",
                                      cluster01,
                                      pcmk_rsc_match_anon_basename
                                      |pcmk_rsc_match_current_node));
 }
 
 static void
 clone_group_member_rsc(void **state) {
     pcmk_resource_t *mysql_proxy = NULL;
 
     /* Find the "mysql-proxy" resource, a member of "mysql-group". */
     for (GList *iter = mysql_clone_group->priv->children;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "mysql-group:0") == 0) {
             for (GList *iter2 = rsc->priv->children;
                  iter2 != NULL; iter2 = iter2->next) {
                 pcmk_resource_t *child = (pcmk_resource_t *) iter2->data;
 
                 if (strcmp(child->id, "mysql-proxy:0") == 0) {
                     mysql_proxy = child;
                     break;
                 }
             }
 
             break;
         }
     }
 
     assert_non_null(mysql_proxy);
 
     /* Passes because NULL was passed for node, regardless of flags. */
     assert_ptr_equal(mysql_proxy, native_find_rsc(mysql_proxy, "mysql-proxy:0", NULL, 0));
     assert_ptr_equal(mysql_proxy,
                      native_find_rsc(mysql_proxy, "mysql-proxy:0", NULL,
                                      pcmk_rsc_match_current_node));
 
     /* Passes because resource's parent is a clone. */
     assert_ptr_equal(mysql_proxy,
                      native_find_rsc(mysql_proxy, "mysql-proxy:0", NULL,
                                      pcmk_rsc_match_clone_only));
     assert_ptr_equal(mysql_proxy,
                      native_find_rsc(mysql_proxy, "mysql-proxy:0", cluster02,
                                      pcmk_rsc_match_clone_only
                                      |pcmk_rsc_match_current_node));
 
     /* Fails because mysql-proxy:0 is not running on cluster01, even with the right flags. */
     assert_null(native_find_rsc(mysql_proxy, "mysql-proxy:0", cluster01,
                                 pcmk_rsc_match_current_node));
 
     // Fails because pcmk_rsc_match_current_node is required if a node is given
     assert_null(native_find_rsc(mysql_proxy, "mysql-proxy:0", cluster02, 0));
 
     /* Passes because mysql-proxy:0 is running on cluster02. */
     assert_ptr_equal(mysql_proxy,
                      native_find_rsc(mysql_proxy, "mysql-proxy:0", cluster02,
                                      pcmk_rsc_match_current_node));
 }
 
 /* TODO: Add tests for finding on assigned node (passing a node without
  * pcmk_rsc_match_current_node, after scheduling, for a resource that is
  * starting/stopping/moving.
  */
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(bad_args),
                 cmocka_unit_test(primitive_rsc),
                 cmocka_unit_test(group_rsc),
                 cmocka_unit_test(inactive_group_rsc),
                 cmocka_unit_test(group_member_rsc),
                 cmocka_unit_test(inactive_group_member_rsc),
                 cmocka_unit_test(clone_rsc),
                 cmocka_unit_test(inactive_clone_rsc),
                 cmocka_unit_test(clone_instance_rsc),
                 cmocka_unit_test(renamed_rsc),
                 cmocka_unit_test(bundle_rsc),
                 cmocka_unit_test(bundle_replica_rsc),
                 cmocka_unit_test(clone_group_rsc),
                 cmocka_unit_test(clone_group_instance_rsc),
                 cmocka_unit_test(clone_group_member_rsc))
diff --git a/lib/pengine/tests/native/pe_base_name_eq_test.c b/lib/pengine/tests/native/pe_base_name_eq_test.c
index 1a08480974..eba5991a8e 100644
--- a/lib/pengine/tests/native/pe_base_name_eq_test.c
+++ b/lib/pengine/tests/native/pe_base_name_eq_test.c
@@ -1,159 +1,159 @@
 /*
- * Copyright 2022-2024 the Pacemaker project contributors
+ * Copyright 2022-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/scheduler.h>
 #include <crm/pengine/internal.h>
 #include <crm/pengine/status.h>
 
 xmlNode *input = NULL;
 pcmk_scheduler_t *scheduler = NULL;
 
 pcmk_resource_t *exim_group, *promotable_0, *promotable_1, *dummy;
 pcmk_resource_t *httpd_bundle, *mysql_group_0, *mysql_group_1;
 
 static int
 setup(void **state) {
     char *path = NULL;
 
-    pcmk__xml_init();
+    pcmk__xml_test_setup_group(state);
 
     path = crm_strdup_printf("%s/crm_mon.xml", getenv("PCMK_CTS_CLI_DIR"));
     input = pcmk__xml_read(path);
     free(path);
 
     if (input == NULL) {
         return 1;
     }
 
     scheduler = pe_new_working_set();
 
     if (scheduler == NULL) {
         return 1;
     }
 
     pcmk__set_scheduler_flags(scheduler, pcmk__sched_no_counts);
     scheduler->input = input;
 
     cluster_status(scheduler);
 
     /* Get references to several resources we use frequently. */
     for (GList *iter = scheduler->priv->resources;
          iter != NULL; iter = iter->next) {
 
         pcmk_resource_t *rsc = (pcmk_resource_t *) iter->data;
 
         if (strcmp(rsc->id, "dummy") == 0) {
             dummy = rsc;
 
         } else if (strcmp(rsc->id, "exim-group") == 0) {
             exim_group = rsc;
 
         } else if (strcmp(rsc->id, "httpd-bundle") == 0) {
             httpd_bundle = rsc;
 
         } else if (strcmp(rsc->id, "mysql-clone-group") == 0) {
             for (GList *iter = rsc->priv->children;
                  iter != NULL; iter = iter->next) {
 
                 pcmk_resource_t *child = (pcmk_resource_t *) iter->data;
 
                 if (strcmp(child->id, "mysql-group:0") == 0) {
                     mysql_group_0 = child;
                 } else if (strcmp(child->id, "mysql-group:1") == 0) {
                     mysql_group_1 = child;
                 }
             }
 
         } else if (strcmp(rsc->id, "promotable-clone") == 0) {
             for (GList *iter = rsc->priv->children;
                  iter != NULL; iter = iter->next) {
 
                 pcmk_resource_t *child = (pcmk_resource_t *) iter->data;
 
                 if (strcmp(child->id, "promotable-rsc:0") == 0) {
                     promotable_0 = child;
                 } else if (strcmp(child->id, "promotable-rsc:1") == 0) {
                     promotable_1 = child;
                 }
             }
         }
     }
 
     return 0;
 }
 
 static int
 teardown(void **state) {
     pe_free_working_set(scheduler);
-    pcmk__xml_cleanup();
+    pcmk__xml_test_teardown_group(state);
     return 0;
 }
 
 static void
 bad_args(void **state) {
     char *id = dummy->id;
 
     assert_false(pe_base_name_eq(NULL, "dummy"));
     assert_false(pe_base_name_eq(dummy, NULL));
 
     dummy->id = NULL;
     assert_false(pe_base_name_eq(dummy, "dummy"));
     dummy->id = id;
 }
 
 static void
 primitive_rsc(void **state) {
     assert_true(pe_base_name_eq(dummy, "dummy"));
     assert_false(pe_base_name_eq(dummy, "DUMMY"));
     assert_false(pe_base_name_eq(dummy, "dUmMy"));
     assert_false(pe_base_name_eq(dummy, "dummy0"));
     assert_false(pe_base_name_eq(dummy, "dummy:0"));
 }
 
 static void
 group_rsc(void **state) {
     assert_true(pe_base_name_eq(exim_group, "exim-group"));
     assert_false(pe_base_name_eq(exim_group, "EXIM-GROUP"));
     assert_false(pe_base_name_eq(exim_group, "exim-group0"));
     assert_false(pe_base_name_eq(exim_group, "exim-group:0"));
     assert_false(pe_base_name_eq(exim_group, "Public-IP"));
 }
 
 static void
 clone_rsc(void **state) {
     assert_true(pe_base_name_eq(promotable_0, "promotable-rsc"));
     assert_true(pe_base_name_eq(promotable_1, "promotable-rsc"));
 
     assert_false(pe_base_name_eq(promotable_0, "promotable-rsc:0"));
     assert_false(pe_base_name_eq(promotable_1, "promotable-rsc:1"));
     assert_false(pe_base_name_eq(promotable_0, "PROMOTABLE-RSC"));
     assert_false(pe_base_name_eq(promotable_1, "PROMOTABLE-RSC"));
     assert_false(pe_base_name_eq(promotable_0, "Promotable-rsc"));
     assert_false(pe_base_name_eq(promotable_1, "Promotable-rsc"));
 }
 
 static void
 bundle_rsc(void **state) {
     assert_true(pe_base_name_eq(httpd_bundle, "httpd-bundle"));
     assert_false(pe_base_name_eq(httpd_bundle, "HTTPD-BUNDLE"));
     assert_false(pe_base_name_eq(httpd_bundle, "httpd"));
     assert_false(pe_base_name_eq(httpd_bundle, "httpd-docker-0"));
 }
 
 PCMK__UNIT_TEST(setup, teardown,
                 cmocka_unit_test(bad_args),
                 cmocka_unit_test(primitive_rsc),
                 cmocka_unit_test(group_rsc),
                 cmocka_unit_test(clone_rsc),
                 cmocka_unit_test(bundle_rsc))