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/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; } 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 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"