Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
/vendor/
/coverage/
/clover.xml
.phpunit.result.cache
256 changes: 222 additions & 34 deletions src/IniReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,9 @@ public function readComments($filename)
$section = '';
$lastComment = '';

foreach ($ini as $line) {
$line = trim($line);
$lineCount = count($ini);
for ($lineNumber = 0; $lineNumber < $lineCount; $lineNumber++) {
$line = trim($ini[$lineNumber]);

if (strpos($line, '[') === 0) {
$tmp = explode(']', $line);
Expand All @@ -183,9 +184,18 @@ public function readComments($filename)
continue;
}

[$key, $value] = explode('=', $line, 2);
$parts = explode('=', $line, 2);

$key = trim($key);
// Skip the remaining lines of a value that spans several lines, so they are not
// taken for sections or keys of their own.
$rawValue = ltrim(explode('=', $ini[$lineNumber], 2)[1] ?? '');
$quote = isset($rawValue[0]) && ($rawValue[0] === '"' || $rawValue[0] === "'") ? $rawValue[0] : null;

if ($quote !== null) {
[, $lineNumber] = $this->readQuotedValue($ini, $lineNumber, $lineCount, $rawValue, $quote);
}

$key = trim($parts[0]);
if (strpos($key, '[]') === strlen($key) - 2) {
$key = substr($key, 0, -2);
}
Expand All @@ -203,7 +213,9 @@ public function readComments($filename)
private function splitIniContentIntoLines($ini)
{
if (is_string($ini)) {
$ini = explode("\n", str_replace("\r", "\n", $ini));
// Only a carriage return that is not part of a "\r\n" is a line break of its own,
// so that a "\r\n" stays intact inside a value.
$ini = explode("\n", preg_replace('/\r(?!\n)/', "\n", $ini));
}

return $ini;
Expand Down Expand Up @@ -231,8 +243,9 @@ private function readWithAlternativeImplementation($ini)
$result = array();
$globals = array();
$i = 0;
foreach ($ini as $line) {
$line = trim($line);
$lineCount = count($ini);
for ($lineNumber = 0; $lineNumber < $lineCount; $lineNumber++) {
$line = trim($ini[$lineNumber]);
$line = str_replace("\t", " ", $line);

// Comments
Expand All @@ -243,36 +256,54 @@ private function readWithAlternativeImplementation($ini)
// Sections
if ($line[0] == '[') {
$tmp = explode(']', $line);

// Text and comments after the section name are ignored, but a quote or an
// assignment there means the line is not a section, as for the native parser.
$rest = substr($line, strlen($tmp[0]) + 1);
$rest = explode(';', $rest, 2)[0];
if (strpos($rest, '"') !== false || strpos($rest, '=') !== false) {
throw new IniReadingException('Syntax error in INI configuration: unexpected content after a section name');
}

$sections[] = trim(substr($tmp[0], 1));
$i++;
continue;
}

// Key-value pair
[$key, $value] = explode('=', $line, 2);
[$key] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if (strstr($value, ";")) {
$tmp = explode(';', $value);
if (count($tmp) == 2) {
if ((($value[0] != '"') && ($value[0] != "'")) ||
preg_match('/^".*"\s*;/', $value) || preg_match('/^".*;[^"]*$/', $value) ||
preg_match("/^'.*'\s*;/", $value) || preg_match("/^'.*;[^']*$/", $value)
) {
$value = $tmp[0];
}
} else {
if ($value[0] == '"') {
$value = preg_replace('/^"(.*)".*/', '$1', $value);
} elseif ($value[0] == "'") {
$value = preg_replace("/^'(.*)'.*/", '$1', $value);
} else {
$value = $tmp[0];
}

// Taken from the unmodified line, so tabs and inner whitespace are preserved.
$rawParts = explode('=', $ini[$lineNumber], 2);
$value = isset($rawParts[1]) ? ltrim($rawParts[1]) : '';

$quote = isset($value[0]) && ($value[0] === '"' || $value[0] === "'") ? $value[0] : null;

if ($quote !== null) {
// A quoted value may span several lines, which the native parser reads as
// one value as well. Anything after the closing quote is dropped.
[$value, $lineNumber] = $this->readQuotedValue($ini, $lineNumber, $lineCount, $value, $quote);

if ($value === null) {
throw new IniReadingException('Syntax error in INI configuration: unterminated quoted value');
}
} else {
// An unquoted value ends at an inline comment.
$value = trim(str_replace("\t", " ", $value));
if (strstr($value, ";")) {
$tmp = explode(';', $value);
$value = $tmp[0];
}
}

$value = trim($value);
$value = trim($value);

// Apostrophes are not treated as quotes, so a value such as "don't" is still
// read the way it was before.
if ($this->hasUnterminatedDoubleQuote($value)) {
throw new IniReadingException('Syntax error in INI configuration: unterminated quote in a value');
}
}

// Special keywords
if ($value === 'true' || $value === 'yes' || $value === 'on') {
Expand All @@ -284,11 +315,11 @@ private function readWithAlternativeImplementation($ini)
}

if (is_string($value)) {
if (preg_match('/^"(.*)"$/', $value)) {
$value = preg_replace('/^"(.*)"$/', '$1', $value);
$value = str_replace('\"', '"', $value);
} elseif (preg_match("/^'(.*)'$/", $value)) {
$value = preg_replace("/^'(.*)'$/", '$1', $value);
if (preg_match('/^"(.*)"$/s', $value)) {
$value = preg_replace('/^"(.*)"$/s', '$1', $value);
$value = $this->unescapeDoubleQuoted($value);
} elseif (preg_match("/^'(.*)'$/s", $value)) {
$value = preg_replace("/^'(.*)'$/s", '$1', $value);
$value = str_replace("\'", "'", $value);
} else {
$value = trim($value, "'\"");
Expand Down Expand Up @@ -323,6 +354,157 @@ private function readWithAlternativeImplementation($ini)
return $this->decode($finalResult, $finalResult);
}

/**
* Returns the position of the closing quote of a quoted value, or false if the value is
* not closed yet.
*
* For double quotes a backslash escapes the following character (matching IniWriter,
* which escapes "\\" and "\""); single-quoted INI values do not support escaping, so the
* next single quote closes them.
*
* $offset is where the scan starts and is updated to where it stopped, so that a value
* built up over several lines is scanned only once instead of from the start every time.
*
* @param string $value A string whose first character is the opening quote.
* @param string $quote The quote character, either '"' or "'".
* @param int $offset Position to start scanning at, updated in place.
* @return int|false
*/
private function findClosingQuote($value, $quote, &$offset)
{
$length = strlen($value);
for ($i = $offset; $i < $length; $i++) {
if ($quote === '"' && $value[$i] === '\\') {
$i++; // skip the escaped character
continue;
}
if ($value[$i] === $quote) {
$offset = $i;
return $i;
}
}

// $i is past the end when the last character was an escape, so that the character
// appended next is not scanned again.
$offset = $i;

return false;
}

/**
* Reads a quoted value, which may span several lines, and returns it together with the
* number of the line it ends on. The value is null when it is never closed.
*
* @param array $ini All lines of the file.
* @param int $lineNumber Number of the line the value starts on.
* @param int $lineCount Total number of lines.
* @param string $value The value as it starts on that line, including the quote.
* @param string $quote The quote character, either '"' or "'".
* @return array
*/
private function readQuotedValue(array $ini, $lineNumber, $lineCount, $value, $quote)
{
$offset = 1;
$position = $this->findClosingQuote($value, $quote, $offset);

if ($position === false) {
$position = $this->findClosingQuoteAtLineEnd($value, $quote);
}

while ($position === false && ++$lineNumber < $lineCount) {
$value .= "\n" . $ini[$lineNumber];
$position = $this->findClosingQuote($value, $quote, $offset);

if ($position === false) {
$position = $this->findClosingQuoteAtLineEnd($value, $quote);
}
}

return array(
$position === false ? null : substr($value, 0, $position + 1),
min($lineNumber, $lineCount - 1),
);
}

/**
* Whether a value contains a double quote that opens a string which is never closed.
*
* @param string $value
* @return bool
*/
private function hasUnterminatedDoubleQuote($value)
{
$length = strlen($value);

for ($i = 0; $i < $length; $i++) {
if ($value[$i] !== '"') {
continue;
}

$offset = 1;
$position = $this->findClosingQuote(substr($value, $i), '"', $offset);

if ($position === false) {
return true;
}

$i += $position;
}

return false;
}

/**
* Returns the position of a quote that ends the last line of a value, or false.
*
* A value written by an older IniWriter, which escaped quotes but not backslashes, can
* end in a backslash directly before its closing quote (e.g. `key = "C:\dir\"`). The
* native parser ends the value at that quote, so a quote that is the very last character
* of a line is treated as the closing quote here as well. Anything following the quote
* on the same line means it is an escaped quote inside the value instead, which is why
* only a quote at the exact end of the line qualifies.
*
* @param string $value
* @param string $quote The quote character, either '"' or "'".
* @return int|false
*/
private function findClosingQuoteAtLineEnd($value, $quote)
{
$value = rtrim($value, "\r");

if (strlen($value) > 1 && substr($value, -1) === $quote) {
return strlen($value) - 1;
}

return false;
}

/**
* Reverses the escaping applied by IniWriter to double-quoted values: "\\" becomes a
* single backslash and "\"" becomes a double quote. Processed left to right so the two
* escapes cannot interfere with each other. Any other backslash sequence is left as-is.
*
* @param string $value
* @return string
*/
private function unescapeDoubleQuoted($value)
{
$result = '';
$length = strlen($value);
for ($i = 0; $i < $length; $i++) {
if ($value[$i] === '\\' && $i + 1 < $length
&& ($value[$i + 1] === '\\' || $value[$i + 1] === '"')
) {
$result .= $value[$i + 1];
$i++;
continue;
}
$result .= $value[$i];
}

return $result;
}

/**
* @param string $filename
* @return bool|string Returns false if failure.
Expand Down Expand Up @@ -361,7 +543,13 @@ private function decode($value, $rawValue)
{
if (is_array($value)) {
foreach ($value as $i => &$subValue) {
$subValue = $this->decode($subValue, $rawValue[$i]);
// Both scanners can disagree about the structure of a file, so only decode
// the values they both returned.
$subRawValue = is_array($rawValue) && array_key_exists($i, $rawValue)
? $rawValue[$i]
: $subValue;

$subValue = $this->decode($subValue, $subRawValue);
}
return $value;
}
Expand Down
32 changes: 28 additions & 4 deletions src/IniWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ public function writeToString(array $config, $header = '')
}
}
} else {
$ini .= $option . ' = ' . $this->encodeValue($value) . "\n";
$encodedOption = $this->encodeOptionName($option);

if ($encodedOption === '') {
throw new IniWritingException(sprintf('Option name "%s" cannot be written', $option));
}

$ini .= $encodedOption . ' = ' . $this->encodeValue($value) . "\n";
}
}

Expand All @@ -123,9 +129,13 @@ private function encodeValue($value)
}

if (is_string($value)) {
// remove any quotes w/ newlines after it since INI parsing will consider it the end of the string
$value = preg_replace('/\"[\n\r]/', "\n", $value);
$value = addcslashes($value, '"');
// The native parse_ini_string() cannot read an escaped quote before a line
// break, and a single remaining one would end up as the last character of a
// line, where it is no longer distinguishable from the closing quote. Keep this.
$value = preg_replace('/"+([\n\r])/', '$1', $value);
// Backslashes are escaped as well, so a value ending in one cannot leave the
// closing quote preceded by a lone backslash. IniReader reverses this.
$value = addcslashes($value, '\\"');
return '"' . $value . '"';
}

Expand All @@ -143,6 +153,20 @@ private function encodeKey($key)
return $key;
}

/**
* Removes the characters that would change the structure of the file when they appear in
* an option name. Everything else is kept, so names such as "db.host" stay unchanged.
*
* @param $key
* @return string
*/
private function encodeOptionName($key)
{
$key = preg_replace('/[\r\n\t\[\]=;#"\']/', '', $key);

return trim($key);
}

/**
* @param $key
* @return string
Expand Down
Loading
Loading