ExchangeXml.java
package com.taxonomy.exchange;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.bootstrap.DOMImplementationRegistry;
import org.w3c.dom.ls.DOMImplementationLS;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** Shared bounded XML policy for exchange adapters. Uploaded schema locations never control validation. */
public final class ExchangeXml {
public static final int MAX_BYTES = 16 * 1024 * 1024;
public static final int MAX_ARTIFACTS = 10_000;
private static final int MAX_ELEMENTS = 250_000;
private static final int MAX_DEPTH = 96;
private static final ConcurrentHashMap<String, Schema> SCHEMAS = new ConcurrentHashMap<>();
private static final Set<String> ARCHIMATE = Set.of("archimate3_Diagram.xsd", "archimate3_View.xsd", "archimate3_Model.xsd", "dc.xsd", "xml.xsd");
private static final Set<String> REQIF = Set.of("reqif.xsd", "driver.xsd", "xml.xsd", "xhtml-attribs-1.xsd",
"xhtml-blkphras-1.xsd", "xhtml-blkpres-1.xsd", "xhtml-blkstruct-1.xsd", "xhtml-datatypes-1.xsd",
"xhtml-edit-1.xsd", "xhtml-framework-1.xsd", "xhtml-hypertext-1.xsd", "xhtml-inlphras-1.xsd",
"xhtml-inlpres-1.xsd", "xhtml-inlstruct-1.xsd", "xhtml-inlstyle-1.xsd", "xhtml-list-1.xsd",
"xhtml-object-1.xsd", "xhtml-param-1.xsd", "xhtml-pres-1.xsd", "xhtml-table-1.xsd", "xhtml-text-1.xsd");
private ExchangeXml() {}
/** Reviewed subsets must retain complete occurrence ancestry; an unreachable node is never silently dropped. */
public static void validatePlacements(com.taxonomy.extension.api.integration.IntegrationContracts.ExchangeDocument document) {
var placements = new java.util.HashMap<String, com.taxonomy.extension.api.integration.IntegrationContracts.Placement>();
Set<String> containers = new java.util.HashSet<>(), objects = new java.util.HashSet<>();
if (ArchiMateExchangeCodec.PROFILE.equals(document.profile())) containers.add("organizations");
for (var artifact : document.artifacts()) {
objects.add(artifact.id());
if (artifact.kind() == com.taxonomy.extension.api.integration.IntegrationContracts.ArtifactKind.SPECIFICATION
|| artifact.kind() == com.taxonomy.extension.api.integration.IntegrationContracts.ArtifactKind.VIEW) containers.add(artifact.id());
}
document.relations().forEach(relation -> objects.add(relation.id()));
for (var placement : document.placements()) if (placements.putIfAbsent(placement.id(), placement) != null)
throw invalid("DUPLICATE_IDENTITY", "Duplicate occurrence identity");
for (var placement : document.placements()) {
if (!containers.contains(placement.containerId()) || placement.position() < 0
|| placement.artifactId() != null && !placement.artifactId().isEmpty() && !objects.contains(placement.artifactId()))
throw invalid("HIERARCHY_REFERENCE", "A reviewed occurrence has a missing container or target");
Set<String> ancestry = new java.util.HashSet<>(); var node = placement;
while (node != null) {
if (!ancestry.add(node.id()) || ancestry.size() > MAX_DEPTH) throw invalid("HIERARCHY_CYCLE", "Occurrence ancestry is cyclic or exceeds the depth limit");
String parent = node.parentId(); if (parent == null) break;
node = placements.get(parent);
if (node == null || !node.containerId().equals(placement.containerId()))
throw invalid("HIERARCHY_REFERENCE", "A reviewed occurrence has a missing or incompatible parent");
}
}
}
/** Namespace-aware semantic comparison; formatting and XML prefix choices are not model changes. */
public static String semantic(String source) {
StringBuilder result = new StringBuilder(); semantic(parse(source.getBytes(StandardCharsets.UTF_8)).getDocumentElement(), result); return result.toString();
}
private static void semantic(Element node, StringBuilder result) {
part(result, "{" + node.getNamespaceURI() + "}" + node.getLocalName());
var attributes = new java.util.TreeMap<String, String>();
for (int i = 0; i < node.getAttributes().getLength(); i++) {
Node attribute = node.getAttributes().item(i);
if (!XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(attribute.getNamespaceURI()) && !"LAST-CHANGE".equals(attribute.getNodeName())) {
String value = attribute.getNodeValue();
if (XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI.equals(attribute.getNamespaceURI()) && "type".equals(attribute.getLocalName())) {
int colon = value.indexOf(':'); String prefix = colon < 0 ? null : value.substring(0, colon);
value = "{" + node.lookupNamespaceURI(prefix) + "}" + value.substring(colon + 1);
}
attributes.put("{" + attribute.getNamespaceURI() + "}" + attribute.getLocalName(), value);
}
}
attributes.forEach((key, value) -> { part(result, key); part(result, value); }); result.append('|');
boolean rich = "http://www.w3.org/1999/xhtml".equals(node.getNamespaceURI());
for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) {
if (child instanceof Element element) semantic(element, result);
else if ((child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE)
&& (rich || !child.getNodeValue().isBlank())) part(result, child.getNodeValue());
}
result.append(';');
}
private static void part(StringBuilder target, String value) { target.append(value.length()).append(':').append(value); }
public static Document parse(byte[] content) {
if (content == null || content.length == 0 || content.length > MAX_BYTES)
throw invalid("PACKAGE_SIZE", "Exchange file must contain between 1 byte and 16 MiB");
try {
SAXParserFactory sax = SAXParserFactory.newInstance();
sax.setNamespaceAware(true);
sax.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
sax.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
sax.setFeature("http://xml.org/sax/features/external-general-entities", false);
sax.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
var reader = sax.newSAXParser().getXMLReader();
reader.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
reader.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
var bounds = new DefaultHandler() {
int depth, elements;
@Override public void startElement(String uri, String local, String name, org.xml.sax.Attributes attributes) throws SAXException {
if (++depth > MAX_DEPTH || ++elements > MAX_ELEMENTS || attributes.getLength() > 128)
throw new SAXException("XML complexity limit");
}
@Override public void endElement(String uri, String local, String name) { depth--; }
@Override public void error(SAXParseException e) throws SAXException { throw e; }
@Override public void fatalError(SAXParseException e) throws SAXException { throw e; }
};
reader.setContentHandler(bounds); reader.setErrorHandler(bounds);
reader.parse(new org.xml.sax.InputSource(new ByteArrayInputStream(content)));
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
var builder = factory.newDocumentBuilder(); builder.setErrorHandler(bounds);
Document document = builder.parse(new ByteArrayInputStream(content));
passiveXhtml(document); return document;
} catch (ExchangeFormatException rejected) { throw rejected; }
catch (Exception rejected) { throw invalid("INVALID_XML", "Malformed, unsafe or over-complex exchange XML"); }
}
private static void passiveXhtml(Document document) {
for (Element element : all(document, "http://www.w3.org/1999/xhtml", "*")) {
if (Set.of("script", "iframe", "embed", "link", "style", "form", "input", "button").contains(element.getLocalName().toLowerCase(java.util.Locale.ROOT)))
throw invalid("ACTIVE_XHTML_REJECTED", "Active rich-text content is outside the supported exchange profile");
for (int i = 0; i < element.getAttributes().getLength(); i++) {
Node attribute = element.getAttributes().item(i);
String name = attribute.getLocalName().toLowerCase(java.util.Locale.ROOT), value = attribute.getNodeValue().strip();
if (name.startsWith("on")) throw invalid("ACTIVE_XHTML_REJECTED", "Rich-text event handlers are not supported");
if (name.equals("style") && (value.contains("\\") || value.toLowerCase(java.util.Locale.ROOT).matches("(?s).*(url\\s*\\(|expression|@import|behavior|-moz-binding).*")))
throw invalid("ACTIVE_XHTML_REJECTED", "External or executable CSS is not supported");
if (Set.of("href", "src", "data", "action", "background", "codebase").contains(name)) {
java.net.URI uri;
try { uri = java.net.URI.create(value); }
catch (IllegalArgumentException failure) { throw invalid("ACTIVE_XHTML_REJECTED", "Invalid rich-text resource URI"); }
if (value.startsWith("//") || uri.getUserInfo() != null || uri.getScheme() != null
&& !(name.equals("href") && Set.of("https", "http", "mailto").contains(uri.getScheme().toLowerCase(java.util.Locale.ROOT))))
throw invalid("ACTIVE_XHTML_REJECTED", "Only passive links and package-relative rich-text assets are supported");
}
}
}
}
public static void validate(Document document, String profile) {
try {
var validator = SCHEMAS.computeIfAbsent(profile, ExchangeXml::schema).newValidator();
validator.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
validator.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
validator.validate(new DOMSource(document));
} catch (Exception rejected) { throw invalid("SCHEMA_INVALID", "Exchange document does not satisfy the pinned supported schema"); }
}
private static Schema schema(String profile) {
String root = switch (profile) { case "reqif-1.2" -> "/reqif-1.2/"; case "archimate-3.1" -> "/archimate-3.1/"; default -> throw invalid("UNKNOWN_PROFILE", "Unknown exchange profile"); };
Set<String> files = profile.equals("reqif-1.2") ? REQIF : ARCHIMATE;
String entry = profile.equals("reqif-1.2") ? "reqif.xsd" : "archimate3_Diagram.xsd";
try {
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
var implementation = (DOMImplementationLS) DOMImplementationRegistry.newInstance().getDOMImplementation("LS");
factory.setResourceResolver((type, namespace, publicId, systemId, base) -> {
String name = systemId == null ? "" : systemId.substring(systemId.lastIndexOf('/') + 1);
if (!files.contains(name)) throw invalid("SCHEMA_REFERENCE", "Unpinned schema reference");
var input = implementation.createLSInput();
input.setPublicId(publicId); input.setSystemId(root + name);
input.setByteStream(ExchangeXml.class.getResourceAsStream(root + name));
if (input.getByteStream() == null) throw invalid("SCHEMA_MISSING", "Pinned schema is missing");
return input;
});
try (var stream = ExchangeXml.class.getResourceAsStream(root + entry)) {
if (stream == null) throw invalid("SCHEMA_MISSING", "Pinned schema is missing");
return factory.newSchema(new StreamSource(stream, root + entry));
}
} catch (Exception failure) { throw new IllegalStateException("Cannot load pinned exchange schema", failure); }
}
public static byte[] write(Node node) {
try {
TransformerFactory factory = TransformerFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
var transformer = factory.newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, node instanceof Document ? "no" : "yes");
var output = new ByteArrayOutputStream();
transformer.transform(new DOMSource(node), new StreamResult(output));
if (output.size() > MAX_BYTES) throw invalid("PACKAGE_SIZE", "Generated exchange exceeds 16 MiB");
return output.toByteArray();
} catch (ExchangeFormatException e) { throw e; }
catch (Exception e) { throw invalid("XML_WRITE", "Cannot serialize exchange document"); }
}
public static String xml(Node node) { return new String(write(node), StandardCharsets.UTF_8); }
public static List<Element> children(Node node) {
List<Element> result = new ArrayList<>();
if (node != null) for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling())
if (child instanceof Element element) result.add(element);
return result;
}
public static Element child(Node parent, String local) {
return children(parent).stream().filter(e -> local.equals(e.getLocalName())).findFirst().orElse(null);
}
public static List<Element> all(Document doc, String namespace, String local) {
var nodes = doc.getElementsByTagNameNS(namespace, local); List<Element> result = new ArrayList<>();
for (int i = 0; i < nodes.getLength(); i++) result.add((Element) nodes.item(i));
return result;
}
public static String text(Node parent, String local) {
Element element = child(parent, local); return element == null ? "" : element.getTextContent();
}
public static Element append(Node parent, String ns, String name) {
Document doc = parent instanceof Document d ? d : parent.getOwnerDocument();
Element result = doc.createElementNS(ns, name); parent.appendChild(result); return result;
}
public static Element text(Node parent, String ns, String name, String value) {
Element result = append(parent, ns, name); result.setTextContent(value); return result;
}
public static String required(Element element, String attribute) {
String value = element.getAttribute(attribute);
if (value.isBlank() || value.length() > 2048) throw invalid("INVALID_IDENTITY", "Missing or oversized external identity");
return value;
}
public static ExchangeFormatException invalid(String code, String message) { return new ExchangeFormatException(code, message); }
}