diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e1d7b04..e439c87 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,10 +1,11 @@ - - + + lib + src - + test diff --git a/src/Bin2hex.php b/src/Bin2hex.php new file mode 100644 index 0000000..a360ae3 --- /dev/null +++ b/src/Bin2hex.php @@ -0,0 +1,40 @@ + + */ + +namespace Horde\Stream\Filter; + +use php_user_filter; + +class Bin2hex extends php_user_filter +{ + public const FILTER_NAME = 'horde.stream.filter.bin2hex'; + + public static function register(): void + { + if (in_array(self::FILTER_NAME, stream_get_filters())) { + return; + } + stream_filter_register(self::FILTER_NAME, static::class); + } + + public function filter($in, $out, &$consumed, $closing): int + { + while ($bucket = stream_bucket_make_writeable($in)) { + $bucket->data = bin2hex($bucket->data); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } +} diff --git a/src/Crc32.php b/src/Crc32.php new file mode 100644 index 0000000..8d2e42e --- /dev/null +++ b/src/Crc32.php @@ -0,0 +1,114 @@ + + */ + +namespace Horde\Stream\Filter; + +use php_user_filter; + +class Crc32 extends php_user_filter +{ + public const FILTER_NAME = 'horde.stream.filter.crc32'; + + public static function register(): void + { + if (in_array(self::FILTER_NAME, stream_get_filters())) { + return; + } + stream_filter_register(self::FILTER_NAME, static::class); + } + + public function onCreate(): bool + { + $this->params->crc32 = 0; + + return true; + } + + public function filter($in, $out, &$consumed, $closing): int + { + while ($bucket = stream_bucket_make_writeable($in)) { + $consumed += $bucket->datalen; + $this->params->crc32 = $this->crc32Combine( + $this->params->crc32, + crc32($bucket->data), + $bucket->datalen, + ); + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } + + private function crc32Combine(int $crc1, int $crc2, int $len2): int + { + $odd = [0xedb88320]; + $row = 1; + + for ($n = 1; $n < 32; ++$n) { + $odd[$n] = $row; + $row <<= 1; + } + + $this->gf2MatrixSquare($even, $odd); + $this->gf2MatrixSquare($odd, $even); + + do { + $this->gf2MatrixSquare($even, $odd); + + if ($len2 & 1) { + $crc1 = $this->gf2MatrixTimes($even, $crc1); + } + + $len2 >>= 1; + + if ($len2 === 0) { + break; + } + + $this->gf2MatrixSquare($odd, $even); + if ($len2 & 1) { + $crc1 = $this->gf2MatrixTimes($odd, $crc1); + } + + $len2 >>= 1; + } while ($len2 !== 0); + + $crc1 ^= $crc2; + + return $crc1; + } + + private function gf2MatrixSquare(?array &$square, array &$mat): void + { + $square = []; + for ($n = 0; $n < 32; ++$n) { + $square[$n] = $this->gf2MatrixTimes($mat, $mat[$n]); + } + } + + private function gf2MatrixTimes(array $mat, int $vec): int + { + $i = $sum = 0; + + while ($vec) { + if ($vec & 1) { + $sum ^= $mat[$i]; + } + + $vec = ($vec >> 1) & 0x7FFFFFFF; + ++$i; + } + + return $sum; + } +} diff --git a/src/Eol.php b/src/Eol.php new file mode 100644 index 0000000..7106fed --- /dev/null +++ b/src/Eol.php @@ -0,0 +1,78 @@ + + * @author Angelo Milazzo + */ + +namespace Horde\Stream\Filter; + +use php_user_filter; + +class Eol extends php_user_filter +{ + public const FILTER_NAME = 'horde.stream.filter.eol'; + + /** @var string[] */ + private array $search = []; + + private string|array $replace = ''; + + private string $prependNext = ''; + + public static function register(): void + { + if (in_array(self::FILTER_NAME, stream_get_filters())) { + return; + } + stream_filter_register(self::FILTER_NAME, static::class); + } + + public function onCreate(): bool + { + $eol = $this->params['eol'] ?? "\r\n"; + + if (!strlen($eol)) { + $this->search = ["\r", "\n"]; + $this->replace = ''; + } elseif (in_array($eol, ["\r", "\n"])) { + $this->search = ["\r\n", ($eol === "\r") ? "\n" : "\r"]; + $this->replace = $eol; + } else { + $this->search = ["\r\n", "\r", "\n"]; + $this->replace = ["\n", "\n", $eol]; + } + + return true; + } + + public function filter($in, $out, &$consumed, $closing): int + { + while ($bucket = stream_bucket_make_writeable($in)) { + $bucket->data = $this->prependNext . $bucket->data; + $this->prependNext = ''; + + if ( + !$closing + && in_array("\r\n", $this->search) + && ($bucket->data[$bucket->datalen - 1] === "\r") + ) { + $bucket->data = substr($bucket->data, 0, -1); + $this->prependNext = "\r"; + } + + $bucket->data = str_replace($this->search, $this->replace, $bucket->data); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } +} diff --git a/src/Htmlspecialchars.php b/src/Htmlspecialchars.php new file mode 100644 index 0000000..c69cb4d --- /dev/null +++ b/src/Htmlspecialchars.php @@ -0,0 +1,40 @@ + + */ + +namespace Horde\Stream\Filter; + +use php_user_filter; + +class Htmlspecialchars extends php_user_filter +{ + public const FILTER_NAME = 'horde.stream.filter.htmlspecialchars'; + + public static function register(): void + { + if (in_array(self::FILTER_NAME, stream_get_filters())) { + return; + } + stream_filter_register(self::FILTER_NAME, static::class); + } + + public function filter($in, $out, &$consumed, $closing): int + { + while ($bucket = stream_bucket_make_writeable($in)) { + $bucket->data = htmlspecialchars($bucket->data); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } +} diff --git a/src/NullFilter.php b/src/NullFilter.php new file mode 100644 index 0000000..b8a52be --- /dev/null +++ b/src/NullFilter.php @@ -0,0 +1,51 @@ + + */ + +namespace Horde\Stream\Filter; + +use php_user_filter; + +class NullFilter extends php_user_filter +{ + public const FILTER_NAME = 'horde.stream.filter.null'; + + private string $search = "\0"; + + private string $replace = ''; + + public static function register(): void + { + if (in_array(self::FILTER_NAME, stream_get_filters())) { + return; + } + stream_filter_register(self::FILTER_NAME, static::class); + } + + public function onCreate(): bool + { + $this->replace = $this->params->replace ?? ''; + + return true; + } + + public function filter($in, $out, &$consumed, $closing): int + { + while ($bucket = stream_bucket_make_writeable($in)) { + $bucket->data = str_replace($this->search, $this->replace, $bucket->data); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } +} diff --git a/test/Horde/Stream/Filter/AllTests.php b/test/Horde/Stream/Filter/AllTests.php deleted file mode 100644 index 6966fde..0000000 --- a/test/Horde/Stream/Filter/AllTests.php +++ /dev/null @@ -1,6 +0,0 @@ -run(); diff --git a/test/Horde/Stream/Filter/Bin2hexTest.php b/test/Horde/Stream/Filter/Bin2hexTest.php index 75f205a..3629741 100644 --- a/test/Horde/Stream/Filter/Bin2hexTest.php +++ b/test/Horde/Stream/Filter/Bin2hexTest.php @@ -1,46 +1,51 @@ testdata = str_repeat("0123456789ABCDE", 1000); - $this->fp = fopen('php://temp', 'r+'); fwrite($this->fp, $this->testdata); } - public function testCrc32() + public function tearDown(): void + { + fclose($this->fp); + } + + public function testLegacyBin2hex(): void { + @stream_filter_register('horde_bin2hex', Horde_Stream_Filter_Bin2hex::class); $filter = stream_filter_prepend($this->fp, 'horde_bin2hex', STREAM_FILTER_READ); + rewind($this->fp); + + $this->assertEquals(bin2hex($this->testdata), stream_get_contents($this->fp)); + + stream_filter_remove($filter); + } + public function testModernBin2hex(): void + { + Bin2hex::register(); + $filter = stream_filter_prepend($this->fp, Bin2hex::FILTER_NAME, STREAM_FILTER_READ); rewind($this->fp); - $this->assertEquals( - bin2hex($this->testdata), - stream_get_contents($this->fp) - ); + $this->assertEquals(bin2hex($this->testdata), stream_get_contents($this->fp)); stream_filter_remove($filter); } diff --git a/test/Horde/Stream/Filter/Crc32Test.php b/test/Horde/Stream/Filter/Crc32Test.php index 5bcbb4c..1cb36b0 100644 --- a/test/Horde/Stream/Filter/Crc32Test.php +++ b/test/Horde/Stream/Filter/Crc32Test.php @@ -1,39 +1,37 @@ testdata = str_repeat("0123456789ABCDE", 1000); - $this->fp = fopen('php://temp', 'r+'); fwrite($this->fp, $this->testdata); } - public function testCrc32() + public function tearDown(): void + { + fclose($this->fp); + } + + public function testLegacyCrc32(): void { + @stream_filter_register('horde_crc32', Horde_Stream_Filter_Crc32::class); $params = new stdClass(); $filter = stream_filter_prepend($this->fp, 'horde_crc32', STREAM_FILTER_READ, $params); @@ -41,12 +39,24 @@ public function testCrc32() while (fread($this->fp, 1024)) { } - $this->assertObjectHasAttribute('crc32', $params); + $this->assertTrue(property_exists($params, 'crc32')); + $this->assertEquals(crc32($this->testdata), $params->crc32); + + stream_filter_remove($filter); + } + + public function testModernCrc32(): void + { + Crc32::register(); + $params = new stdClass(); + $filter = stream_filter_prepend($this->fp, Crc32::FILTER_NAME, STREAM_FILTER_READ, $params); + + rewind($this->fp); + while (fread($this->fp, 1024)) { + } - $this->assertEquals( - crc32($this->testdata), - $params->crc32 - ); + $this->assertTrue(property_exists($params, 'crc32')); + $this->assertEquals(crc32($this->testdata), $params->crc32); stream_filter_remove($filter); } diff --git a/test/Horde/Stream/Filter/EolTest.php b/test/Horde/Stream/Filter/EolTest.php index b2d4f4c..e6bdd7e 100644 --- a/test/Horde/Stream/Filter/EolTest.php +++ b/test/Horde/Stream/Filter/EolTest.php @@ -1,28 +1,24 @@ fp = fopen('php://temp', 'r+'); fwrite($this->fp, "A\r\nB\rC\nD\r\n\r\nE\r\rF\n\nG\r\n\n\r\nH\r\n\r\r\nI"); } @@ -32,34 +28,40 @@ public function tearDown(): void fclose($this->fp); } - public static function lineEndingProvider() + public static function lineEndingProvider(): array { return [ - ["\r", "A\rB\rC\rD\r\rE\r\rF\r\rG\r\r\rH\r\r\rI"], - ["\n", "A\nB\nC\nD\n\nE\n\nF\n\nG\n\n\nH\n\n\nI"], - ["\r\n", "A\r\nB\r\nC\r\nD\r\n\r\nE\r\n\r\nF\r\n\r\nG\r\n\r\n\r\nH\r\n\r\n\r\nI"], - ["", "ABCDEFGHI"], + 'CR' => ["\r", "A\rB\rC\rD\r\rE\r\rF\r\rG\r\r\rH\r\r\rI"], + 'LF' => ["\n", "A\nB\nC\nD\n\nE\n\nF\n\nG\n\n\nH\n\n\nI"], + 'CRLF' => ["\r\n", "A\r\nB\r\nC\r\nD\r\n\r\nE\r\n\r\nF\r\n\r\nG\r\n\r\n\r\nH\r\n\r\n\r\nI"], + 'strip' => ["", "ABCDEFGHI"], ]; } - /** - * @dataProvider lineEndingProvider - */ - public function testFilterLineEndings($eol, $expected) + #[DataProvider('lineEndingProvider')] + public function testLegacyFilterLineEndings(string $eol, string $expected): void { $filter = stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => $eol]); rewind($this->fp); $this->assertEquals($expected, stream_get_contents($this->fp)); } - public function testBug12673() + #[DataProvider('lineEndingProvider')] + public function testModernFilterLineEndings(string $eol, string $expected): void + { + $filter = stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => $eol]); + rewind($this->fp); + $this->assertEquals($expected, stream_get_contents($this->fp)); + } + + public function testBug12673(): void { $test = str_repeat(str_repeat("A", 1) . "\r\n", 4000); rewind($this->fp); fwrite($this->fp, $test); - $filter = stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\r\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\r\n"]); rewind($this->fp); $this->assertEquals($test, stream_get_contents($this->fp)); @@ -70,7 +72,7 @@ public function testBug12673() ftruncate($this->fp, 0); fwrite($this->fp, $test); - stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\r\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\r\n"]); rewind($this->fp); $this->assertEquals( @@ -80,34 +82,26 @@ public function testBug12673() . fread($this->fp, 1) . fread($this->fp, 14) . fread($this->fp, 2) - . fread($this->fp, 100) + . fread($this->fp, 100), ); } - public function testUnixStyleNewLineSubstitution() + public function testUnixStyleNewLineSubstitution(): void { $test = str_repeat("A\r\n", 4000); - $expectedResult = str_repeat("A\n", 4000); + $expected = str_repeat("A\n", 4000); rewind($this->fp); fwrite($this->fp, $test); - $filter = stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\n"]); rewind($this->fp); - $this->assertEquals($expectedResult, stream_get_contents($this->fp)); + $this->assertEquals($expected, stream_get_contents($this->fp)); } - /** - * Test CRLF split at bucket boundary (byte 8191). - * - * This is the specific case that PR #2 fixes. The \r from \r\n falls - * at the end of the first bucket (8192 bytes), causing incorrect - * double newline conversion in buggy implementation. - */ - public function testCrlfBucketBoundarySplit() + public function testCrlfBucketBoundarySplit(): void { - // 2730 * 3 bytes = 8190 bytes, then X\r\n crosses boundary $test = str_repeat("A\r\n", 2730) . "X\r\n" . "END"; $expected = str_repeat("A\n", 2730) . "X\n" . "END"; @@ -115,16 +109,13 @@ public function testCrlfBucketBoundarySplit() ftruncate($this->fp, 0); fwrite($this->fp, $test); - stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\n"]); rewind($this->fp); $this->assertEquals($expected, stream_get_contents($this->fp)); } - /** - * Test trailing bare \r at end of stream. - */ - public function testTrailingCarriageReturn() + public function testTrailingCarriageReturn(): void { $test = "Line1\r\nLine2\r"; $expected = "Line1\nLine2\n"; @@ -133,18 +124,13 @@ public function testTrailingCarriageReturn() ftruncate($this->fp, 0); fwrite($this->fp, $test); - stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\n"]); rewind($this->fp); $this->assertEquals($expected, stream_get_contents($this->fp)); } - /** - * Test conversion to CRLF (multi-character target EOL). - * - * Ensures original Bug #12673 fix still works. - */ - public function testConversionToMultiCharEol() + public function testConversionToMultiCharEol(): void { $test = str_repeat("A\n", 4000); $expected = str_repeat("A\r\n", 4000); @@ -153,16 +139,13 @@ public function testConversionToMultiCharEol() ftruncate($this->fp, 0); fwrite($this->fp, $test); - stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\r\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\r\n"]); rewind($this->fp); $this->assertEquals($expected, stream_get_contents($this->fp)); } - /** - * Test double CRLF sequences. - */ - public function testDoubleCrlf() + public function testDoubleCrlf(): void { $test = "A\r\n\r\nB"; $expected = "A\n\nB"; @@ -171,16 +154,13 @@ public function testDoubleCrlf() ftruncate($this->fp, 0); fwrite($this->fp, $test); - stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\n"]); rewind($this->fp); $this->assertEquals($expected, stream_get_contents($this->fp)); } - /** - * Test CR-only input conversion. - */ - public function testCarriageReturnOnly() + public function testCarriageReturnOnly(): void { $test = "A\rB\rC"; $expected = "A\nB\nC"; @@ -189,10 +169,9 @@ public function testCarriageReturnOnly() ftruncate($this->fp, 0); fwrite($this->fp, $test); - stream_filter_prepend($this->fp, 'horde_eol', STREAM_FILTER_READ, ['eol' => "\n"]); + stream_filter_prepend($this->fp, Eol::FILTER_NAME, STREAM_FILTER_READ, ['eol' => "\n"]); rewind($this->fp); $this->assertEquals($expected, stream_get_contents($this->fp)); } - } diff --git a/test/Horde/Stream/Filter/NullFilterTest.php b/test/Horde/Stream/Filter/NullFilterTest.php new file mode 100644 index 0000000..b91e519 --- /dev/null +++ b/test/Horde/Stream/Filter/NullFilterTest.php @@ -0,0 +1,52 @@ +testdata = "abcde\0fghij"; + $this->fp = fopen('php://temp', 'r+'); + fwrite($this->fp, $this->testdata); + } + + public function tearDown(): void + { + fclose($this->fp); + } + + public function testLegacyNull(): void + { + @stream_filter_register('horde_null', Horde_Stream_Filter_Null::class); + $filter = stream_filter_prepend($this->fp, 'horde_null', STREAM_FILTER_READ); + rewind($this->fp); + + $this->assertEquals('abcdefghij', stream_get_contents($this->fp)); + + stream_filter_remove($filter); + } + + public function testModernNullFilter(): void + { + NullFilter::register(); + $filter = stream_filter_prepend($this->fp, NullFilter::FILTER_NAME, STREAM_FILTER_READ); + rewind($this->fp); + + $this->assertEquals('abcdefghij', stream_get_contents($this->fp)); + + stream_filter_remove($filter); + } +} diff --git a/test/Horde/Stream/Filter/NullTest.php b/test/Horde/Stream/Filter/NullTest.php deleted file mode 100644 index 2d74c2a..0000000 --- a/test/Horde/Stream/Filter/NullTest.php +++ /dev/null @@ -1,43 +0,0 @@ -testdata = "abcde\0fghij"; - $this->fp = fopen('php://temp', 'r+'); - fwrite($this->fp, $this->testdata); - } - - public function testNull() - { - $filter = stream_filter_prepend($this->fp, 'horde_null', STREAM_FILTER_READ); - rewind($this->fp); - $this->assertEquals( - 'abcdefghij', - stream_get_contents($this->fp) - ); - stream_filter_remove($filter); - } -} diff --git a/test/Horde/Stream/Filter/bootstrap.php b/test/Horde/Stream/Filter/bootstrap.php deleted file mode 100644 index 95ebeca..0000000 --- a/test/Horde/Stream/Filter/bootstrap.php +++ /dev/null @@ -1,10 +0,0 @@ -