list = new ArrayList<>();
+ list.add(parentElement.get(elementName));
+ parentRepeatedElements.put(elementName, list);
+ parentElement.remove(elementName);
+ }
+ parentRepeatedElements.get(elementName).add(elementValue);
+ } else {
+ // Apply force_array logic for single elements
+ Object finalContent = applyForceArray(elementName, elementValue);
+ parentElement.put(elementName, finalContent);
+ }
+ }
+ }
+
+ // Complete DOM element if building DOM
+ if (buildDom && domElementStack.isEmpty() == false) {
+ domElementStack.pop();
+ }
+ }
+
+ @Override
+ public void endDocument() {
+ // Document parsing complete
+ }
+
+ public Object getStructuredResult() {
+ return rootResult;
+ }
+
+ public Document getDomDocument() {
+ return domDocument;
+ }
+
+ private String getElementName(String ignoredUri, String localName, String qName) {
+ String elementName;
+ if (removeNamespaces) {
+ elementName = localName != null && localName.isEmpty() == false ? localName : qName;
+ } else {
+ elementName = qName;
+ }
+
+ // Apply toLower if enabled
+ if (toLower) {
+ elementName = elementName.toLowerCase(Locale.ROOT);
+ }
+
+ return elementName;
+ }
+
+ private String getAttributeName(String ignoredUri, String localName, String qName) {
+ String attrName;
+ if (removeNamespaces) {
+ attrName = localName != null && localName.isEmpty() == false ? localName : qName;
+ } else {
+ attrName = qName;
+ }
+
+ // Apply toLower if enabled
+ if (toLower) {
+ attrName = attrName.toLowerCase(Locale.ROOT);
+ }
+
+ return attrName;
+ }
+ }
+
+ /**
+ * Creates a secure, pre-configured SAX parser factory for XML parsing using XmlUtils.
+ */
+ private static SAXParserFactory createSecureSaxParserFactory() {
+ try {
+ SAXParserFactory factory = XmlUtils.getHardenedSaxParserFactory();
+ factory.setValidating(false);
+ factory.setNamespaceAware(false);
+ return factory;
+ } catch (Exception e) {
+ logger.warn("Cannot configure secure XML parsing features - XML processor may not work correctly", e);
+ return null;
+ }
+ }
+
+ /**
+ * Creates a secure, pre-configured namespace-aware SAX parser factory for XML parsing using XmlUtils.
+ */
+ private static SAXParserFactory createSecureSaxParserFactoryNamespaceAware() {
+ try {
+ SAXParserFactory factory = XmlUtils.getHardenedSaxParserFactory();
+ factory.setValidating(false);
+ factory.setNamespaceAware(true);
+ return factory;
+ } catch (Exception e) {
+ logger.warn("Cannot configure secure namespace-aware XML parsing features - XML processor may not work correctly", e);
+ return null;
+ }
+ }
+
+ /**
+ * Creates a secure, pre-configured DocumentBuilderFactory for DOM creation using XmlUtils.
+ * Since we only use this factory to create empty DOM documents programmatically
+ * (not to parse XML), we use the hardened builder factory.
+ * The SAX parser handles all XML parsing with appropriate security measures.
+ */
+ private static DocumentBuilderFactory createSecureDocumentBuilderFactory() {
+ try {
+ DocumentBuilderFactory factory = XmlUtils.getHardenedBuilderFactory();
+ factory.setValidating(false); // Override validation for DOM creation
+ return factory;
+ } catch (Exception e) {
+ logger.warn("Cannot configure secure DOM builder factory - XML processor may not work correctly", e);
+ return null;
+ }
+ }
+
+ /**
+ * Creates a secure, pre-configured XPath object expression evaluation using XmlUtils.
+ */
+ private static XPath createSecureXPath() {
+ try {
+ return XmlUtils.getHardenedXPath();
+ } catch (Exception e) {
+ logger.warn("Cannot configure secure XPath object - XML processor may not work correctly", e);
+ return null;
+ }
+ }
+
+ /**
+ * Selects the appropriate pre-configured SAX parser factory based on processor configuration.
+ *
+ * Factory selection matrix:
+ * Regular parsing, no namespaces: SAX_PARSER_FACTORY
+ * Regular parsing, with namespaces: SAX_PARSER_FACTORY_NS
+ *
+ *
+ * @return the appropriate SAX parser factory for the current configuration
+ * @throws UnsupportedOperationException if the required XML factory is not available
+ */
+ private static SAXParserFactory selectSaxParserFactory(final boolean needsNamespaceAware) {
+ SAXParserFactory factory = needsNamespaceAware ? XmlFactories.SAX_PARSER_FACTORY_NS : XmlFactories.SAX_PARSER_FACTORY;
+ if (factory == null) {
+ throw new UnsupportedOperationException(
+ "XML parsing"
+ + (needsNamespaceAware ? " with namespace-aware features " : " ")
+ + "is not supported by the current JDK. Please update your JDK to one that "
+ + "supports these XML features."
+ );
+ }
+ return factory;
+ }
+}
diff --git a/modules/ingest-common/src/test/java/org/elasticsearch/ingest/common/XmlProcessorFactoryTests.java b/modules/ingest-common/src/test/java/org/elasticsearch/ingest/common/XmlProcessorFactoryTests.java
new file mode 100644
index 0000000000000..cf75772f0fccf
--- /dev/null
+++ b/modules/ingest-common/src/test/java/org/elasticsearch/ingest/common/XmlProcessorFactoryTests.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.ingest.common;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.anEmptyMap;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+public class XmlProcessorFactoryTests extends ESTestCase {
+
+ private static final String DEFAULT_FIELD = "field1";
+ private static final String DEFAULT_TARGET_FIELD = "target";
+
+ /**
+ * Creates a new XmlProcessor.Factory instance for testing.
+ */
+ private XmlProcessor.Factory createFactory() {
+ return new XmlProcessor.Factory();
+ }
+
+ /**
+ * Creates a processor with the default factory and given configuration.
+ * This validates that all configuration parameters are consumed during processor creation.
+ */
+ private XmlProcessor createProcessor(Map config) throws Exception {
+ XmlProcessor.Factory factory = createFactory();
+ String processorTag = randomAlphaOfLength(10);
+
+ // Make a copy of the config to avoid modifying the original
+ Map configCopy = new HashMap<>(config);
+
+ // Create the processor (this should consume config parameters)
+ XmlProcessor processor = factory.create(null, processorTag, null, configCopy, null);
+
+ // Validate that all configuration parameters were consumed
+ assertThat(configCopy, anEmptyMap());
+
+ return processor;
+ }
+
+ /**
+ * Helper to expect processor creation failure with specific message.
+ */
+ private void expectCreationFailure(Map config, Class extends Exception> exceptionClass, String expectedMessage) {
+ XmlProcessor.Factory factory = createFactory();
+ String processorTag = randomAlphaOfLength(10);
+
+ // Make a mutable copy since Map.of creates immutable maps
+ Map configCopy = new HashMap<>(config);
+
+ Exception exception = expectThrows(exceptionClass, () -> factory.create(null, processorTag, null, configCopy, null));
+ assertThat(exception.getMessage(), equalTo(expectedMessage));
+ }
+
+ /**
+ * Tests processor creation with various configurations.
+ */
+ public void testCreate() throws Exception {
+ Map config = Map.of(
+ "field",
+ DEFAULT_FIELD,
+ "target_field",
+ DEFAULT_TARGET_FIELD,
+ "ignore_missing",
+ true,
+ "to_lower",
+ true,
+ "remove_empty_values",
+ true
+ );
+
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.getTargetField(), equalTo(DEFAULT_TARGET_FIELD));
+ assertThat(processor.isIgnoreMissing(), equalTo(true));
+ assertThat(processor.isRemoveEmptyValues(), equalTo(true));
+ }
+
+ public void testCreateWithDefaults() throws Exception {
+ Map config = Map.of("field", DEFAULT_FIELD);
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.getTargetField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isIgnoreMissing(), equalTo(false));
+ assertThat(processor.isRemoveEmptyValues(), equalTo(false));
+ }
+
+ public void testCreateMissingField() throws Exception {
+ Map config = Map.of(); // Empty config - no field specified
+ expectCreationFailure(config, ElasticsearchParseException.class, "[field] required property is missing");
+ }
+
+ public void testCreateWithRemoveEmptyValuesOnly() throws Exception {
+ Map config = Map.of("field", DEFAULT_FIELD, "remove_empty_values", true);
+
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isRemoveEmptyValues(), equalTo(true));
+ assertThat(processor.isIgnoreMissing(), equalTo(false)); // other flags should remain default
+ }
+
+ public void testCreateWithXPath() throws Exception {
+ Map xpathConfig = Map.of("//author/text()", "author_field", "//title/@lang", "language_field");
+ Map config = Map.of("field", DEFAULT_FIELD, "xpath", xpathConfig);
+
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ }
+
+ public void testCreateWithInvalidXPathConfig() throws Exception {
+ Map config = Map.of(
+ "field",
+ DEFAULT_FIELD,
+ "xpath",
+ "invalid_string" // Should be a map
+ );
+
+ expectCreationFailure(config, ElasticsearchParseException.class, "[xpath] property isn't a map, but of type [java.lang.String]");
+ }
+
+ public void testCreateWithInvalidXPathTargetField() throws Exception {
+ Map xpathConfig = new HashMap<>();
+ xpathConfig.put("//author/text()", 123); // Should be string
+
+ Map config = Map.of("field", DEFAULT_FIELD, "xpath", xpathConfig);
+
+ expectCreationFailure(
+ config,
+ IllegalArgumentException.class,
+ "XPath [//author/text()] target field must be a string, got [Integer]"
+ );
+ }
+
+ public void testCreateWithNamespaces() throws Exception {
+ Map namespacesConfig = Map.of("book", "http://example.com/book", "author", "http://example.com/author");
+ Map config = Map.of("field", DEFAULT_FIELD, "namespaces", namespacesConfig);
+
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.getNamespaces(), equalTo(namespacesConfig));
+ }
+
+ public void testCreateWithInvalidNamespacesConfig() throws Exception {
+ Map config = Map.of(
+ "field",
+ DEFAULT_FIELD,
+ "namespaces",
+ "invalid_string" // Should be a map
+ );
+
+ expectCreationFailure(
+
+ config,
+
+ ElasticsearchParseException.class,
+
+ "[namespaces] property isn't a map, but of type [java.lang.String]"
+
+ );
+ }
+
+ public void testCreateWithInvalidNamespaceURI() throws Exception {
+ Map namespacesConfig = new HashMap<>();
+ namespacesConfig.put("book", 123); // Should be string
+
+ Map config = Map.of("field", DEFAULT_FIELD, "namespaces", namespacesConfig);
+
+ expectCreationFailure(config, IllegalArgumentException.class, "Namespace prefix [book] must have a string URI, got [Integer]");
+ }
+
+ public void testCreateWithXPathAndNamespaces() throws Exception {
+ Map xpathConfig = Map.of("//book:author/text()", "author_field", "//book:title/@lang", "language_field");
+ Map namespacesConfig = Map.of("book", "http://example.com/book");
+ Map config = Map.of("field", DEFAULT_FIELD, "xpath", xpathConfig, "namespaces", namespacesConfig);
+
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.getNamespaces(), equalTo(namespacesConfig));
+ }
+
+ // Tests for individual boolean options
+
+ public void testCreateWithStoreXmlFalse() throws Exception {
+ Map config = Map.of("field", DEFAULT_FIELD, "store_xml", false);
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isStoreXml(), equalTo(false));
+ }
+
+ public void testCreateWithRemoveNamespaces() throws Exception {
+ Map config = Map.of("field", DEFAULT_FIELD, "remove_namespaces", true);
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isRemoveNamespaces(), equalTo(true));
+ }
+
+ public void testCreateWithForceContent() throws Exception {
+ Map config = Map.of("field", DEFAULT_FIELD, "force_content", true);
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isForceContent(), equalTo(true));
+ }
+
+ public void testCreateWithForceArray() throws Exception {
+ Map config = Map.of("field", DEFAULT_FIELD, "force_array", true);
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isForceArray(), equalTo(true));
+ }
+
+ public void testCreateWithMultipleOptions() throws Exception {
+ Map config = Map.of(
+ "field",
+ DEFAULT_FIELD,
+ "ignore_missing",
+ true,
+ "force_content",
+ true,
+ "force_array",
+ true,
+ "remove_namespaces",
+ true
+ );
+ XmlProcessor processor = createProcessor(config);
+
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ assertThat(processor.isIgnoreMissing(), equalTo(true));
+ assertThat(processor.isForceContent(), equalTo(true));
+ assertThat(processor.isForceArray(), equalTo(true));
+ assertThat(processor.isRemoveNamespaces(), equalTo(true));
+ }
+
+ // Tests for XPath compilation errors (testing precompilation feature)
+
+ public void testCreateWithInvalidXPathExpression() throws Exception {
+ Map xpathConfig = Map.of("invalid xpath ][", "target_field");
+ Map config = Map.of("field", DEFAULT_FIELD, "xpath", xpathConfig);
+
+ XmlProcessor.Factory factory = createFactory();
+ String processorTag = randomAlphaOfLength(10);
+
+ // Make a mutable copy since Map.of creates immutable maps
+ Map configCopy = new HashMap<>(config);
+
+ IllegalArgumentException exception = expectThrows(
+ IllegalArgumentException.class,
+ () -> factory.create(null, processorTag, null, configCopy, null)
+ );
+
+ // Check that the error message contains the XPath expression and indicates it's invalid
+ assertThat(exception.getMessage(), containsString("Invalid XPath expression [invalid xpath ][]"));
+ assertThat(exception.getCause().getMessage(), containsString("javax.xml.transform.TransformerException"));
+ }
+
+ public void testCreateWithXPathUsingNamespacesWithoutConfiguration() throws Exception {
+ Map xpathConfig = Map.of("//book:title/text()", "title_field");
+ Map config = Map.of("field", DEFAULT_FIELD, "xpath", xpathConfig);
+
+ expectCreationFailure(
+ config,
+ IllegalArgumentException.class,
+ "Invalid XPath expression [//book:title/text()]: contains namespace prefixes but no namespace configuration provided"
+ );
+ }
+
+ public void testConfigurationParametersAreProperlyRemoved() throws Exception {
+ // Test that demonstrates configuration validation works when using production-like validation
+ // This test verifies that all valid configuration parameters are consumed during processor creation
+
+ Map xpathConfig = Map.of("//test", "test_field");
+ Map config = Map.of("field", DEFAULT_FIELD, "xpath", xpathConfig);
+
+ // This should succeed as all parameters are valid
+ XmlProcessor processor = createProcessor(config);
+ assertThat(processor.getField(), equalTo(DEFAULT_FIELD));
+ }
+}
diff --git a/modules/ingest-common/src/test/java/org/elasticsearch/ingest/common/XmlProcessorTests.java b/modules/ingest-common/src/test/java/org/elasticsearch/ingest/common/XmlProcessorTests.java
new file mode 100644
index 0000000000000..f5e7e5d4e30ee
--- /dev/null
+++ b/modules/ingest-common/src/test/java/org/elasticsearch/ingest/common/XmlProcessorTests.java
@@ -0,0 +1,578 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.ingest.common;
+
+import org.elasticsearch.ingest.IngestDocument;
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Tests for {@link XmlProcessor}. These tests ensure feature parity and test coverage.
+ */
+public class XmlProcessorTests extends ESTestCase {
+
+ private static final String XML_FIELD = "xmldata";
+ private static final String TARGET_FIELD = "data";
+
+ private static IngestDocument createTestIngestDocument(String xml) {
+ return new IngestDocument("_index", "_id", 1, null, null, new HashMap<>(Map.of(XML_FIELD, xml)));
+ }
+
+ private static XmlProcessor createTestProcessor(Map config) throws Exception {
+ config.putIfAbsent("field", XML_FIELD);
+ config.putIfAbsent("target_field", TARGET_FIELD);
+
+ XmlProcessor.Factory factory = new XmlProcessor.Factory();
+ return factory.create(null, "_tag", null, config, null);
+ }
+
+ /**
+ * Test parsing standard XML structure.
+ */
+ public void testParseStandardXml() throws Exception {
+ String xml = "";
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("key", "value"));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML with array elements (multiple elements with same name).
+ */
+ public void testParseXmlWithArrayValue() throws Exception {
+ String xml = """
+
+ value1
+ value2
+ """;
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("key", List.of("value1", "value2")));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML with nested elements.
+ */
+ public void testParseXmlWithNestedElements() throws Exception {
+ String xml = """
+
+
+ value
+
+ """;
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("key1", Map.of("key2", "value")));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML in a single item array.
+ */
+ public void testParseXmlInSingleItemArray() throws Exception {
+ String xml = "";
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("bar", "baz"));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test extracting a single element using XPath.
+ */
+ public void testXPathSingleElementExtraction() throws Exception {
+ String xml = """
+
+ hello
+ world
+ """;
+
+ Map xpathMap = Map.of("/foo/bar/text()", "bar_content");
+
+ Map config = new HashMap<>();
+ config.put("xpath", xpathMap);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ // Get the XPath result
+ Object barContent = ingestDocument.getFieldValue("bar_content", Object.class);
+ assertThat(barContent, equalTo("hello"));
+
+ // Verify that the full parsed XML is also available
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("bar", "hello", "baz", "world"));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test extracting multiple elements using XPath.
+ */
+ public void testXPathMultipleElementsExtraction() throws Exception {
+ String xml = """
+
+ first
+ second
+ third
+ """;
+
+ Map xpathMap = Map.of("/foo/bar", "all_bars");
+
+ Map config = new HashMap<>();
+ config.put("xpath", xpathMap);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ @SuppressWarnings("unchecked")
+ List allBars = (List) ingestDocument.getFieldValue("all_bars", List.class);
+ List expectedBars = List.of("first", "second", "third");
+ assertThat(allBars, equalTo(expectedBars));
+ }
+
+ /**
+ * Test extracting attributes using XPath.
+ */
+ public void testXPathAttributeExtraction() throws Exception {
+ String xml = """
+
+ content
+ """;
+
+ Map xpathMap = new HashMap<>();
+ xpathMap.put("/foo/bar/@id", "bar_id");
+ xpathMap.put("/foo/bar/@type", "bar_type");
+
+ Map config = new HashMap<>();
+ config.put("xpath", xpathMap);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ String barId = ingestDocument.getFieldValue("bar_id", String.class);
+ assertThat(barId, equalTo("123"));
+
+ String barType = ingestDocument.getFieldValue("bar_type", String.class);
+ assertThat(barType, equalTo("test"));
+ }
+
+ /**
+ * Test extracting elements with namespaces using XPath.
+ */
+ public void testXPathNamespacedExtraction() throws Exception {
+ String xml = """
+
+
+ namespace-value
+ regular-value
+ """;
+
+ Map namespaces = Map.of("myns", "http://example.org/ns1");
+ Map xpathMap = Map.of("//myns:element/text()", "ns_value");
+
+ Map config = new HashMap<>();
+ config.put("xpath", xpathMap);
+ config.put("namespaces", namespaces);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ String nsValue = ingestDocument.getFieldValue("ns_value", String.class);
+ assertThat(nsValue, equalTo("namespace-value"));
+ }
+
+ /**
+ * Test parsing XML with mixed content (text and elements mixed together).
+ */
+ public void testParseXmlWithMixedContent() throws Exception {
+ String xml = """
+
+ This text is bold and this is italic !
+ """;
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("b", "bold", "i", "italic", "#text", "This text is and this is !"));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML with CDATA sections.
+ */
+ public void testParseXmlWithCDATA() throws Exception {
+ String xml = " that shouldn't be parsed!]]> ";
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", "This is CDATA content with that shouldn't be parsed!");
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML with numeric data.
+ */
+ public void testParseXmlWithNumericData() throws Exception {
+ String xml = """
+
+ 123
+ 99.95
+ true
+ """;
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("count", "123", "price", "99.95", "active", "true"));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML with force_array option enabled.
+ */
+ public void testParseXmlWithForceArray() throws Exception {
+ String xml = "single_value ";
+
+ Map config = new HashMap<>();
+ config.put("force_array", true); // Enable force_array option
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("bar", List.of("single_value")));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test extracting multiple elements using multiple XPath expressions.
+ * Tests that multiple XPath expressions can be used simultaneously.
+ */
+ public void testMultipleXPathExpressions() throws Exception {
+ String xml = """
+
+
+ John
+ 30
+
+
+ Jane
+ 25
+
+ """;
+
+ // Configure multiple XPath expressions
+ Map xpathMap = new HashMap<>();
+ xpathMap.put("/root/person[1]/n/text()", "first_person_name");
+ xpathMap.put("/root/person[2]/n/text()", "second_person_name");
+ xpathMap.put("/root/person/@id", "person_ids");
+
+ Map config = new HashMap<>();
+ config.put("xpath", xpathMap);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ // Verify XPath results
+ Object firstName = ingestDocument.getFieldValue("first_person_name", Object.class);
+ assertThat(firstName, equalTo("John"));
+
+ Object secondName = ingestDocument.getFieldValue("second_person_name", Object.class);
+ assertThat(secondName, equalTo("Jane"));
+
+ List> personIds = ingestDocument.getFieldValue("person_ids", List.class);
+ assertThat(personIds, equalTo(List.of("1", "2")));
+
+ // Verify that the target field was also created (since storeXml defaults to true)
+ assertThat(ingestDocument.hasField(TARGET_FIELD), equalTo(true));
+ }
+
+ /**
+ * Test handling of invalid XML with ignoreFailure=false.
+ */
+ public void testInvalidXml() throws Exception {
+ String xml = ""; // Invalid XML missing closing tag
+
+ Map config = new HashMap<>();
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument));
+
+ assertThat(exception.getMessage(), containsString("invalid XML"));
+ }
+
+ /**
+ * Test handling of invalid XML with ignoreFailure=true.
+ * Note: The ignore_failure parameter is handled by the framework's OnFailureProcessor wrapper.
+ * When calling the processor directly (as in tests), exceptions are still thrown.
+ * This test verifies that the processor itself properly reports XML parsing errors.
+ */
+ public void testInvalidXmlWithIgnoreFailure() throws Exception {
+ String xml = ""; // Invalid XML missing closing tag
+
+ Map config = new HashMap<>();
+ config.put("ignore_failure", true);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ // Even with ignore_failure=true, calling the processor directly still throws exceptions
+ // The framework's OnFailureProcessor wrapper handles the ignore_failure behavior in production
+ IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument));
+
+ assertThat(exception.getMessage(), containsString("invalid XML"));
+ }
+
+ /**
+ * Test the store_xml=false option to not store parsed XML in target field.
+ */
+ public void testNoStoreXml() throws Exception {
+ String xml = "value ";
+
+ // Set up XPath to extract value but don't store XML
+ Map xpathMap = Map.of("/foo/bar/text()", "bar_content");
+
+ Map config = new HashMap<>();
+ config.put("store_xml", false); // Do not store XML in target field
+ config.put("xpath", xpathMap);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ // Verify XPath result is stored
+ String barContent = ingestDocument.getFieldValue("bar_content", String.class);
+ assertThat(barContent, equalTo("value"));
+
+ // Verify the target field was not created
+ assertThat(ingestDocument.hasField(TARGET_FIELD), is(false));
+ }
+
+ /**
+ * Test the to_lower option for converting field names to lowercase.
+ */
+ public void testToLower() throws Exception {
+ String xml = "value ";
+
+ Map config = new HashMap<>();
+ config.put("to_lower", true); // Enable to_lower option
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ // Verify field names are lowercase
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of("foo", Map.of("bar", "value"));
+ assertThat(data, equalTo(expectedData));
+ }
+
+ /**
+ * Test the ignore_missing option when field is missing.
+ */
+ public void testIgnoreMissing() throws Exception {
+ String xmlField = "nonexistent_field";
+
+ Map config = new HashMap<>();
+ config.put("field", xmlField);
+ config.put("ignore_missing", true); // Enable ignore_missing option
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = new IngestDocument("_index", "_id", 1, null, null, new HashMap<>(Map.of()));
+ processor.execute(ingestDocument);
+
+ assertThat("Target field should not be created when source field is missing", ingestDocument.hasField(TARGET_FIELD), is(false));
+
+ // With ignoreMissing=false
+ config.put("ignore_missing", false);
+ XmlProcessor failingProcessor = createTestProcessor(config);
+
+ // This should throw an exception
+ IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> failingProcessor.execute(ingestDocument));
+
+ assertThat(exception.getMessage(), containsString("not present as part of path"));
+ }
+
+ /**
+ * Test that remove_empty_values correctly filters out empty values from arrays and mixed content.
+ */
+ public void testRemoveEmptyValues() throws Exception {
+ // XML with mixed empty and non-empty elements, including array elements with mixed empty/non-empty values
+ String xml = """
+
+
+
+ content
+
+
+ nested-content
+
+
+ - first
+
+ - third
+ -
+ - fifth
+
+ Text with and content
+ """;
+
+ Map config = new HashMap<>();
+ config.put("remove_empty_values", true);
+ XmlProcessor processor = createTestProcessor(config);
+
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+ processor.execute(ingestDocument);
+
+ Map, ?> result = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedData = Map.of(
+ "root",
+ Map.of(
+ "valid",
+ "content",
+ "nested",
+ Map.of("valid", "nested-content"),
+ "items",
+ Map.of("item", List.of("first", "third", "fifth")),
+ "mixed",
+ Map.of("valid", "content", "#text", "Text with and")
+ )
+ );
+
+ assertThat(result, equalTo(expectedData));
+ }
+
+ /**
+ * Test parsing XML with remove_namespaces option.
+ */
+ public void testRemoveNamespaces() throws Exception {
+ String xml = """
+
+ value
+ """;
+
+ Map config = new HashMap<>();
+ config.put("remove_namespaces", true);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedDataWithoutNs = Map.of("foo", Map.of("bar", "value"));
+ assertThat(data, equalTo(expectedDataWithoutNs));
+
+ // Now test with removeNamespaces=false
+ IngestDocument ingestDocument2 = createTestIngestDocument(xml);
+
+ config.put("remove_namespaces", false);
+ XmlProcessor processor2 = createTestProcessor(config);
+ processor2.execute(ingestDocument2);
+
+ Map, ?> data2 = ingestDocument2.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedDataWithNs = Map.of("foo", Map.of("xmlns:ns", "http://example.org/ns", "ns:bar", "value"));
+ assertThat(data2, equalTo(expectedDataWithNs));
+ }
+
+ /**
+ * Test the force_content option.
+ */
+ public void testForceContent() throws Exception {
+ String xml = "simple text ";
+
+ Map config = new HashMap<>();
+ config.put("force_content", true);
+ XmlProcessor processor = createTestProcessor(config);
+ IngestDocument ingestDocument = createTestIngestDocument(xml);
+
+ processor.execute(ingestDocument);
+
+ Map, ?> data = ingestDocument.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedDataWithForceContent = Map.of("foo", Map.of("#text", "simple text"));
+ assertThat(data, equalTo(expectedDataWithForceContent));
+
+ // Now test with forceContent=false
+ config.put("force_content", false);
+ XmlProcessor processor2 = createTestProcessor(config);
+ IngestDocument ingestDocument2 = createTestIngestDocument(xml);
+
+ processor2.execute(ingestDocument2);
+
+ Map, ?> data2 = ingestDocument2.getFieldValue(TARGET_FIELD, Map.class);
+
+ Map expectedDataWithoutForceContent = Map.of("foo", "simple text");
+ assertThat(data2, equalTo(expectedDataWithoutForceContent));
+ }
+}
diff --git a/server/src/main/resources/transport/upper_bounds/8.18.csv b/server/src/main/resources/transport/upper_bounds/8.18.csv
index 4eb5140004ea6..266bfbbd3bf78 100644
--- a/server/src/main/resources/transport/upper_bounds/8.18.csv
+++ b/server/src/main/resources/transport/upper_bounds/8.18.csv
@@ -1 +1 @@
-initial_elasticsearch_8_18_6,8840008
+transform_check_for_dangling_tasks,8840011
diff --git a/server/src/main/resources/transport/upper_bounds/8.19.csv b/server/src/main/resources/transport/upper_bounds/8.19.csv
index 476468b203875..3600b3f8c633a 100644
--- a/server/src/main/resources/transport/upper_bounds/8.19.csv
+++ b/server/src/main/resources/transport/upper_bounds/8.19.csv
@@ -1 +1 @@
-initial_elasticsearch_8_19_3,8841067
+transform_check_for_dangling_tasks,8841070
diff --git a/server/src/main/resources/transport/upper_bounds/9.0.csv b/server/src/main/resources/transport/upper_bounds/9.0.csv
index f8f50cc6d7839..c11e6837bb813 100644
--- a/server/src/main/resources/transport/upper_bounds/9.0.csv
+++ b/server/src/main/resources/transport/upper_bounds/9.0.csv
@@ -1 +1 @@
-initial_elasticsearch_9_0_6,9000015
+transform_check_for_dangling_tasks,9000018
diff --git a/server/src/main/resources/transport/upper_bounds/9.1.csv b/server/src/main/resources/transport/upper_bounds/9.1.csv
index 5a65f2e578156..80b97d85f7511 100644
--- a/server/src/main/resources/transport/upper_bounds/9.1.csv
+++ b/server/src/main/resources/transport/upper_bounds/9.1.csv
@@ -1 +1 @@
-initial_elasticsearch_9_1_4,9112007
+transform_check_for_dangling_tasks,9112009
diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv
index e24f914a1d1ca..2147eab66c207 100644
--- a/server/src/main/resources/transport/upper_bounds/9.2.csv
+++ b/server/src/main/resources/transport/upper_bounds/9.2.csv
@@ -1 +1 @@
-ml_inference_endpoint_cache,9157000
+initial_9.2.0,9185000
diff --git a/server/src/main/resources/transport/upper_bounds/9.3.csv b/server/src/main/resources/transport/upper_bounds/9.3.csv
new file mode 100644
index 0000000000000..2147eab66c207
--- /dev/null
+++ b/server/src/main/resources/transport/upper_bounds/9.3.csv
@@ -0,0 +1 @@
+initial_9.2.0,9185000