Dom.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. /**
  7. * Magento configuration XML DOM utility
  8. */
  9. namespace Magento\Framework\Config;
  10. use Magento\Framework\Config\Dom\UrnResolver;
  11. use Magento\Framework\Config\Dom\ValidationSchemaException;
  12. use Magento\Framework\Phrase;
  13. /**
  14. * Class Dom
  15. *
  16. * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  17. * @api
  18. * @since 100.0.2
  19. */
  20. class Dom
  21. {
  22. /**
  23. * Prefix which will be used for root namespace
  24. */
  25. const ROOT_NAMESPACE_PREFIX = 'x';
  26. /**
  27. * Format of items in errors array to be used by default. Available placeholders - fields of \LibXMLError.
  28. */
  29. const ERROR_FORMAT_DEFAULT = "%message%\nLine: %line%\n";
  30. /**
  31. * @var \Magento\Framework\Config\ValidationStateInterface
  32. */
  33. private $validationState;
  34. /**
  35. * Dom document
  36. *
  37. * @var \DOMDocument
  38. */
  39. protected $dom;
  40. /**
  41. * @var Dom\NodeMergingConfig
  42. */
  43. protected $nodeMergingConfig;
  44. /**
  45. * Name of attribute that specifies type of argument node
  46. *
  47. * @var string|null
  48. */
  49. protected $typeAttributeName;
  50. /**
  51. * Schema validation file
  52. *
  53. * @var string
  54. */
  55. protected $schema;
  56. /**
  57. * Format of error messages
  58. *
  59. * @var string
  60. */
  61. protected $errorFormat;
  62. /**
  63. * Default namespace for xml elements
  64. *
  65. * @var string
  66. */
  67. protected $rootNamespace;
  68. /**
  69. * @var \Magento\Framework\Config\Dom\UrnResolver
  70. */
  71. private static $urnResolver;
  72. /**
  73. * @var array
  74. */
  75. private static $resolvedSchemaPaths = [];
  76. /**
  77. * Build DOM with initial XML contents and specifying identifier attributes for merging
  78. *
  79. * Format of $idAttributes: array('/xpath/to/some/node' => 'id_attribute_name')
  80. * The path to ID attribute name should not include any attribute notations or modifiers -- only node names
  81. *
  82. * @param string $xml
  83. * @param \Magento\Framework\Config\ValidationStateInterface $validationState
  84. * @param array $idAttributes
  85. * @param string $typeAttributeName
  86. * @param string $schemaFile
  87. * @param string $errorFormat
  88. */
  89. public function __construct(
  90. $xml,
  91. \Magento\Framework\Config\ValidationStateInterface $validationState,
  92. array $idAttributes = [],
  93. $typeAttributeName = null,
  94. $schemaFile = null,
  95. $errorFormat = self::ERROR_FORMAT_DEFAULT
  96. ) {
  97. $this->validationState = $validationState;
  98. $this->schema = $schemaFile;
  99. $this->nodeMergingConfig = new Dom\NodeMergingConfig(new Dom\NodePathMatcher(), $idAttributes);
  100. $this->typeAttributeName = $typeAttributeName;
  101. $this->errorFormat = $errorFormat;
  102. $this->dom = $this->_initDom($xml);
  103. $this->rootNamespace = $this->dom->lookupNamespaceUri($this->dom->namespaceURI);
  104. }
  105. /**
  106. * Retrieve array of xml errors
  107. *
  108. * @param string $errorFormat
  109. * @return string[]
  110. */
  111. private static function getXmlErrors($errorFormat)
  112. {
  113. $errors = [];
  114. $validationErrors = libxml_get_errors();
  115. if (count($validationErrors)) {
  116. foreach ($validationErrors as $error) {
  117. $errors[] = self::_renderErrorMessage($error, $errorFormat);
  118. }
  119. } else {
  120. $errors[] = 'Unknown validation error';
  121. }
  122. return $errors;
  123. }
  124. /**
  125. * Merge $xml into DOM document
  126. *
  127. * @param string $xml
  128. * @return void
  129. */
  130. public function merge($xml)
  131. {
  132. $dom = $this->_initDom($xml);
  133. $this->_mergeNode($dom->documentElement, '');
  134. }
  135. /**
  136. * Recursive merging of the \DOMElement into the original document
  137. *
  138. * Algorithm:
  139. * 1. Find the same node in original document
  140. * 2. Extend and override original document node attributes and scalar value if found
  141. * 3. Append new node if original document doesn't have the same node
  142. *
  143. * @param \DOMElement $node
  144. * @param string $parentPath path to parent node
  145. * @return void
  146. * @SuppressWarnings(PHPMD.CyclomaticComplexity)
  147. */
  148. protected function _mergeNode(\DOMElement $node, $parentPath)
  149. {
  150. $path = $this->_getNodePathByParent($node, $parentPath);
  151. $matchedNode = $this->_getMatchedNode($path);
  152. /* Update matched node attributes and value */
  153. if ($matchedNode) {
  154. //different node type
  155. if ($this->typeAttributeName &&
  156. $node->hasAttribute($this->typeAttributeName) &&
  157. $matchedNode->hasAttribute($this->typeAttributeName) &&
  158. $node->getAttribute($this->typeAttributeName) !== $matchedNode->getAttribute($this->typeAttributeName)
  159. ) {
  160. $parentMatchedNode = $this->_getMatchedNode($parentPath);
  161. $newNode = $this->dom->importNode($node, true);
  162. $parentMatchedNode->replaceChild($newNode, $matchedNode);
  163. return;
  164. }
  165. $this->_mergeAttributes($matchedNode, $node);
  166. if (!$node->hasChildNodes()) {
  167. return;
  168. }
  169. /* override node value */
  170. if ($this->_isTextNode($node)) {
  171. /* skip the case when the matched node has children, otherwise they get overridden */
  172. if (!$matchedNode->hasChildNodes() || $this->_isTextNode($matchedNode)) {
  173. $matchedNode->nodeValue = $node->childNodes->item(0)->nodeValue;
  174. }
  175. } else {
  176. /* recursive merge for all child nodes */
  177. foreach ($node->childNodes as $childNode) {
  178. if ($childNode instanceof \DOMElement) {
  179. $this->_mergeNode($childNode, $path);
  180. }
  181. }
  182. }
  183. } else {
  184. /* Add node as is to the document under the same parent element */
  185. $parentMatchedNode = $this->_getMatchedNode($parentPath);
  186. $newNode = $this->dom->importNode($node, true);
  187. $parentMatchedNode->appendChild($newNode);
  188. }
  189. }
  190. /**
  191. * Check if the node content is text
  192. *
  193. * @param \DOMElement $node
  194. * @return bool
  195. */
  196. protected function _isTextNode($node)
  197. {
  198. return $node->childNodes->length == 1 && $node->childNodes->item(0) instanceof \DOMText;
  199. }
  200. /**
  201. * Merges attributes of the merge node to the base node
  202. *
  203. * @param \DOMElement $baseNode
  204. * @param \DOMNode $mergeNode
  205. * @return void
  206. */
  207. protected function _mergeAttributes($baseNode, $mergeNode)
  208. {
  209. foreach ($mergeNode->attributes as $attribute) {
  210. $baseNode->setAttribute($this->_getAttributeName($attribute), $attribute->value);
  211. }
  212. }
  213. /**
  214. * Identify node path based on parent path and node attributes
  215. *
  216. * @param \DOMElement $node
  217. * @param string $parentPath
  218. * @return string
  219. */
  220. protected function _getNodePathByParent(\DOMElement $node, $parentPath)
  221. {
  222. $prefix = $this->rootNamespace === null ? '' : self::ROOT_NAMESPACE_PREFIX . ':';
  223. $path = $parentPath . '/' . $prefix . $node->tagName;
  224. $idAttribute = $this->nodeMergingConfig->getIdAttribute($path);
  225. if (is_array($idAttribute)) {
  226. $constraints = [];
  227. foreach ($idAttribute as $attribute) {
  228. $value = $node->getAttribute($attribute);
  229. $constraints[] = "@{$attribute}='{$value}'";
  230. }
  231. $path .= '[' . implode(' and ', $constraints) . ']';
  232. } elseif ($idAttribute && ($value = $node->getAttribute($idAttribute))) {
  233. $path .= "[@{$idAttribute}='{$value}']";
  234. }
  235. return $path;
  236. }
  237. /**
  238. * Getter for node by path
  239. *
  240. * @param string $nodePath
  241. * @throws \Magento\Framework\Exception\LocalizedException An exception is possible if original document contains
  242. * multiple nodes for identifier
  243. * @return \DOMElement|null
  244. */
  245. protected function _getMatchedNode($nodePath)
  246. {
  247. $xPath = new \DOMXPath($this->dom);
  248. if ($this->rootNamespace) {
  249. $xPath->registerNamespace(self::ROOT_NAMESPACE_PREFIX, $this->rootNamespace);
  250. }
  251. $matchedNodes = $xPath->query($nodePath);
  252. $node = null;
  253. if ($matchedNodes->length > 1) {
  254. throw new \Magento\Framework\Exception\LocalizedException(
  255. new \Magento\Framework\Phrase(
  256. "More than one node matching the query: %1, Xml is: %2",
  257. [$nodePath, $this->dom->saveXML()]
  258. )
  259. );
  260. } elseif ($matchedNodes->length == 1) {
  261. $node = $matchedNodes->item(0);
  262. }
  263. return $node;
  264. }
  265. /**
  266. * Validate dom document
  267. *
  268. * @param \DOMDocument $dom
  269. * @param string $schema Absolute schema file path or URN
  270. * @param string $errorFormat
  271. * @return array of errors
  272. * @throws \Exception
  273. */
  274. public static function validateDomDocument(
  275. \DOMDocument $dom,
  276. $schema,
  277. $errorFormat = self::ERROR_FORMAT_DEFAULT
  278. ) {
  279. if (!function_exists('libxml_set_external_entity_loader')) {
  280. return [];
  281. }
  282. if (!self::$urnResolver) {
  283. self::$urnResolver = new UrnResolver();
  284. }
  285. if (!isset(self::$resolvedSchemaPaths[$schema])) {
  286. self::$resolvedSchemaPaths[$schema] = self::$urnResolver->getRealPath($schema);
  287. }
  288. $schema = self::$resolvedSchemaPaths[$schema];
  289. libxml_use_internal_errors(true);
  290. libxml_set_external_entity_loader([self::$urnResolver, 'registerEntityLoader']);
  291. $errors = [];
  292. try {
  293. $result = $dom->schemaValidate($schema);
  294. if (!$result) {
  295. $errors = self::getXmlErrors($errorFormat);
  296. }
  297. } catch (\Exception $exception) {
  298. $errors = self::getXmlErrors($errorFormat);
  299. libxml_use_internal_errors(false);
  300. array_unshift($errors, new Phrase('Processed schema file: %1', [$schema]));
  301. throw new ValidationSchemaException(new Phrase(implode("\n", $errors)));
  302. }
  303. libxml_set_external_entity_loader(null);
  304. libxml_use_internal_errors(false);
  305. return $errors;
  306. }
  307. /**
  308. * Render error message string by replacing placeholders '%field%' with properties of \LibXMLError
  309. *
  310. * @param \LibXMLError $errorInfo
  311. * @param string $format
  312. * @return string
  313. * @throws \InvalidArgumentException
  314. */
  315. private static function _renderErrorMessage(\LibXMLError $errorInfo, $format)
  316. {
  317. $result = $format;
  318. foreach ($errorInfo as $field => $value) {
  319. $placeholder = '%' . $field . '%';
  320. $value = trim((string)$value);
  321. $result = str_replace($placeholder, $value, $result);
  322. }
  323. if (strpos($result, '%') !== false) {
  324. if (preg_match_all('/%.+%/', $result, $matches)) {
  325. $unsupported = [];
  326. foreach ($matches[0] as $placeholder) {
  327. if (strpos($result, $placeholder) !== false) {
  328. $unsupported[] = $placeholder;
  329. }
  330. }
  331. if (!empty($unsupported)) {
  332. throw new \InvalidArgumentException(
  333. "Error format '{$format}' contains unsupported placeholders: " . implode(', ', $unsupported)
  334. );
  335. }
  336. }
  337. }
  338. return $result;
  339. }
  340. /**
  341. * DOM document getter
  342. *
  343. * @return \DOMDocument
  344. */
  345. public function getDom()
  346. {
  347. return $this->dom;
  348. }
  349. /**
  350. * Create DOM document based on $xml parameter
  351. *
  352. * @param string $xml
  353. * @return \DOMDocument
  354. * @throws \Magento\Framework\Config\Dom\ValidationException
  355. */
  356. protected function _initDom($xml)
  357. {
  358. $dom = new \DOMDocument();
  359. $useErrors = libxml_use_internal_errors(true);
  360. $res = $dom->loadXML($xml);
  361. if (!$res) {
  362. $errors = self::getXmlErrors($this->errorFormat);
  363. libxml_use_internal_errors($useErrors);
  364. throw new \Magento\Framework\Config\Dom\ValidationException(implode("\n", $errors));
  365. }
  366. libxml_use_internal_errors($useErrors);
  367. if ($this->validationState->isValidationRequired() && $this->schema) {
  368. $errors = $this->validateDomDocument($dom, $this->schema, $this->errorFormat);
  369. if (count($errors)) {
  370. throw new \Magento\Framework\Config\Dom\ValidationException(implode("\n", $errors));
  371. }
  372. }
  373. return $dom;
  374. }
  375. /**
  376. * Validate self contents towards to specified schema
  377. *
  378. * @param string $schemaFileName absolute path to schema file
  379. * @param array &$errors
  380. * @return bool
  381. */
  382. public function validate($schemaFileName, &$errors = [])
  383. {
  384. if ($this->validationState->isValidationRequired()) {
  385. $errors = $this->validateDomDocument($this->dom, $schemaFileName, $this->errorFormat);
  386. return !count($errors);
  387. }
  388. return true;
  389. }
  390. /**
  391. * Set schema file
  392. *
  393. * @param string $schemaFile
  394. * @return $this
  395. */
  396. public function setSchemaFile($schemaFile)
  397. {
  398. $this->schema = $schemaFile;
  399. return $this;
  400. }
  401. /**
  402. * Returns the attribute name with prefix, if there is one
  403. *
  404. * @param \DOMAttr $attribute
  405. * @return string
  406. */
  407. private function _getAttributeName($attribute)
  408. {
  409. if ($attribute->prefix !== null && !empty($attribute->prefix)) {
  410. $attributeName = $attribute->prefix . ':' . $attribute->name;
  411. } else {
  412. $attributeName = $attribute->name;
  413. }
  414. return $attributeName;
  415. }
  416. }