<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

namespace Magento\Framework\Xml;

use DOMDocument;

/**
 * The XML Security feature
 */
class Security
{
    /**
     * Heuristic scan to detect entity in XML
     *
     * @param string $xmlContent
     * @return bool
     */
    private function heuristicScan($xmlContent)
    {
        return strpos((string)$xmlContent, '<!ENTITY') === false;
    }

    /**
     * Return true if PHP is running with PHP-FPM
     *
     * @return bool
     */
    private function isPhpFpm()
    {
        return substr(php_sapi_name(), 0, 3) === 'fpm';
    }

    /**
     * Security check loaded XML document
     *
     * @param string $xmlContent
     * @return bool
     *
     * @SuppressWarnings(PHPMD.UnusedLocalVariable)
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     * @SuppressWarnings("unused")
     */
    public function scan($xmlContent)
    {
        /**
         * If running with PHP-FPM we perform an heuristic scan
         * We cannot use libxml_disable_entity_loader because of this bug
         * @see https://bugs.php.net/bug.php?id=64938
         */
        if ($this->isPhpFpm()) {
            return $this->heuristicScan($xmlContent);
        }

        $document = new DOMDocument();

        if (version_compare(PHP_VERSION, '8.0') < 0) {
            // this function no longer has an effect in PHP 8.0, but it's required in earlier versions
            // phpcs:ignore
            $loadEntities = libxml_disable_entity_loader(true);
        }
        $useInternalXmlErrors = libxml_use_internal_errors(true);

        /**
         * Load XML with network access disabled (LIBXML_NONET)
         * error disabled with @ for PHP-FPM scenario
         * Works for PHP < 8
         */
        set_error_handler(
            function ($errno, $errstr) {
                if (substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
                    return true; // ignore default php error handler, $document->loadXML return false
                }
                return false;
            },
            E_WARNING
        );

        try {
            $result = (bool)$document->loadXML($xmlContent, LIBXML_NONET);
        } catch (\ValueError $exception) {
            // In PHP 8, $document->loadXML with an empty content will generate a ValueError.
            // This check emulates the previous (php 7) behaviour.
            if (substr_count($exception->getMessage(), 'DOMDocument::loadXML()') > 0) {
                $result = false;
            } else {
                throw $exception;
            }
        }
        restore_error_handler();
        // Entity load to previous setting
        if (isset($loadEntities)) {
            // phpcs:ignore
            libxml_disable_entity_loader($loadEntities);
        }
        libxml_use_internal_errors($useInternalXmlErrors);

        if (!$result) {
            return false;
        }

        foreach ($document->childNodes as $child) {
            if ($child->nodeType === XML_DOCUMENT_TYPE_NODE) {
                if ($child->entities->length > 0) {
                    return false;
                }
            }
        }

        return true;
    }
}
