From 45e2d9a412ccda0819f078e96c1e6f6f1b975c71 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 14 Aug 2026 11:23:44 +0200 Subject: [PATCH 1/3] Write values and option names unambiguously Backslashes are escaped in addition to quotes, so a value ending in a backslash cannot leave the closing quote preceded by a lone backslash, and all quotes directly before a line break are removed so none of them can end up as the last character of a line. Option names are encoded as well, keeping the characters that are valid in a name such as "." or ":". An option name that would end up empty is rejected instead of producing a file that cannot be read. --- src/IniWriter.php | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/IniWriter.php b/src/IniWriter.php index 8c83461..24cd018 100644 --- a/src/IniWriter.php +++ b/src/IniWriter.php @@ -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"; } } @@ -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 . '"'; } @@ -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 From 24016ebaa02b2a18a58ef91151afa07075e0d6e3 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 14 Aug 2026 11:23:56 +0200 Subject: [PATCH 2/3] Read quoted values that span several lines The fallback implementation read the file strictly line by line, so the lines after the opening quote of a value were taken for sections or keys of their own instead of being part of the value, unlike the native parser. Quoted values are now read up to the matching closing quote, scanning each line only once. A quote at the very end of a line ends a value that would otherwise be unterminated, so values written by an earlier version of IniWriter are still read as before. The value is taken from the unmodified line so tabs and inner whitespace are kept, "\r\n" counts as one line break, and anything after the closing quote is ignored. Lines the native parser does not accept either are now rejected: an unterminated quoted value, a quote or an assignment after a section name, and a double quote that opens a string which is never closed. readComments() skips such values as well, so it no longer reports sections and keys that readString() does not return, and values are only decoded with raw values of the same structure. --- src/IniReader.php | 256 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 222 insertions(+), 34 deletions(-) diff --git a/src/IniReader.php b/src/IniReader.php index 56c3d7f..646e22a 100644 --- a/src/IniReader.php +++ b/src/IniReader.php @@ -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); @@ -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); } @@ -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; @@ -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 @@ -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') { @@ -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, "'\""); @@ -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. @@ -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; } From 4690f419213970a7c85b32d178339c50ccc3c27a Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 14 Aug 2026 11:23:56 +0200 Subject: [PATCH 3/3] Add tests for quoted values spanning several lines Runs against both the native and the fallback implementation, and pins the cases the fallback implementation cannot read the same way. --- .gitignore | 1 + tests/BaseIniReaderTest.php | 314 ++++++++++++++++++++++++ tests/IniReaderDisabledFunctionTest.php | 31 +++ tests/IniWriterTest.php | 259 +++++++++++++++++++ tests/resources/MultiLineComments.ini | 8 + 5 files changed, 613 insertions(+) create mode 100644 tests/resources/MultiLineComments.ini diff --git a/.gitignore b/.gitignore index 2529327..a9bf2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /vendor/ /coverage/ /clover.xml +.phpunit.result.cache diff --git a/tests/BaseIniReaderTest.php b/tests/BaseIniReaderTest.php index ac2d6b5..f0f6279 100644 --- a/tests/BaseIniReaderTest.php +++ b/tests/BaseIniReaderTest.php @@ -245,6 +245,320 @@ public function test_readString_shouldCastToIntOnlyIfNoDataIsLost() $this->assertSame($expected, $this->reader->readString($ini)); } + /** + * A double-quoted value that contains newlines is a single multi-line value: the lines + * after the opening quote are part of the value, not new sections or keys. The native + * parser and the fallback implementation must read it back the same way. + */ + public function test_readString_multiLineDoubleQuotedValue_isReadAsOneValue() + { + $ini = << array( + 'first' => "\n[section2]", + 'second' => "\nkey2=value2", + ), + ); + + $result = $this->reader->readString($ini); + + $this->assertSame($expected, $result); + // the lines inside the value stay inside the value; no extra section/key appears + $this->assertArrayNotHasKey('section2', $result); + $this->assertArrayNotHasKey('key2', $result); + } + + public function test_readString_multiLineQuotedValue_withEscapedQuote() + { + $ini = << array( + 'key' => "va\"lue\n[newsection]\nc=d\n", + ), + ); + + $this->assertSame($expected, $this->reader->readString($ini)); + } + + /** + * Single-quoted values can also span multiple lines; the fallback parser must read them + * as one value, consistent with the native parser. + */ + public function test_readString_singleQuotedMultiLineValue_isReadAsOneValue() + { + $ini = <<reader->readString($ini); + + $this->assertSame(array('data' => array('first' => "a\n[section2]\nkey2=value2")), $result); + $this->assertArrayNotHasKey('section2', $result); + } + + /** + * Files written by an older IniWriter, which escaped quotes but not backslashes, can + * contain a value ending in a backslash directly before its closing quote. Such a value + * must still end at that quote, so the following keys are not absorbed into it. + */ + public function test_readString_valueEndingInBackslash_endsAtClosingQuote() + { + $ini = <<<'INI' +[sec] +winpath = "C:\Users\foo\" +endbs = "abc\" +keep = "1" + +INI; + $expected = array( + 'sec' => array( + 'winpath' => 'C:\Users\foo\\', + 'endbs' => 'abc\\', + 'keep' => 1, + ), + ); + + $this->assertSame($expected, $this->reader->readString($ini)); + } + + /** + * A quote that is followed by more content on the same line is an escaped quote inside + * the value, so the value continues on the following lines. + */ + public function test_readString_escapedQuoteFollowedByContent_doesNotEndValue() + { + // the escaped quote is followed by a space, so it is part of the value + $ini = "[sec]\nk = \"abc\\\" \ndef\"\nnext = \"1\"\n"; + + $expected = array( + 'sec' => array( + 'k' => "abc\" \ndef", + 'next' => 1, + ), + ); + + $this->assertSame($expected, $this->reader->readString($ini)); + } + + /** + * A multi-line value whose last line ends in a backslash directly before the closing + * quote still ends there, so the following keys are read normally. + */ + public function test_readString_multiLineValueEndingInBackslash_endsAtClosingQuote() + { + $ini = <<<'INI' +[sec] +k = "line1 +line2\" +next = "1" + +INI; + $expected = array( + 'sec' => array( + 'k' => "line1\nline2\\", + 'next' => 1, + ), + ); + + $this->assertSame($expected, $this->reader->readString($ini)); + } + + /** + * Reading a large file that contains an unterminated quote must not take a + * disproportionate amount of time: the value is scanned once, not from the start again + * for every line that is appended to it. + */ + public function test_readString_unterminatedQuoteInLargeFile_isNotSlow() + { + $ini = "[s]\nk = \"abc\n" . str_repeat("filler = value\n", 20000); + + $start = microtime(true); + try { + $this->reader->readString($ini); + } catch (IniReadingException $e) { + // expected, the value is never closed + } + + $this->assertLessThan(2, microtime(true) - $start, 'parsing took disproportionately long'); + } + + /** + * Anything after the closing quote of a value (such as an inline comment) is not part of + * the value. + */ + public function test_readString_contentAfterClosingQuote_isNotPartOfValue() + { + $ini = << array( + 'k' => "abc\ndef", + 'z' => 1, + ), + ); + + $this->assertSame($expected, $this->reader->readString($ini)); + } + + /** + * A tab inside a quoted value is part of the value and must not be turned into a space. + */ + public function test_readString_tabInsideQuotedValue_isPreserved() + { + $ini = "[s]\nk = \"a\tb\nc\td\"\n"; + + $this->assertSame(array('s' => array('k' => "a\tb\nc\td")), $this->reader->readString($ini)); + } + + /** + * A "\r\n" inside a quoted value is one line break, not two. + */ + public function test_readString_crlfInsideQuotedValue_isKept() + { + $ini = "[s]\nk = \"a\r\nb\"\r\nz = 1\r\n"; + + $this->assertSame(array('s' => array('k' => "a\r\nb", 'z' => 1)), $this->reader->readString($ini)); + } + + /** + * The lines of a multi-line value are part of that value, so readComments() must not read + * them as a section or a key of their own. The keys it reports have to be the same ones + * readFile() returns. + */ + public function test_readComments_multiLineValue_matchesReadFile() + { + $file = __DIR__ . '/resources/MultiLineComments.ini'; + + $expected = array( + 'section' => array( + 'first' => "description for first\n", + 'second' => "\ndescription for second\n", + ), + ); + + $comments = $this->reader->readComments($file); + + $this->assertSame($expected, $comments); + $this->assertArrayNotHasKey('NotASection', $comments); + + // the comments describe exactly the settings the file contains + $config = $this->reader->readFile($file); + $this->assertSame(array_keys($config), array_keys($comments)); + $this->assertSame(array_keys($config['section']), array_keys($comments['section'])); + } + + /** + * Content after a section name makes the line invalid, consistent with the native parser. + */ + public function test_readString_contentAfterSectionName_throws() + { + $this->expectException(IniReadingException::class); + $this->reader->readString("[sec]\"\nk = 1\n"); + } + + public function test_readString_commentAfterSectionName_isAllowed() + { + $expected = array('sec' => array('k' => 1)); + + $this->assertSame($expected, $this->reader->readString("[sec] ; a comment\nk = 1\n")); + } + + /** + * Plain text after a section name is ignored rather than rejected, the way the native + * parser reads it. + * + * @dataProvider getSectionLinesWithTrailingText + */ + public function test_readString_textAfterSectionName_isIgnored($sectionLine) + { + $expected = array('sec' => array('k' => 1)); + + $this->assertSame($expected, $this->reader->readString($sectionLine . "\nk = 1\n")); + } + + public function getSectionLinesWithTrailingText() + { + return array( + 'directly after' => array('[sec]extra'), + 'separated' => array('[sec] extra'), + 'with apostrophe' => array("[sec]'"), + ); + } + + /** + * An unquoted value containing a double quote that is never closed makes the line + * invalid. + */ + public function test_readString_unbalancedQuoteInUnquotedValue_throws() + { + $this->expectException(IniReadingException::class); + $this->reader->readString("[sec]\nk = 1\"\n"); + } + + /** + * An escaped quote does not close a value, so a following line cannot become a section + * of its own. Either the lines stay part of the value or the file is rejected. + * + * @dataProvider getValuesWithQuotesThatDoNotClose + */ + public function test_readString_quoteThatDoesNotClose_addsNoSection($ini) + { + try { + $result = $this->reader->readString($ini); + } catch (IniReadingException $e) { + // the file is rejected, so nothing is read from it + $result = array(); + } + + $this->assertArrayNotHasKey('other', $result); + } + + public function getValuesWithQuotesThatDoNotClose() + { + return array( + 'escaped quote' => array("[s]\nk = a\"b\\\"c\"d\"\n[other]\nx = 1\n\"\nz = 2\n"), + 'trailing quote' => array("[s]\nk = 1\"\n[other]\nx = 1\n"), + ); + } + + + /** + * An unterminated quoted value must be rejected, consistent with the native parser. + */ + public function test_readString_unterminatedQuote_throws() + { + $this->expectException(IniReadingException::class); + $this->reader->readString("[s]\nk = \"abc\n"); + } + + public function test_readString_unterminatedSingleQuote_throws() + { + $this->expectException(IniReadingException::class); + $this->reader->readString("[s]\nk = 'abc\n"); + } + public function test_readFile_shouldThrow_withInvalidFile() { $this->expectExceptionMessage("The file /foobar doesn't exist or is not readable"); diff --git a/tests/IniReaderDisabledFunctionTest.php b/tests/IniReaderDisabledFunctionTest.php index 52cb3f7..2b65ff8 100644 --- a/tests/IniReaderDisabledFunctionTest.php +++ b/tests/IniReaderDisabledFunctionTest.php @@ -23,6 +23,37 @@ protected function setUp(): void $property->setValue($this->reader, false); } + /** + * Cases the fallback implementation cannot read the way the native parser does, because + * they depend on how the native parser itself is built. Pinned here so a change to them + * is noticed. + * + * @dataProvider getKnownDifferencesToNativeParser + */ + public function test_readString_knownDifferencesToNativeParser($ini, $expected) + { + $this->assertSame($expected, $this->reader->readString($ini)); + } + + public function getKnownDifferencesToNativeParser() + { + return array( + // a lone carriage return is read as a line break, the native parser keeps it + 'lone carriage return' => array("[s]\nk = \"a\rb\"\n", array('s' => array('k' => "a\nb"))), + // the native parser replaces "${...}" with an environment variable + 'variable syntax' => array("[s]\nk = \"a\${B}c\"\n", array('s' => array('k' => 'a${B}c'))), + // the native parser turns "k[sub]" into an array + 'associative subkey' => array("[s]\nk[sub] = \"v\"\n", array('s' => array('k[sub]' => 'v'))), + // the native parser returns an empty string + 'empty value' => array("[s]\nk =\n", array('s' => array('k' => null))), + ); + } + + public function test_readString_apostropheInUnquotedValue_isKept() + { + $this->assertSame(array('sec' => array('k' => "don't")), $this->reader->readString("[sec]\nk = don't\n")); + } + public function test_readComments() { $descriptions = $this->reader->readComments(__DIR__ . '/resources/test.ini.php'); diff --git a/tests/IniWriterTest.php b/tests/IniWriterTest.php index 5af6a7a..8d712a0 100644 --- a/tests/IniWriterTest.php +++ b/tests/IniWriterTest.php @@ -82,6 +82,265 @@ public function test_writeToString_doesNotAllowInjection() $this->assertEquals($configClean, $result); } + /** + * A value that contains newlines must round-trip to exactly the same value on both the + * native and the fallback reader: the embedded lines stay part of the value instead of + * becoming separate sections or keys. + */ + public function test_writeToString_newlineInValue_roundTrips() + { + $config = array( + 'data' => array( + 'first' => "\n[section2]", + 'second' => "\nkey2=value2", + ), + ); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + $result = $reader->readString($ini); + + $this->assertSame($config, $result, 'useNativeFunction=' . var_export($useNativeFunction, true)); + $this->assertArrayNotHasKey('section2', $result); + $this->assertArrayNotHasKey('key2', $result); + } + } + + /** + * @dataProvider getValuesWithQuotesBeforeLineBreak + */ + public function test_writeToString_quotesBeforeLineBreak_roundTripExactly($value) + { + $config = array('section' => array('key' => $value, 'other' => 'keep')); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + $result = $reader->readString($ini); + + $message = 'useNativeFunction=' . var_export($useNativeFunction, true); + $this->assertSame(array('section'), array_keys($result), $message); + $this->assertSame(array('key', 'other'), array_keys($result['section']), $message); + } + } + + public function getValuesWithQuotesBeforeLineBreak() + { + return array( + 'two quotes' => array("\"\"\nkey2=value2"), + 'two quotes, section' => array("\"\"\n[section2]"), + 'three quotes' => array("\"\"\"\nkey2=value2"), + 'text and quotes' => array("x\"\"\"\"\n[section2]"), + 'one quote' => array("a\"\nkey2=value2"), + ); + } + + /** + * A value ending in a backslash, followed by a multi-line value, must still round-trip + * exactly on both parsers: the trailing backslash must not run into the next value. + */ + public function test_writeToString_valueEndingInBackslash_roundTrips() + { + $config = array( + 'data' => array( + 'first' => 'abc\\', // value ending in a single backslash + 'second' => "\n[section2]\nkey2=value2", + ), + ); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + $result = $reader->readString($ini); + + $this->assertSame($config, $result, 'useNativeFunction=' . var_export($useNativeFunction, true)); + $this->assertArrayNotHasKey('section2', $result); + } + } + + /** + * Backslashes (e.g. Windows paths) must round-trip identically through both parsers. + */ + public function test_writeToString_roundTripsBackslashes() + { + $config = array( + 'paths' => array( + 'windows' => 'C:\\Users\\foo', + 'trailing' => 'ends\\', + 'double' => 'a\\\\b', + ), + ); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + + $this->assertSame($config, $reader->readString($ini), 'useNativeFunction=' . var_export($useNativeFunction, true)); + } + } + + /** + * Values with characters that are significant to INI syntax must survive a write/read + * round-trip unchanged and identically on both the native and the fallback parser, + * without producing spurious sections or keys. + * + * @dataProvider getTrickyRoundTripValues + */ + public function test_writeToString_roundTripsTrickyValues($value) + { + $config = array('section' => array('key' => $value)); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + $result = $reader->readString($ini); + + $this->assertSame($config, $result, 'useNativeFunction=' . var_export($useNativeFunction, true)); + } + } + + public function getTrickyRoundTripValues() + { + return array( + 'double quote in the middle' => array('p@ss"w0rd'), + 'semicolon in value' => array('a;b'), + 'lone double quote' => array('"'), + 'lone backslash' => array('\\'), + 'multiple quotes' => array('a"b"c'), + 'embedded newline' => array("line1\nline2"), + 'brackets look like section' => array('[notasection]'), + 'equals sign in value' => array('a=b'), + 'newline then key=value' => array("x\ny=z"), + 'quote and backslash' => array('a\\"b'), + 'tab in value' => array("a\tb"), + 'tab in multi-line value' => array("a\tb\nc\td"), + 'windows line break' => array("a\r\nb"), + 'empty' => array(''), + 'spaces only' => array(' '), + 'surrounding spaces' => array(' a '), + 'two quotes' => array('a""b'), + 'three quotes' => array('a"""b'), + 'only quotes' => array('""'), + 'two backslashes' => array('a\\\\b'), + 'three backslashes' => array('a\\\\\\'), + 'ends with two backslashes' => array('a\\\\'), + 'only backslashes' => array('\\\\'), + 'newline then quote' => array("a\n\"b"), + 'looks like a comment' => array('; not a comment'), + 'starts with a semicolon' => array(';x'), + 'looks like a section' => array('[General]'), + 'section on its own line' => array("a\n[General]\nb"), + 'several line breaks' => array("a\n\n\nb"), + 'equals sign and quotes' => array('a="b"'), + 'looks like a boolean' => array('true'), + 'looks like null' => array('null'), + 'percent signs' => array('%s%d'), + 'multibyte' => array('héllo→世界'), + 'long' => array(str_repeat('x', 5000)), + ); + } + + /** + * The value written for a quote directly before a line break loses that quote, but both + * implementations still read the same value. + */ + public function test_writeToString_quoteBeforeLineBreak_isLostButReadsTheSame() + { + $writer = new IniWriter(); + $ini = $writer->writeToString(array('s' => array('k' => "a\"\nb"))); + + $expected = array('s' => array('k' => "a\nb")); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + + $this->assertSame($expected, $reader->readString($ini), 'useNativeFunction=' . var_export($useNativeFunction, true)); + } + } + + /** + * Characters that are valid in an option name are kept when writing. + * + * @dataProvider getValidOptionNames + */ + public function test_writeToString_keepsValidOptionNames($option) + { + $config = array('s' => array($option => 'v')); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + + $this->assertSame($config, $reader->readString($ini), 'useNativeFunction=' . var_export($useNativeFunction, true)); + } + } + + public function getValidOptionNames() + { + return array( + 'dot' => array('db.host'), + 'colon' => array('my:key'), + 'space' => array('a b'), + ); + } + + public function test_writeToString_shouldThrowException_whenOptionNameCannotBeWritten() + { + $this->expectException(IniWritingException::class); + $this->expectExceptionMessage('cannot be written'); + + $writer = new IniWriter(); + $writer->writeToString(array('s' => array('[]' => 'v'))); + } + + /** + * Key names are encoded as well, so a key containing line breaks cannot turn into a + * section or an additional key when the file is read back. + */ + public function test_writeToString_encodesKeyNames() + { + $config = array( + 'sec' => array( + "a\n[section2]\nkey2" => 'v', + 'keep' => '1', + ), + ); + + $writer = new IniWriter(); + $ini = $writer->writeToString($config); + + $expected = array('sec' => array('asection2key2' => 'v', 'keep' => 1)); + + foreach (array(true, false) as $useNativeFunction) { + $reader = new IniReader(); + $reader->setUseNativeFunction($useNativeFunction); + $result = $reader->readString($ini); + + $this->assertSame($expected, $result, 'useNativeFunction=' . var_export($useNativeFunction, true)); + $this->assertArrayNotHasKey('section2', $result); + } + } + public function test_writeToString_withEmptyConfig() { $writer = new IniWriter(); diff --git a/tests/resources/MultiLineComments.ini b/tests/resources/MultiLineComments.ini new file mode 100644 index 0000000..4c81dd4 --- /dev/null +++ b/tests/resources/MultiLineComments.ini @@ -0,0 +1,8 @@ +[section] +; description for first +first = " +[NotASection] +notakey = 1" + +; description for second +second = "value"