Url: optimized unescape() performance

Currently unescape() can be used to perform DOS on Nette application if HTTP server does not restrict URL length.

Benchmark

~~~php
$datasets = [
	'a' => ['%66%6F%6F', '', 'foo'],
	'b' => ['%66%6F%6F', 'o', 'f%6F%6F'],
	'c' => ['%66%6F%6F', 'f', '%66oo'],
	'd' => ['%66%6F%6F', 'fo', '%66%6F%6F'],
	'e' => ['%66%6f%6f', 'fo', '%66%6f%6f'],
	'no-escapes (a)' => [str_repeat('a', 1e6), '', str_repeat('a', 1e6)],
	'no-escapes (z)' => [str_repeat('z', 1e6), '', str_repeat('z', 1e6)],
	'escapes' => [str_repeat('%40', 1e5), '', str_repeat('@', 1e5)],
	'escapes2' => [str_repeat('%40', 1e5 / 2) . '%20' . str_repeat('%40', 1e5 / 2), ' ', str_repeat('@', 1e5 / 2) . '%20' . str_repeat('@', 1e5 / 2)],
	'escapes3' => [str_repeat('%40', 1e5), '@', str_repeat('%40', 1e5)],
];

$tests = [
	'old' => function ($testCount, $s, $reserved, $expected) {
		for ($i = 0; $i < $testCount; $i++) {
			preg_match_all('#(?<=%)[a-f0-9][a-f0-9]#i', $s, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
			foreach (array_reverse($matches) as $match) {
				$ch = chr(hexdec($match[0][0]));
				if (strpos($reserved, $ch) === FALSE) {
					$s = substr_replace($s, $ch, $match[0][1] - 1, 3);
				}
			}
		}
		Assert::same($expected, $s);
	},
	'new' => function ($testCount, $s, $reserved, $expected) {
		for ($i = 0; $i < $testCount; $i++) {
			preg_match_all('#%[a-f0-9][a-f0-9]#i', $s, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
			foreach (array_reverse($matches) as $match) {
				$ch = chr(hexdec(substr($match[0][0], 1)));
				if (strpos($reserved, $ch) === FALSE) {
					$s = substr_replace($s, $ch, $match[0][1], 3);
				}
			}
		}
		Assert::same($expected, $s);
	},
	'split' => function ($testCount, $s, $reserved, $expected) {
		for ($i = 0; $i < $testCount; $i++) {
			if ($reserved === '') {
				$s = rawurldecode($s);

			} else {
				$delimiters = array();
				for ($i = 0; $i < strlen($reserved); $i++) {
					$delimiters[] = strtoupper(dechex(ord($reserved[$i])));
				}
				$parts = preg_split('#(' . implode('|', $delimiters) . ')#i', $s, -1, PREG_SPLIT_DELIM_CAPTURE);
				for ($i = 0; $i < count($parts); $i += 2) {
					$parts[$i] = rawurldecode($parts[$i]);
				}
				$s = implode('', $parts);
			}
		}
		Assert::same($expected, $s);
	},
];

$testCount = 1;
$padLength = max(array_map('strlen', array_keys($datasets))) + 3;
foreach ($tests as $testName => $testCallback) {
	printf("Test %s:\n", $testName);
	foreach ($datasets as $datasetName => $dataset) {
		$time = -microtime(TRUE);
		$testCallback($testCount, ...$dataset);
		$time += microtime(TRUE);
		printf("  %s%5.0f ms\n", str_pad($datasetName, $padLength), $time * 1e3);
	}
	printf("\n");
}
~~~

Results:

~~~~
Test old:
  a                    0 ms
  b                    0 ms
  c                    0 ms
  d                    0 ms
  e                    0 ms
  no-escapes (a)      37 ms
  no-escapes (z)      33 ms
  escapes           3303 ms
  escapes2          3325 ms
  escapes3           289 ms

Test new:
  a                    0 ms
  b                    0 ms
  c                    0 ms
  d                    0 ms
  e                    0 ms
  no-escapes (a)       1 ms
  no-escapes (z)       0 ms
  escapes           3349 ms
  escapes2          3461 ms
  escapes3           277 ms

Test split:
  a                    0 ms
  b                    0 ms
  c                    0 ms
  d                    1 ms
  e                    0 ms
  no-escapes (a)       1 ms
  no-escapes (z)       1 ms
  escapes              8 ms
  escapes2             9 ms
  escapes3           106 ms
~~~