// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\File\Ini; use ErrorException; use Icinga\File\Ini\Dom\Section; use Icinga\File\Ini\Dom\Comment; use Icinga\File\Ini\Dom\Document; use Icinga\File\Ini\Dom\Directive; use Icinga\Application\Logger; use Icinga\Exception\ConfigurationError; use Icinga\Exception\NotReadableError; use Icinga\Application\Config; class IniParser { const LINE_START = 0; const SECTION = 1; const ESCAPE = 2; const DIRECTIVE_KEY = 4; const DIRECTIVE_VALUE_START = 5; const DIRECTIVE_VALUE = 6; const DIRECTIVE_VALUE_QUOTED = 7; const COMMENT = 8; const COMMENT_END = 9; const LINE_END = 10; /** * Cancel the parsing with an error * * @param $message The error description * @param $line The line in which the error occured * * @throws ConfigurationError */ private static function throwParseError($message, $line) { throw new ConfigurationError(sprintf('Ini parser error: %s. (l. %d)', $message, $line)); } /** * Read the ini file contained in a string and return a mutable DOM that can be used * to change the content of an INI file. * * @param $str A string containing the whole ini file * * @return Document The mutable DOM object. * @throws ConfigurationError In case the file is not parseable */ public static function parseIni($str) { $doc = new Document(); $sec = null; $dir = null; $coms = array(); $state = self::LINE_START; $escaping = null; $token = ''; $line = 0; for ($i = 0; $i < strlen($str); $i++) { $s = $str[$i]; switch ($state) { case self::LINE_START: if (ctype_space($s)) { continue 2; } switch ($s) { case '[': $state = self::SECTION; break; case ';': $state = self::COMMENT; break; default: $state = self::DIRECTIVE_KEY; $token = $s; break; } break; case self::ESCAPE: $token .= $s; $state = $escaping; $escaping = null; break; case self::SECTION: if ($s === "\n") { self::throwParseError('Unterminated SECTION', $line); } elseif ($s === '\\') { $state = self::ESCAPE; $escaping = self::SECTION; } elseif ($s !== ']') { $token .= $s; } else { $sec = new Section($token); $sec->setCommentsPre($coms); $doc->addSection($sec); $dir = null; $coms = array(); $state = self::LINE_END; $token = ''; } break; case self::DIRECTIVE_KEY: if ($s !== '=') { $token .= $s; } else { $dir = new Directive($token); $dir->setCommentsPre($coms); if (isset($sec)) { $sec->addDirective($dir); } else { Logger::warning(sprintf( 'Ini parser warning: section-less directive "%s" ignored. (l. %d)', $token, $line )); } $coms = array(); $state = self::DIRECTIVE_VALUE_START; $token = ''; } break; case self::DIRECTIVE_VALUE_START: if (ctype_space($s)) { continue 2; } elseif ($s === '"') { $state = self::DIRECTIVE_VALUE_QUOTED; } else { $state = self::DIRECTIVE_VALUE; $token = $s; } break; case self::DIRECTIVE_VALUE: /* Escaping non-quoted values is not supported by php_parse_ini, it might be reasonable to include in case we are switching completely our own parser implementation */ if ($s === "\n" || $s === ";") { $dir->setValue($token); $token = ''; if ($s === "\n") { $state = self::LINE_START; $line ++; } elseif ($s === ';') { $state = self::COMMENT; } } else { $token .= $s; } break; case self::DIRECTIVE_VALUE_QUOTED: if ($s === '\\') { $state = self::ESCAPE; $escaping = self::DIRECTIVE_VALUE_QUOTED; } elseif ($s !== '"') { $token .= $s; } else { $dir->setValue($token); $token = ''; $state = self::LINE_END; } break; case self::COMMENT: case self::COMMENT_END: if ($s !== "\n") { $token .= $s; } else { $com = new Comment(); $com->setContent($token); $token = ''; // Comments at the line end belong to the current line's directive or section. Comments // on empty lines belong to the next directive that shows up. if ($state === self::COMMENT_END) { if (isset($dir)) { $dir->setCommentPost($com); } else { $sec->setCommentPost($com); } } else { $coms[] = $com; } $state = self::LINE_START; $line ++; } break; case self::LINE_END: if ($s === "\n") { $state = self::LINE_START; $line ++; } elseif ($s === ';') { $state = self::COMMENT_END; } break; } } // process the last token switch ($state) { case self::COMMENT: case self::COMMENT_END: $com = new Comment(); $com->setContent($token); if ($state === self::COMMENT_END) { if (isset($dir)) { $dir->setCommentPost($com); } else { $sec->setCommentPost($com); } } else { $coms[] = $com; } break; case self::DIRECTIVE_VALUE: $dir->setValue($token); $sec->addDirective($dir); break; case self::ESCAPE: case self::DIRECTIVE_VALUE_QUOTED: case self::DIRECTIVE_KEY: case self::SECTION: self::throwParseError('File ended in unterminated state ' . $state, $line); } if (! empty($coms)) { $doc->setCommentsDangling($coms); } return $doc; } /** * Read the ini file and parse it with ::parseIni() * * @param string $file The ini file to read * * @return Config * @throws NotReadableError When the file cannot be read */ public static function parseIniFile($file) { if (($path = realpath($file)) === false) { throw new NotReadableError('Couldn\'t compute the absolute path of `%s\'', $file); } if (($content = file_get_contents($path)) === false) { throw new NotReadableError('Couldn\'t read the file `%s\'', $path); } try { $configArray = parse_ini_string($content, true, INI_SCANNER_RAW); } catch (ErrorException $e) { throw new ConfigurationError('Couldn\'t parse the INI file `%s\'', $path, $e); } $unescaped = array(); foreach ($configArray as $section => $options) { $unescaped[self::unescapeSectionName($section)] = array_map([__CLASS__, 'unescapeOptionValue'], $options); } return Config::fromArray($unescaped)->setConfigFile($file); } /** * Unescape significant characters in the given section name * * @param string $str * * @return string */ protected static function unescapeSectionName($str) { $str = str_replace('\"', '"', $str); $str = str_replace('\;', ';', $str); return str_replace('\\\\', '\\', $str); } /** * Unescape significant characters in the given option value * * @param string $str * * @return string */ protected static function unescapeOptionValue($str) { $str = str_replace('\n', "\n", $str); $str = str_replace('\r', "\r", $str); $str = str_replace('\"', '"', $str); $str = str_replace('\\\\', '\\', $str); // This replacement is a work-around for PHP bug #76965. Fixed with versions 7.1.24, 7.2.12 and 7.3.0. return preg_replace('~^([\'"])(.*?)\1\s+$~', '$2', $str); } }