diff --git a/lib/common/acl.c b/lib/common/acl.c
index 66db534c1d..9cdafa7016 100644
--- a/lib/common/acl.c
+++ b/lib/common/acl.c
@@ -1,923 +1,923 @@
 /*
  * 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 <stdio.h>
 #include <sys/types.h>
 #include <pwd.h>
 #include <string.h>
 #include <stdlib.h>
 #include <stdarg.h>
 
 #include <libxml/tree.h>                // xmlNode, etc.
 #include <libxml/xmlstring.h>           // xmlChar
 #include <libxml/xpath.h>               // xmlXPathObject, etc.
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>
 #include "crmcommon_private.h"
 
 typedef struct xml_acl_s {
         enum xml_private_flags mode;
         gchar *xpath;
 } xml_acl_t;
 
 static void
 free_acl(void *data)
 {
     if (data) {
         xml_acl_t *acl = data;
 
         g_free(acl->xpath);
         free(acl);
     }
 }
 
 void
 pcmk__free_acls(GList *acls)
 {
     g_list_free_full(acls, free_acl);
 }
 
 static GList *
 create_acl(const xmlNode *xml, GList *acls, enum xml_private_flags mode)
 {
     xml_acl_t *acl = NULL;
 
     const char *tag = crm_element_value(xml, PCMK_XA_OBJECT_TYPE);
     const char *ref = crm_element_value(xml, PCMK_XA_REFERENCE);
     const char *xpath = crm_element_value(xml, PCMK_XA_XPATH);
     const char *attr = crm_element_value(xml, PCMK_XA_ATTRIBUTE);
 
     if ((tag == NULL) && (ref == NULL) && (xpath == NULL)) {
         // Schema should prevent this, but to be safe ...
         crm_trace("Ignoring ACL <%s> element without selection criteria",
                   xml->name);
         return NULL;
     }
 
     acl = pcmk__assert_alloc(1, sizeof (xml_acl_t));
 
     acl->mode = mode;
     if (xpath) {
         acl->xpath = g_strdup(xpath);
         crm_trace("Unpacked ACL <%s> element using xpath: %s",
                   xml->name, acl->xpath);
 
     } else {
         GString *buf = g_string_sized_new(128);
 
         if ((ref != NULL) && (attr != NULL)) {
             // NOTE: schema currently does not allow this
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), "[@" PCMK_XA_ID "='",
                            ref, "' and @", attr, "]", NULL);
 
         } else if (ref != NULL) {
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), "[@" PCMK_XA_ID "='",
                            ref, "']", NULL);
 
         } else if (attr != NULL) {
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), "[@", attr, "]", NULL);
 
         } else {
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), NULL);
         }
 
         acl->xpath = buf->str;
 
         g_string_free(buf, FALSE);
         crm_trace("Unpacked ACL <%s> element as xpath: %s",
                   xml->name, acl->xpath);
     }
 
     return g_list_append(acls, acl);
 }
 
 /*!
  * \internal
  * \brief Unpack a user, group, or role subtree of the ACLs section
  *
  * \param[in]     acl_top    XML of entire ACLs section
  * \param[in]     acl_entry  XML of ACL element being unpacked
  * \param[in,out] acls       List of ACLs unpacked so far
  *
  * \return New head of (possibly modified) acls
  *
  * \note This function is recursive
  */
 static GList *
 parse_acl_entry(const xmlNode *acl_top, const xmlNode *acl_entry, GList *acls)
 {
     for (const xmlNode *child = pcmk__xe_first_child(acl_entry, NULL, NULL,
                                                      NULL);
          child != NULL; child = pcmk__xe_next(child, NULL)) {
 
         if (pcmk__xe_is(child, PCMK_XE_ACL_PERMISSION)) {
             const char *kind = crm_element_value(child, PCMK_XA_KIND);
 
             pcmk__assert(kind != NULL);
             crm_trace("Unpacking <" PCMK_XE_ACL_PERMISSION "> element of "
                       "kind '%s'",
                       kind);
 
             if (pcmk__str_eq(kind, PCMK_VALUE_READ, pcmk__str_none)) {
                 acls = create_acl(child, acls, pcmk__xf_acl_read);
 
             } else if (pcmk__str_eq(kind, PCMK_VALUE_WRITE, pcmk__str_none)) {
                 acls = create_acl(child, acls, pcmk__xf_acl_write);
 
             } else if (pcmk__str_eq(kind, PCMK_VALUE_DENY, pcmk__str_none)) {
                 acls = create_acl(child, acls, pcmk__xf_acl_deny);
 
             } else {
                 crm_warn("Ignoring unknown ACL kind '%s'", kind);
             }
 
         } else if (pcmk__xe_is(child, PCMK_XE_ROLE)) {
             const char *ref_role = crm_element_value(child, PCMK_XA_ID);
 
             crm_trace("Unpacking <" PCMK_XE_ROLE "> element");
 
             if (ref_role == NULL) {
                 continue;
             }
 
             for (xmlNode *role = pcmk__xe_first_child(acl_top, NULL, NULL,
                                                       NULL);
                  role != NULL; role = pcmk__xe_next(role, NULL)) {
 
                 const char *role_id = NULL;
 
                 if (!pcmk__xe_is(role, PCMK_XE_ACL_ROLE)) {
                     continue;
                 }
 
                 role_id = crm_element_value(role, PCMK_XA_ID);
 
                 if (pcmk__str_eq(ref_role, role_id, pcmk__str_none)) {
                     crm_trace("Unpacking referenced role '%s' in <%s> element",
                               role_id, acl_entry->name);
                     acls = parse_acl_entry(acl_top, role, acls);
                     break;
                 }
             }
         }
     }
 
     return acls;
 }
 
 /*
     <acls>
       <acl_target id="l33t-haxor"><role id="auto-l33t-haxor"/></acl_target>
       <acl_role id="auto-l33t-haxor">
         <acl_permission id="crook-nothing" kind="deny" xpath="/cib"/>
       </acl_role>
       <acl_target id="niceguy">
         <role id="observer"/>
       </acl_target>
       <acl_role id="observer">
         <acl_permission id="observer-read-1" kind="read" xpath="/cib"/>
         <acl_permission id="observer-write-1" kind="write" xpath="//nvpair[@name='stonith-enabled']"/>
         <acl_permission id="observer-write-2" kind="write" xpath="//nvpair[@name='target-role']"/>
       </acl_role>
       <acl_target id="badidea"><role id="auto-badidea"/></acl_target>
       <acl_role id="auto-badidea">
         <acl_permission id="badidea-resources" kind="read" xpath="//meta_attributes"/>
         <acl_permission id="badidea-resources-2" kind="deny" reference="dummy-meta_attributes"/>
       </acl_role>
     </acls>
 */
 
 static const char *
 acl_to_text(enum xml_private_flags flags)
 {
     if (pcmk_is_set(flags, pcmk__xf_acl_deny)) {
         return "deny";
 
     } else if (pcmk_any_flags_set(flags, pcmk__xf_acl_write|pcmk__xf_acl_create)) {
         return "read/write";
 
     } else if (pcmk_is_set(flags, pcmk__xf_acl_read)) {
         return "read";
     }
     return "none";
 }
 
 void
 pcmk__apply_acl(xmlNode *xml)
 {
     GList *aIter = NULL;
     xml_doc_private_t *docpriv = xml->doc->_private;
     xml_node_private_t *nodepriv;
     xmlXPathObject *xpathObj = NULL;
 
     if (!xml_acl_enabled(xml)) {
         crm_trace("Skipping ACLs for user '%s' because not enabled for this XML",
-                  docpriv->user);
+                  docpriv->acl_user);
         return;
     }
 
     for (aIter = docpriv->acls; aIter != NULL; aIter = aIter->next) {
         int max = 0, lpc = 0;
         xml_acl_t *acl = aIter->data;
 
         xpathObj = pcmk__xpath_search(xml->doc, acl->xpath);
         max = pcmk__xpath_num_results(xpathObj);
 
         for (lpc = 0; lpc < max; lpc++) {
             xmlNode *match = pcmk__xpath_result(xpathObj, lpc);
 
             if (match == NULL) {
                 continue;
             }
 
             /* @COMPAT If the ACL's XPath matches a node that is neither an
              * element nor a document, we apply the ACL to the parent element
              * rather than to the matched node. For example, if the XPath
              * matches a "score" attribute, then it applies to every element
              * that contains a "score" attribute. That is, the XPath expression
              * "//@score" matches all attributes named "score", but we apply the
              * ACL to all elements containing such an attribute.
              *
              * This behavior is incorrect from an XPath standpoint and is thus
              * confusing and counterintuitive. The correct way to match all
              * elements containing a "score" attribute is to use an XPath
              * predicate: "// *[@score]". (Space inserted after slashes so that
              * GCC doesn't throw an error about nested comments.)
              *
              * Additionally, if an XPath expression matches the entire document
              * (for example, "/"), then the ACL applies to the document's root
              * element if it exists.
              *
              * These behaviors should be changed so that the ACL applies to the
              * nodes matched by the XPath expression, or so that it doesn't
              * apply at all if applying an ACL to an attribute doesn't make
              * sense.
              *
              * Unfortunately, we document in Pacemaker Explained that matching
              * attributes is a valid way to match elements: "Attributes may be
              * specified in the XPath to select particular elements, but the
              * permissions apply to the entire element."
              *
              * So we have to keep this behavior at least until a compatibility
              * break. Even then, it's not feasible in the general case to
              * transform such XPath expressions using XSLT.
              */
             match = pcmk__xpath_match_element(match);
             if (match == NULL) {
                 continue;
             }
 
             nodepriv = match->_private;
             pcmk__set_xml_flags(nodepriv, acl->mode);
 
             // Build a GString only if tracing is enabled
             pcmk__if_tracing(
                 {
                     GString *path = pcmk__element_xpath(match);
                     crm_trace("Applying %s ACL to %s matched by %s",
                               acl_to_text(acl->mode), path->str, acl->xpath);
                     g_string_free(path, TRUE);
                 },
                 {}
             );
         }
         crm_trace("Applied %s ACL %s (%d match%s)",
                   acl_to_text(acl->mode), acl->xpath, max,
                   ((max == 1)? "" : "es"));
         xmlXPathFreeObject(xpathObj);
     }
 }
 
 /*!
  * \internal
  * \brief Unpack ACLs for a given user into the
  * metadata of the target XML tree
  *
  * Taking the description of ACLs from the source XML tree and
  * marking up the target XML tree with access information for the
  * given user by tacking it onto the relevant nodes
  *
  * \param[in]     source  XML with ACL definitions
  * \param[in,out] target  XML that ACLs will be applied to
  * \param[in]     user    Username whose ACLs need to be unpacked
  */
 void
 pcmk__unpack_acl(xmlNode *source, xmlNode *target, const char *user)
 {
     xml_doc_private_t *docpriv = NULL;
 
     if ((target == NULL) || (target->doc == NULL)
         || (target->doc->_private == NULL)) {
         return;
     }
 
     docpriv = target->doc->_private;
     if (!pcmk_acl_required(user)) {
         crm_trace("Not unpacking ACLs because not required for user '%s'",
                   user);
 
     } else if (docpriv->acls == NULL) {
         xmlNode *acls = pcmk__xpath_find_one(source->doc, "//" PCMK_XE_ACLS,
                                              LOG_NEVER);
 
-        pcmk__str_update(&docpriv->user, user);
+        pcmk__str_update(&(docpriv->acl_user), user);
 
         if (acls) {
             xmlNode *child = NULL;
 
             for (child = pcmk__xe_first_child(acls, NULL, NULL, NULL);
                  child != NULL; child = pcmk__xe_next(child, NULL)) {
 
                 if (pcmk__xe_is(child, PCMK_XE_ACL_TARGET)) {
                     const char *id = crm_element_value(child, PCMK_XA_NAME);
 
                     if (id == NULL) {
                         id = crm_element_value(child, PCMK_XA_ID);
                     }
 
                     if (id && strcmp(id, user) == 0) {
                         crm_debug("Unpacking ACLs for user '%s'", id);
                         docpriv->acls = parse_acl_entry(acls, child, docpriv->acls);
                     }
                 } else if (pcmk__xe_is(child, PCMK_XE_ACL_GROUP)) {
                     const char *id = crm_element_value(child, PCMK_XA_NAME);
 
                     if (id == NULL) {
                         id = crm_element_value(child, PCMK_XA_ID);
                     }
 
                     if (id && pcmk__is_user_in_group(user,id)) {
                         crm_debug("Unpacking ACLs for group '%s'", id);
                         docpriv->acls = parse_acl_entry(acls, child, docpriv->acls);
                     }
                 }
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Copy source to target and set xf_acl_enabled flag in target
  *
  * \param[in]     acl_source    XML with ACL definitions
  * \param[in,out] target        XML that ACLs will be applied to
  * \param[in]     user          Username whose ACLs need to be set
  */
 void
 pcmk__enable_acl(xmlNode *acl_source, xmlNode *target, const char *user)
 {
     if (target == NULL) {
         return;
     }
     pcmk__unpack_acl(acl_source, target, user);
     pcmk__xml_doc_set_flags(target->doc, pcmk__xf_acl_enabled);
     pcmk__apply_acl(target);
 }
 
 static inline bool
 test_acl_mode(enum xml_private_flags allowed, enum xml_private_flags requested)
 {
     if (pcmk_is_set(allowed, pcmk__xf_acl_deny)) {
         return false;
 
     } else if (pcmk_all_flags_set(allowed, requested)) {
         return true;
 
     } else if (pcmk_is_set(requested, pcmk__xf_acl_read)
                && pcmk_is_set(allowed, pcmk__xf_acl_write)) {
         return true;
 
     } else if (pcmk_is_set(requested, pcmk__xf_acl_create)
                && pcmk_any_flags_set(allowed, pcmk__xf_acl_write|pcmk__xf_created)) {
         return true;
     }
     return false;
 }
 
 /*!
  * \internal
  * \brief Rid XML tree of all unreadable nodes and node properties
  *
  * \param[in,out] xml   Root XML node to be purged of attributes
  *
  * \return true if this node or any of its children are readable
  *         if false is returned, xml will be freed
  *
  * \note This function is recursive
  */
 static bool
 purge_xml_attributes(xmlNode *xml)
 {
     xmlNode *child = NULL;
     xmlAttr *xIter = NULL;
     bool readable_children = false;
     xml_node_private_t *nodepriv = xml->_private;
 
     if (test_acl_mode(nodepriv->flags, pcmk__xf_acl_read)) {
         crm_trace("%s[@" PCMK_XA_ID "=%s] is readable",
                   xml->name, pcmk__xe_id(xml));
         return true;
     }
 
     xIter = xml->properties;
     while (xIter != NULL) {
         xmlAttr *tmp = xIter;
         const char *prop_name = (const char *)xIter->name;
 
         xIter = xIter->next;
         if (strcmp(prop_name, PCMK_XA_ID) == 0) {
             continue;
         }
 
         pcmk__xa_remove(tmp, true);
     }
 
     child = pcmk__xml_first_child(xml);
     while ( child != NULL ) {
         xmlNode *tmp = child;
 
         child = pcmk__xml_next(child);
         readable_children |= purge_xml_attributes(tmp);
     }
 
     if (!readable_children) {
         // Nothing readable under here, so purge completely
         pcmk__xml_free(xml);
     }
     return readable_children;
 }
 
 /*!
  * \brief Copy ACL-allowed portions of specified XML
  *
  * \param[in]  user        Username whose ACLs should be used
  * \param[in]  acl_source  XML containing ACLs
  * \param[in]  xml         XML to be copied
  * \param[out] result      Copy of XML portions readable via ACLs
  *
  * \return true if xml exists and ACLs are required for user, false otherwise
  * \note If this returns true, caller should use \p result rather than \p xml
  */
 bool
 xml_acl_filtered_copy(const char *user, xmlNode *acl_source, xmlNode *xml,
                       xmlNode **result)
 {
     GList *aIter = NULL;
     xmlNode *target = NULL;
     xml_doc_private_t *docpriv = NULL;
 
     *result = NULL;
     if ((xml == NULL) || !pcmk_acl_required(user)) {
         crm_trace("Not filtering XML because ACLs not required for user '%s'",
                   user);
         return false;
     }
 
     crm_trace("Filtering XML copy using user '%s' ACLs", user);
     target = pcmk__xml_copy(NULL, xml);
     if (target == NULL) {
         return true;
     }
 
     pcmk__enable_acl(acl_source, target, user);
 
     docpriv = target->doc->_private;
     for(aIter = docpriv->acls; aIter != NULL && target; aIter = aIter->next) {
         int max = 0;
         xml_acl_t *acl = aIter->data;
 
         if (acl->mode != pcmk__xf_acl_deny) {
             /* Nothing to do */
 
         } else if (acl->xpath) {
             int lpc = 0;
             xmlXPathObject *xpathObj = pcmk__xpath_search(target->doc,
                                                           acl->xpath);
 
             max = pcmk__xpath_num_results(xpathObj);
             for(lpc = 0; lpc < max; lpc++) {
                 xmlNode *match = pcmk__xpath_result(xpathObj, lpc);
 
                 if (match == NULL) {
                     continue;
                 }
 
                 // @COMPAT See COMPAT comment in pcmk__apply_acl()
                 match = pcmk__xpath_match_element(match);
                 if (match == NULL) {
                     continue;
                 }
 
                 if (!purge_xml_attributes(match) && (match == target)) {
                     crm_trace("ACLs deny user '%s' access to entire XML document",
                               user);
                     xmlXPathFreeObject(xpathObj);
                     return true;
                 }
             }
             crm_trace("ACLs deny user '%s' access to %s (%d %s)",
                       user, acl->xpath, max,
                       pcmk__plural_alt(max, "match", "matches"));
             xmlXPathFreeObject(xpathObj);
         }
     }
 
     if (!purge_xml_attributes(target)) {
         crm_trace("ACLs deny user '%s' access to entire XML document", user);
         return true;
     }
 
     if (docpriv->acls) {
         g_list_free_full(docpriv->acls, free_acl);
         docpriv->acls = NULL;
 
     } else {
         crm_trace("User '%s' without ACLs denied access to entire XML document",
                   user);
         pcmk__xml_free(target);
         target = NULL;
     }
 
     if (target) {
         *result = target;
     }
 
     return true;
 }
 
 /*!
  * \internal
  * \brief Check whether creation of an XML element is implicitly allowed
  *
  * Check whether XML is a "scaffolding" element whose creation is implicitly
  * allowed regardless of ACLs (that is, it is not in the ACL section and has
  * no attributes other than \c PCMK_XA_ID).
  *
  * \param[in] xml  XML element to check
  *
  * \return true if XML element is implicitly allowed, false otherwise
  */
 static bool
 implicitly_allowed(const xmlNode *xml)
 {
     GString *path = NULL;
 
     for (xmlAttr *prop = xml->properties; prop != NULL; prop = prop->next) {
         if (strcmp((const char *) prop->name, PCMK_XA_ID) != 0) {
             return false;
         }
     }
 
     path = pcmk__element_xpath(xml);
     pcmk__assert(path != NULL);
 
     if (strstr((const char *) path->str, "/" PCMK_XE_ACLS "/") != NULL) {
         g_string_free(path, TRUE);
         return false;
     }
 
     g_string_free(path, TRUE);
     return true;
 }
 
 #define display_id(xml) pcmk__s(pcmk__xe_id(xml), "<unset>")
 
 /*!
  * \internal
  * \brief Drop XML nodes created in violation of ACLs
  *
  * Given an XML element, free all of its descendant nodes created in violation
  * of ACLs, with the exception of allowing "scaffolding" elements (i.e. those
  * that aren't in the ACL section and don't have any attributes other than
  * \c PCMK_XA_ID).
  *
  * \param[in,out] xml        XML to check
  * \param[in]     check_top  Whether to apply checks to argument itself
  *                           (if true, xml might get freed)
  *
  * \note This function is recursive
  */
 void
 pcmk__apply_creation_acl(xmlNode *xml, bool check_top)
 {
     xml_node_private_t *nodepriv = xml->_private;
 
     if (pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
         if (implicitly_allowed(xml)) {
             crm_trace("Creation of <%s> scaffolding with " PCMK_XA_ID "=\"%s\""
                       " is implicitly allowed",
                       xml->name, display_id(xml));
 
         } else if (pcmk__check_acl(xml, NULL, pcmk__xf_acl_write)) {
             crm_trace("ACLs allow creation of <%s> with " PCMK_XA_ID "=\"%s\"",
                       xml->name, display_id(xml));
 
         } else if (check_top) {
             /* is_root=true should be impossible with check_top=true, but check
              * for sanity
              */
             bool is_root = (xmlDocGetRootElement(xml->doc) == xml);
             xml_doc_private_t *docpriv = xml->doc->_private;
 
             crm_trace("ACLs disallow creation of %s<%s> with "
                       PCMK_XA_ID "=\"%s\"",
                       (is_root? "root element " : ""), xml->name,
                       display_id(xml));
 
             // pcmk__xml_free() checks ACLs if enabled, which would fail
             pcmk__clear_xml_flags(docpriv, pcmk__xf_acl_enabled);
             pcmk__xml_free(xml);
 
             if (!is_root) {
                 // If root, the document was freed. Otherwise re-enable ACLs.
                 pcmk__set_xml_flags(docpriv, pcmk__xf_acl_enabled);
             }
             return;
 
         } else {
             crm_notice("ACLs would disallow creation of %s<%s> with "
                        PCMK_XA_ID "=\"%s\"",
                        ((xml == xmlDocGetRootElement(xml->doc))? "root element " : ""),
                        xml->name, display_id(xml));
         }
     }
 
     for (xmlNode *cIter = pcmk__xml_first_child(xml); cIter != NULL; ) {
         xmlNode *child = cIter;
         cIter = pcmk__xml_next(cIter); /* In case it is free'd */
         pcmk__apply_creation_acl(child, true);
     }
 }
 
 /*!
  * \brief Check whether or not an XML node is ACL-denied
  *
  * \param[in]  xml node to check
  *
  * \return true if XML node exists and is ACL-denied, false otherwise
  */
 bool
 xml_acl_denied(const xmlNode *xml)
 {
     if (xml && xml->doc && xml->doc->_private){
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         return pcmk_is_set(docpriv->flags, pcmk__xf_acl_denied);
     }
     return false;
 }
 
 void
 xml_acl_disable(xmlNode *xml)
 {
     if (xml_acl_enabled(xml)) {
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         /* Catch anything that was created but shouldn't have been */
         pcmk__apply_acl(xml);
         pcmk__apply_creation_acl(xml, false);
         pcmk__clear_xml_flags(docpriv, pcmk__xf_acl_enabled);
     }
 }
 
 /*!
  * \brief Check whether or not an XML node is ACL-enabled
  *
  * \param[in]  xml node to check
  *
  * \return true if XML node exists and is ACL-enabled, false otherwise
  */
 bool
 xml_acl_enabled(const xmlNode *xml)
 {
     if (xml && xml->doc && xml->doc->_private){
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         return pcmk_is_set(docpriv->flags, pcmk__xf_acl_enabled);
     }
     return false;
 }
 
 /*!
  * \internal
  * \brief Deny access to an XML tree's document based on ACLs
  *
  * \param[in,out] xml        XML tree
  * \param[in]     attr_name  Name of attribute being accessed in \p xml (for
  *                           logging only)
  * \param[in]     prefix     Prefix describing ACL that denied access (for
  *                           logging only)
  * \param[in]     user       User accessing \p xml (for logging only)
  * \param[in]     mode       Access mode
  */
 #define check_acl_deny(xml, attr_name, prefix, user, mode) do {             \
         xmlNode *tree = xml;                                                \
                                                                             \
         pcmk__xml_doc_set_flags(tree->doc, pcmk__xf_acl_denied);            \
         pcmk__if_tracing(                                                   \
             {                                                               \
                 GString *xpath = pcmk__element_xpath(tree);                 \
                                                                             \
                 if ((attr_name) != NULL) {                                  \
                     pcmk__g_strcat(xpath, "[@", attr_name, "]", NULL);      \
                 }                                                           \
                 qb_log_from_external_source(__func__, __FILE__,             \
                                             "%sACL denies user '%s' %s "    \
                                             "access to %s",                 \
                                             LOG_TRACE, __LINE__, 0 ,        \
                                             prefix, user,                   \
                                             acl_to_text(mode), xpath->str); \
                 g_string_free(xpath, TRUE);                                 \
             },                                                              \
             {}                                                              \
         );                                                                  \
     } while (false);
 
 bool
 pcmk__check_acl(xmlNode *xml, const char *attr_name,
                 enum xml_private_flags mode)
 {
     xml_doc_private_t *docpriv = NULL;
 
     pcmk__assert((xml != NULL) && (xml->doc->_private != NULL));
 
     if (!pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_tracking)
         || !xml_acl_enabled(xml)) {
         return true;
     }
 
     docpriv = xml->doc->_private;
     if (docpriv->acls == NULL) {
-        check_acl_deny(xml, attr_name, "Lack of ", docpriv->user, mode);
+        check_acl_deny(xml, attr_name, "Lack of ", docpriv->acl_user, mode);
         return false;
     }
 
     /* Walk the tree upwards looking for xml_acl_* flags
      * - Creating an attribute requires write permissions for the node
      * - Creating a child requires write permissions for the parent
      */
 
     if (attr_name != NULL) {
         xmlAttr *attr = xmlHasProp(xml, (const xmlChar *) attr_name);
 
         if ((attr != NULL) && (mode == pcmk__xf_acl_create)) {
             mode = pcmk__xf_acl_write;
         }
     }
 
     for (const xmlNode *parent = xml;
          (parent != NULL) && (parent->_private != NULL);
          parent = parent->parent) {
 
         const xml_node_private_t *nodepriv = parent->_private;
 
         if (test_acl_mode(nodepriv->flags, mode)) {
             return true;
         }
 
         if (pcmk_is_set(nodepriv->flags, pcmk__xf_acl_deny)) {
             const char *pfx = (parent != xml)? "Parent " : "";
 
-            check_acl_deny(xml, attr_name, pfx, docpriv->user, mode);
+            check_acl_deny(xml, attr_name, pfx, docpriv->acl_user, mode);
             return false;
         }
     }
 
-    check_acl_deny(xml, attr_name, "Default ", docpriv->user, mode);
+    check_acl_deny(xml, attr_name, "Default ", docpriv->acl_user, mode);
     return false;
 }
 
 /*!
  * \brief Check whether ACLs are required for a given user
  *
  * \param[in]  User name to check
  *
  * \return true if the user requires ACLs, false otherwise
  */
 bool
 pcmk_acl_required(const char *user)
 {
     if (pcmk__str_empty(user)) {
         crm_trace("ACLs not required because no user set");
         return false;
 
     } else if (!strcmp(user, CRM_DAEMON_USER) || !strcmp(user, "root")) {
         crm_trace("ACLs not required for privileged user %s", user);
         return false;
     }
     crm_trace("ACLs required for %s", user);
     return true;
 }
 
 char *
 pcmk__uid2username(uid_t uid)
 {
     struct passwd *pwent = getpwuid(uid);
 
     if (pwent == NULL) {
         crm_perror(LOG_INFO, "Cannot get user details for user ID %d", uid);
         return NULL;
     }
     return pcmk__str_copy(pwent->pw_name);
 }
 
 /*!
  * \internal
  * \brief Set the ACL user field properly on an XML request
  *
  * Multiple user names are potentially involved in an XML request: the effective
  * user of the current process; the user name known from an IPC client
  * connection; and the user name obtained from the request itself, whether by
  * the current standard XML attribute name or an older legacy attribute name.
  * This function chooses the appropriate one that should be used for ACLs, sets
  * it in the request (using the standard attribute name, and the legacy name if
  * given), and returns it.
  *
  * \param[in,out] request    XML request to update
  * \param[in]     field      Alternate name for ACL user name XML attribute
  * \param[in]     peer_user  User name as known from IPC connection
  *
  * \return ACL user name actually used
  */
 const char *
 pcmk__update_acl_user(xmlNode *request, const char *field,
                       const char *peer_user)
 {
     static const char *effective_user = NULL;
     const char *requested_user = NULL;
     const char *user = NULL;
 
     if (effective_user == NULL) {
         effective_user = pcmk__uid2username(geteuid());
         if (effective_user == NULL) {
             effective_user = pcmk__str_copy("#unprivileged");
             crm_err("Unable to determine effective user, assuming unprivileged for ACLs");
         }
     }
 
     requested_user = crm_element_value(request, PCMK__XA_ACL_TARGET);
     if (requested_user == NULL) {
         /* Currently, different XML attribute names are used for the ACL user in
          * different contexts (PCMK__XA_ATTR_USER, PCMK__XA_CIB_USER, etc.).
          * The caller may specify that name as the field argument.
          *
          * @TODO Standardize on PCMK__XA_ACL_TARGET and eventually drop the
          * others once rolling upgrades from versions older than that are no
          * longer supported.
          */
         requested_user = crm_element_value(request, field);
     }
 
     if (!pcmk__is_privileged(effective_user)) {
         /* We're not running as a privileged user, set or overwrite any existing
          * value for PCMK__XA_ACL_TARGET
          */
         user = effective_user;
 
     } else if (peer_user == NULL && requested_user == NULL) {
         /* No user known or requested, use 'effective_user' and make sure one is
          * set for the request
          */
         user = effective_user;
 
     } else if (peer_user == NULL) {
         /* No user known, trusting 'requested_user' */
         user = requested_user;
 
     } else if (!pcmk__is_privileged(peer_user)) {
         /* The peer is not a privileged user, set or overwrite any existing
          * value for PCMK__XA_ACL_TARGET
          */
         user = peer_user;
 
     } else if (requested_user == NULL) {
         /* Even if we're privileged, make sure there is always a value set */
         user = peer_user;
 
     } else {
         /* Legal delegation to 'requested_user' */
         user = requested_user;
     }
 
     // This requires pointer comparison, not string comparison
     if (user != crm_element_value(request, PCMK__XA_ACL_TARGET)) {
         crm_xml_add(request, PCMK__XA_ACL_TARGET, user);
     }
 
     if (field != NULL && user != crm_element_value(request, field)) {
         crm_xml_add(request, field, user);
     }
 
     return requested_user;
 }
diff --git a/lib/common/crmcommon_private.h b/lib/common/crmcommon_private.h
index 47a17e7e59..8e1b453eb6 100644
--- a/lib/common/crmcommon_private.h
+++ b/lib/common/crmcommon_private.h
@@ -1,464 +1,488 @@
 /*
  * Copyright 2018-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.
  */
 
 #ifndef PCMK__COMMON_CRMCOMMON_PRIVATE__H
 #define PCMK__COMMON_CRMCOMMON_PRIVATE__H
 
 /* This header is for the sole use of libcrmcommon, so that functions can be
  * declared with G_GNUC_INTERNAL for efficiency.
  */
 
 #include <stdint.h>         // uint8_t, uint32_t
 #include <stdbool.h>        // bool
 #include <sys/types.h>      // size_t
 
 #include <glib.h>           // G_GNUC_INTERNAL, G_GNUC_PRINTF, gchar, etc.
 #include <libxml/tree.h>    // xmlNode, xmlAttr
 #include <libxml/xmlstring.h>           // xmlChar
 #include <qb/qbipcc.h>      // struct qb_ipc_response_header
 
 #include <crm/common/ipc.h>             // pcmk_ipc_api_t, crm_ipc_t, etc.
 #include <crm/common/iso8601.h>         // crm_time_t
 #include <crm/common/logging.h>         // LOG_NEVER
 #include <crm/common/mainloop.h>        // mainloop_io_t
 #include <crm/common/output_internal.h> // pcmk__output_t
 #include <crm/common/results.h>         // crm_exit_t
 #include <crm/common/rules.h>           // pcmk_rule_input_t
 #include <crm/common/xml_internal.h>    // enum xml_private_flags
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 // Decent chunk size for processing large amounts of data
 #define PCMK__BUFFER_SIZE 4096
 
 #if defined(PCMK__UNIT_TESTING)
 #undef G_GNUC_INTERNAL
 #define G_GNUC_INTERNAL
 #endif
 
-/* When deleting portions of an XML tree, we keep a record so we can know later
- * (e.g. when checking differences) that something was deleted.
+/*!
+ * \internal
+ * \brief Information about an XML node that was deleted
+ *
+ * When change tracking is enabled and we delete an XML node using
+ * \c pcmk__xml_free(), we free it and add its path and position to a list in
+ * its document's private data. This allows us to display changes, generate
+ * patchsets, etc.
+ *
+ * Note that this does not happen when deleting an XML attribute using
+ * \c pcmk__xa_remove(). In that case:
+ * * If \c force is \c true, we remove the attribute without any tracking.
+ * * If \c force is \c false, we mark the attribute as deleted but leave it in
+ *   place until we commit changes.
  */
 typedef struct pcmk__deleted_xml_s {
-    gchar *path;
-    int position;
+    gchar *path;        //!< XPath expression identifying the deleted node
+    int position;       //!< Position of the deleted node among its siblings
 } pcmk__deleted_xml_t;
 
+/*!
+ * \internal
+ * \brief Private data for an XML node
+ */
 typedef struct xml_node_private_s {
-        uint32_t check;
-        uint32_t flags;
+    uint32_t check;         //!< Magic number for checking integrity
+    uint32_t flags;         //!< Group of <tt>enum xml_private_flags</tt>
 } xml_node_private_t;
 
+/*!
+ * \internal
+ * \brief Private data for an XML document
+ */
 typedef struct xml_doc_private_s {
-        uint32_t check;
-        uint32_t flags;
-        char *user;
-        GList *acls;
-        GList *deleted_objs; // List of pcmk__deleted_xml_t
+    uint32_t check;         //!< Magic number for checking integrity
+    uint32_t flags;         //!< Group of <tt>enum xml_private_flags</tt>
+    char *acl_user;         //!< User affected by \c acls (for logging)
+
+    //! ACLs to check requested changes against (list of \c xml_acl_t)
+    GList *acls;
+
+    //! XML nodes marked as deleted (list of \c pcmk__deleted_xml_t)
+    GList *deleted_objs;
 } xml_doc_private_t;
 
 // XML private data magic numbers
 #define PCMK__XML_DOC_PRIVATE_MAGIC     0x81726354UL
 #define PCMK__XML_NODE_PRIVATE_MAGIC    0x54637281UL
 
 // XML entity references
 #define PCMK__XML_ENTITY_AMP    "&amp;"
 #define PCMK__XML_ENTITY_GT     "&gt;"
 #define PCMK__XML_ENTITY_LT     "&lt;"
 #define PCMK__XML_ENTITY_QUOT   "&quot;"
 
 #define pcmk__set_xml_flags(xml_priv, flags_to_set) do {                    \
         (xml_priv)->flags = pcmk__set_flags_as(__func__, __LINE__,          \
             LOG_NEVER, "XML", "XML node", (xml_priv)->flags,                \
             (flags_to_set), #flags_to_set);                                 \
     } while (0)
 
 #define pcmk__clear_xml_flags(xml_priv, flags_to_clear) do {                \
         (xml_priv)->flags = pcmk__clear_flags_as(__func__, __LINE__,        \
             LOG_NEVER, "XML", "XML node", (xml_priv)->flags,                \
             (flags_to_clear), #flags_to_clear);                             \
     } while (0)
 
 G_GNUC_INTERNAL
 const char *pcmk__xml_element_type_text(xmlElementType type);
 
 G_GNUC_INTERNAL
 bool pcmk__xml_reset_node_flags(xmlNode *xml, void *user_data);
 
 G_GNUC_INTERNAL
 void pcmk__xml_set_parent_flags(xmlNode *xml, uint64_t flags);
 
 G_GNUC_INTERNAL
 void pcmk__xml_new_private_data(xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__xml_free_private_data(xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__xml_free_node(xmlNode *xml);
 
 G_GNUC_INTERNAL
 xmlDoc *pcmk__xml_new_doc(void);
 
 G_GNUC_INTERNAL
 int pcmk__xml_position(const xmlNode *xml,
                        enum xml_private_flags ignore_if_set);
 
 G_GNUC_INTERNAL
 xmlNode *pcmk__xml_match(const xmlNode *haystack, const xmlNode *needle,
                          bool exact);
 
 G_GNUC_INTERNAL
 xmlNode *pcmk__xc_match(const xmlNode *root, const xmlNode *search_comment,
                         bool exact);
 
 G_GNUC_INTERNAL
 void pcmk__xc_update(xmlNode *parent, xmlNode *target, xmlNode *update);
 
 G_GNUC_INTERNAL
 void pcmk__free_acls(GList *acls);
 
 G_GNUC_INTERNAL
 void pcmk__unpack_acl(xmlNode *source, xmlNode *target, const char *user);
 
 G_GNUC_INTERNAL
 bool pcmk__is_user_in_group(const char *user, const char *group);
 
 G_GNUC_INTERNAL
 void pcmk__apply_acl(xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__apply_creation_acl(xmlNode *xml, bool check_top);
 
 G_GNUC_INTERNAL
 int pcmk__xa_remove(xmlAttr *attr, bool force);
 
 G_GNUC_INTERNAL
 void pcmk__mark_xml_attr_dirty(xmlAttr *a);
 
 G_GNUC_INTERNAL
 bool pcmk__xa_filterable(const char *name);
 
 G_GNUC_INTERNAL
 void pcmk__log_xmllib_err(void *ctx, const char *fmt, ...)
 G_GNUC_PRINTF(2, 3);
 
 G_GNUC_INTERNAL
 void pcmk__mark_xml_node_dirty(xmlNode *xml);
 
 G_GNUC_INTERNAL
 bool pcmk__marked_as_deleted(xmlAttrPtr a, void *user_data);
 
 G_GNUC_INTERNAL
 void pcmk__dump_xml_attr(const xmlAttr *attr, GString *buffer);
 
 G_GNUC_INTERNAL
 int pcmk__xe_set_score(xmlNode *target, const char *name, const char *value);
 
 G_GNUC_INTERNAL
 bool pcmk__xml_is_name_start_char(const char *utf8, int *len);
 
 G_GNUC_INTERNAL
 bool pcmk__xml_is_name_char(const char *utf8, int *len);
 
 /*
  * Date/times
  */
 
 // For use with pcmk__add_time_from_xml()
 enum pcmk__time_component {
     pcmk__time_unknown,
     pcmk__time_years,
     pcmk__time_months,
     pcmk__time_weeks,
     pcmk__time_days,
     pcmk__time_hours,
     pcmk__time_minutes,
     pcmk__time_seconds,
 };
 
 G_GNUC_INTERNAL
 const char *pcmk__time_component_attr(enum pcmk__time_component component);
 
 G_GNUC_INTERNAL
 int pcmk__add_time_from_xml(crm_time_t *t, enum pcmk__time_component component,
                             const xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__set_time_if_earlier(crm_time_t *target, const crm_time_t *source);
 
 
 /*
  * IPC
  */
 
 #define PCMK__IPC_VERSION 1
 
 #define PCMK__CONTROLD_API_MAJOR "1"
 #define PCMK__CONTROLD_API_MINOR "0"
 
 // IPC behavior that varies by daemon
 typedef struct pcmk__ipc_methods_s {
     /*!
      * \internal
      * \brief Allocate any private data needed by daemon IPC
      *
      * \param[in,out] api  IPC API connection
      *
      * \return Standard Pacemaker return code
      */
     int (*new_data)(pcmk_ipc_api_t *api);
 
     /*!
      * \internal
      * \brief Free any private data used by daemon IPC
      *
      * \param[in,out] api_data  Data allocated by new_data() method
      */
     void (*free_data)(void *api_data);
 
     /*!
      * \internal
      * \brief Perform daemon-specific handling after successful connection
      *
      * Some daemons require clients to register before sending any other
      * commands. The controller requires a CRM_OP_HELLO (with no reply), and
      * the CIB manager, executor, and fencer require a CRM_OP_REGISTER (with a
      * reply). Ideally this would be consistent across all daemons, but for now
      * this allows each to do its own authorization.
      *
      * \param[in,out] api  IPC API connection
      *
      * \return Standard Pacemaker return code
      */
     int (*post_connect)(pcmk_ipc_api_t *api);
 
     /*!
      * \internal
      * \brief Check whether an IPC request results in a reply
      *
      * \param[in,out] api      IPC API connection
      * \param[in]     request  IPC request XML
      *
      * \return true if request would result in an IPC reply, false otherwise
      */
     bool (*reply_expected)(pcmk_ipc_api_t *api, const xmlNode *request);
 
     /*!
      * \internal
      * \brief Perform daemon-specific handling of an IPC message
      *
      * \param[in,out] api  IPC API connection
      * \param[in,out] msg  Message read from IPC connection
      *
      * \return true if more IPC reply messages should be expected
      */
     bool (*dispatch)(pcmk_ipc_api_t *api, xmlNode *msg);
 
     /*!
      * \internal
      * \brief Perform daemon-specific handling of an IPC disconnect
      *
      * \param[in,out] api  IPC API connection
      */
     void (*post_disconnect)(pcmk_ipc_api_t *api);
 } pcmk__ipc_methods_t;
 
 // Implementation of pcmk_ipc_api_t
 struct pcmk_ipc_api_s {
     enum pcmk_ipc_server server;          // Daemon this IPC API instance is for
     enum pcmk_ipc_dispatch dispatch_type; // How replies should be dispatched
     size_t ipc_size_max;                  // maximum IPC buffer size
     crm_ipc_t *ipc;                       // IPC connection
     mainloop_io_t *mainloop_io;     // If using mainloop, I/O source for IPC
     bool free_on_disconnect;        // Whether disconnect should free object
     pcmk_ipc_callback_t cb;         // Caller-registered callback (if any)
     void *user_data;                // Caller-registered data (if any)
     void *api_data;                 // For daemon-specific use
     pcmk__ipc_methods_t *cmds;      // Behavior that varies by daemon
 };
 
 typedef struct pcmk__ipc_header_s {
     struct qb_ipc_response_header qb;
     uint32_t size_uncompressed;
     uint32_t size_compressed;
     uint32_t flags;
     uint8_t version;
 } pcmk__ipc_header_t;
 
 G_GNUC_INTERNAL
 int pcmk__send_ipc_request(pcmk_ipc_api_t *api, const xmlNode *request);
 
 G_GNUC_INTERNAL
 void pcmk__call_ipc_callback(pcmk_ipc_api_t *api,
                              enum pcmk_ipc_event event_type,
                              crm_exit_t status, void *event_data);
 
 G_GNUC_INTERNAL
 unsigned int pcmk__ipc_buffer_size(unsigned int max);
 
 G_GNUC_INTERNAL
 bool pcmk__valid_ipc_header(const pcmk__ipc_header_t *header);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__attrd_api_methods(void);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__controld_api_methods(void);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__pacemakerd_api_methods(void);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__schedulerd_api_methods(void);
 
 
 /*
  * Logging
  */
 
 //! XML is newly created
 #define PCMK__XML_PREFIX_CREATED "++"
 
 //! XML has been deleted
 #define PCMK__XML_PREFIX_DELETED "--"
 
 //! XML has been modified
 #define PCMK__XML_PREFIX_MODIFIED "+ "
 
 //! XML has been moved
 #define PCMK__XML_PREFIX_MOVED "+~"
 
 /*
  * Output
  */
 G_GNUC_INTERNAL
 int pcmk__bare_output_new(pcmk__output_t **out, const char *fmt_name,
                           const char *filename, char **argv);
 
 G_GNUC_INTERNAL
 void pcmk__register_option_messages(pcmk__output_t *out);
 
 G_GNUC_INTERNAL
 void pcmk__register_patchset_messages(pcmk__output_t *out);
 
 G_GNUC_INTERNAL
 bool pcmk__output_text_get_fancy(pcmk__output_t *out);
 
 /*
  * Rules
  */
 
 // How node attribute values may be compared in rules
 enum pcmk__comparison {
     pcmk__comparison_unknown,
     pcmk__comparison_defined,
     pcmk__comparison_undefined,
     pcmk__comparison_eq,
     pcmk__comparison_ne,
     pcmk__comparison_lt,
     pcmk__comparison_lte,
     pcmk__comparison_gt,
     pcmk__comparison_gte,
 };
 
 // How node attribute values may be parsed in rules
 enum pcmk__type {
     pcmk__type_unknown,
     pcmk__type_string,
     pcmk__type_integer,
     pcmk__type_number,
     pcmk__type_version,
 };
 
 // Where to obtain reference value for a node attribute comparison
 enum pcmk__reference_source {
     pcmk__source_unknown,
     pcmk__source_literal,
     pcmk__source_instance_attrs,
     pcmk__source_meta_attrs,
 };
 
 G_GNUC_INTERNAL
 enum pcmk__comparison pcmk__parse_comparison(const char *op);
 
 G_GNUC_INTERNAL
 enum pcmk__type pcmk__parse_type(const char *type, enum pcmk__comparison op,
                                  const char *value1, const char *value2);
 
 G_GNUC_INTERNAL
 enum pcmk__reference_source pcmk__parse_source(const char *source);
 
 G_GNUC_INTERNAL
 int pcmk__cmp_by_type(const char *value1, const char *value2,
                       enum pcmk__type type);
 
 G_GNUC_INTERNAL
 int pcmk__unpack_duration(const xmlNode *duration, const crm_time_t *start,
                           crm_time_t **end);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_date_spec(const xmlNode *date_spec, const crm_time_t *now);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_attr_expression(const xmlNode *expression,
                                    const pcmk_rule_input_t *rule_input);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_rsc_expression(const xmlNode *expr,
                                   const pcmk_rule_input_t *rule_input);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_op_expression(const xmlNode *expr,
                                  const pcmk_rule_input_t *rule_input);
 
 
 /*
  * Utils
  */
 #define PCMK__PW_BUFFER_LEN 500
 
 
 /*
  * Schemas
  */
 typedef struct {
     unsigned char v[2];
 } pcmk__schema_version_t;
 
 enum pcmk__schema_validator {
     pcmk__schema_validator_none,
     pcmk__schema_validator_rng
 };
 
 typedef struct {
     int schema_index;
     char *name;
 
     /*!
      * List of XSLT stylesheets for upgrading from this schema version to the
      * next one. Sorted by the order in which they should be applied to the CIB.
      */
     GList *transforms;
 
     void *cache;
     enum pcmk__schema_validator validator;
     pcmk__schema_version_t version;
 } pcmk__schema_t;
 
 G_GNUC_INTERNAL
 GList *pcmk__find_x_0_schema(void);
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif  // PCMK__COMMON_CRMCOMMON_PRIVATE__H
diff --git a/lib/common/xml.c b/lib/common/xml.c
index 68bd6a592a..fe3eae7269 100644
--- a/lib/common/xml.c
+++ b/lib/common/xml.c
@@ -1,1665 +1,1664 @@
 /*
  * 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 <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>           // xmlChar, 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 ((const xmlChar *) "1.0")
 
 /*!
  * \internal
  * \brief Get a string representation of an XML element type for logging
  *
  * \param[in] type  XML element type
  *
  * \return String representation of \p type
  */
 const char *
 pcmk__xml_element_type_text(xmlElementType type)
 {
     static const char *const element_type_names[] = {
         [XML_ELEMENT_NODE]       = "element",
         [XML_ATTRIBUTE_NODE]     = "attribute",
         [XML_TEXT_NODE]          = "text",
         [XML_CDATA_SECTION_NODE] = "CDATA section",
         [XML_ENTITY_REF_NODE]    = "entity reference",
         [XML_ENTITY_NODE]        = "entity",
         [XML_PI_NODE]            = "PI",
         [XML_COMMENT_NODE]       = "comment",
         [XML_DOCUMENT_NODE]      = "document",
         [XML_DOCUMENT_TYPE_NODE] = "document type",
         [XML_DOCUMENT_FRAG_NODE] = "document fragment",
         [XML_NOTATION_NODE]      = "notation",
         [XML_HTML_DOCUMENT_NODE] = "HTML document",
         [XML_DTD_NODE]           = "DTD",
         [XML_ELEMENT_DECL]       = "element declaration",
         [XML_ATTRIBUTE_DECL]     = "attribute declaration",
         [XML_ENTITY_DECL]        = "entity declaration",
         [XML_NAMESPACE_DECL]     = "namespace declaration",
         [XML_XINCLUDE_START]     = "XInclude start",
         [XML_XINCLUDE_END]       = "XInclude end",
     };
 
     // Assumes the numeric values of the indices are in ascending order
     if ((type < XML_ELEMENT_NODE) || (type > XML_XINCLUDE_END)) {
         return "unrecognized type";
     }
     return element_type_names[type];
 }
 
 /*!
  * \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;
 }
 
 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);
         }
     }
 }
 
 /*!
  * \internal
  * \brief Set flags for an XML document
  *
  * \param[in,out] doc    XML document
  * \param[in]     flags  Group of <tt>enum xml_private_flags</tt>
  */
 void
 pcmk__xml_doc_set_flags(xmlDoc *doc, uint32_t flags)
 {
     if (doc != NULL) {
         xml_doc_private_t *docpriv = doc->_private;
 
         pcmk__set_xml_flags(docpriv, flags);
     }
 }
 
 /*!
  * \internal
  * \brief Check whether the given flags are set for an XML document
  *
  * \param[in] doc    XML document to check
  * \param[in] flags  Group of <tt>enum xml_private_flags</tt>
  *
  * \return \c true if all of \p flags are set for \p doc, or \c false otherwise
  */
 bool
 pcmk__xml_doc_all_flags_set(const xmlDoc *doc, uint32_t flags)
 {
     if (doc != NULL) {
         xml_doc_private_t *docpriv = doc->_private;
 
         return (docpriv != NULL) && pcmk_all_flags_set(docpriv->flags, flags);
     }
     return false;
 }
 
 // Mark document, element, and all element's parents as changed
 void
 pcmk__mark_xml_node_dirty(xmlNode *xml)
 {
     if (xml == NULL) {
         return;
     }
     pcmk__xml_doc_set_flags(xml->doc, 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__xml_doc_all_flags_set(xml->doc, pcmk__xf_tracking)) {
         // 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;
+        pcmk__str_update(&(docpriv->acl_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__xml_doc_all_flags_set(node->doc, pcmk__xf_tracking)) {
         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) 
 {
     if (xml == NULL) {
         return;
     }
 
     xml_accept_changes(xml);
     crm_trace("Tracking changes%s to %p", enforce_acls?" with ACLs":"", xml);
     pcmk__xml_doc_set_flags(xml->doc, pcmk__xf_tracking);
     if(enforce_acls) {
         if(acl_source == NULL) {
             acl_source = xml;
         }
         pcmk__xml_doc_set_flags(xml->doc, pcmk__xf_acl_enabled);
         pcmk__unpack_acl(acl_source, xml, user);
         pcmk__apply_acl(xml);
     }
 }
 
 /*!
  * \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((const xmlChar *) 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((const xmlChar *) 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 (pcmk__xml_doc_all_flags_set(node->doc, pcmk__xf_tracking)
         && !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__xml_doc_set_flags(node->doc, 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, (const xmlChar *) 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__xml_doc_all_flags_set(new_xml->doc,
                                                        pcmk__xf_lazy
                                                        |pcmk__xf_tracking)) {
                 /* pcmk__xf_tracking is always set by xml_calculate_changes()
                  * before this function is called, so only the pcmk__xf_lazy
                  * check is truly relevant.
                  */
                 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)
 {
     if (new_xml != NULL) {
         pcmk__xml_doc_set_flags(new_xml->doc, 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 (!pcmk__xml_doc_all_flags_set(new_xml->doc, pcmk__xf_tracking)) {
         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 <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();
 }
 
 void
 crm_xml_cleanup(void)
 {
     pcmk__xml_cleanup();
 }
 
 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 = '.';
         }
     }
 }
 
 bool
 xml_tracking_changes(xmlNode *xml)
 {
     return (xml != NULL)
            && pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_tracking);
 }
 
 bool
 xml_document_dirty(xmlNode *xml)
 {
     return (xml != NULL)
            && pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_dirty);
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API