diff --git a/php-transformer/src/Path/ArtifactPath.php b/php-transformer/src/Path/ArtifactPath.php index b6fd08cd2..052ef399f 100644 --- a/php-transformer/src/Path/ArtifactPath.php +++ b/php-transformer/src/Path/ArtifactPath.php @@ -8,7 +8,7 @@ final class ArtifactPath public static function safeRelativePath(string $path): string { $path = self::cleanInput($path); - if ( '' === $path || str_starts_with($path, '/') || (bool) preg_match('#^[A-Za-z]:/#', $path) ) { + if ( '' === $path || 1 !== preg_match('//u', $path) || str_starts_with($path, '/') || (bool) preg_match('#^[A-Za-z]:/#', $path) ) { return ''; } @@ -37,10 +37,10 @@ public static function resolveRelativePath(string $reference, string $sourcePath $base = '' === $sourcePath || ! str_contains($sourcePath, '/') ? '' : dirname($sourcePath) . '/'; $parts = array(); foreach ( explode('/', $base . $reference) as $part ) { - $part = rawurldecode($part); - if ( str_contains($part, '/') || str_contains($part, '\\') ) { - return ''; - } + $part = self::decodePathSegment($part); + if ( str_contains($part, '/') || str_contains($part, '\\') || (bool) preg_match('/%(?:2f|5c)/i', $part) ) { + return ''; + } if ( '' === $part || '.' === $part ) { continue; } @@ -67,6 +67,18 @@ private static function cleanInput(string $path): string return str_replace('\\', '/', trim($path)); } + private static function decodePathSegment(string $segment): string + { + $decoded = rawurldecode($segment); + if ( 1 === preg_match('//u', $decoded) ) { + return $decoded; + } + + // Retain malformed byte escapes as ASCII provenance rather than creating + // an invalid UTF-8 path that cannot be canonically serialized. + return preg_replace_callback('/%([0-9a-f]{2})/i', static fn (array $match): string => '%' . strtoupper($match[1]), $segment) ?? $segment; + } + private static function isAbsoluteReference(string $path): bool { return str_starts_with($path, '/') || (bool) preg_match('#^[A-Za-z][A-Za-z0-9+.-]*:#', $path); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index dd7fc8203..0324f8ae2 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -687,9 +687,12 @@ function serialize_blocks(array $blocks): string $assert('' === ArtifactPath::safeRelativePath('/assets/logo.png'), 'artifact paths reject root-absolute paths'); $assert('' === ArtifactPath::safeRelativePath('C:\\assets\\logo.png'), 'artifact paths reject drive-absolute paths'); $assert('' === ArtifactPath::safeRelativePath('../secrets/logo.png'), 'artifact paths reject traversal paths'); +$assert('' === ArtifactPath::safeRelativePath("assets/\xFFlogo.png"), 'artifact paths reject raw invalid UTF-8 bytes'); $assert('assets/logo.png' === ArtifactPath::resolveRelativePath('../assets/logo.png?version=1#hash', 'pages/home.html'), 'artifact references resolve relative paths without query or fragment'); $assert('assets/JOHN-OATES-‘ARKANSAS.jpg' === ArtifactPath::resolveRelativePath('../assets/JOHN-OATES-%E2%80%98ARKANSAS.jpg', 'pages/home.html'), 'artifact references resolve percent-encoded Unicode path segments to canonical artifact paths'); +$assert('assets/%FFlogo.png' === ArtifactPath::resolveRelativePath('../assets/%fflogo.png', 'pages/home.html'), 'artifact references retain malformed percent-encoded bytes as canonical ASCII provenance'); $assert('' === ArtifactPath::resolveRelativePath('../assets%2flogo.png', 'pages/home.html'), 'artifact references reject encoded path separators'); +$assert('' === ArtifactPath::resolveRelativePath('../assets%ff%2flogo.png', 'pages/home.html'), 'artifact references reject encoded separators even when a malformed byte escape is retained'); $assert('' === ArtifactPath::resolveRelativePath('https://example.com/logo.png', 'pages/home.html'), 'artifact references reject URL references'); $assert('' === ArtifactPath::resolveRelativePath('../../logo.png', 'pages/home.html'), 'artifact references reject traversal above the artifact root'); diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 500c4a073..8d9cc2afa 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -180,6 +180,10 @@ $changedDeclaration = $declaredArtifact; $changedDeclaration['runtime_declarations'][0]['payload']['entities'][0]['id'] = 'b'; $changedDeclarationResult = (new ArtifactCompiler())->compile($changedDeclaration)->toArray(); $changedDeclarationPlan = $changedDeclarationResult['source_reports']['wordpress_site_plan']; $assert(($declaredPlan['source']['source_hash'] ?? null) !== ($changedDeclarationPlan['source']['source_hash'] ?? null) && ($declaredPlan['runtime_declarations'][0]['reconciliation_identity'] ?? null) === ($changedDeclarationPlan['runtime_declarations'][0]['reconciliation_identity'] ?? null) && ($declaredPlan['runtime_declarations'][0]['payload_hash'] ?? null) !== ($changedDeclarationPlan['runtime_declarations'][0]['payload_hash'] ?? null), 'Declaration-only payload changes update source and declaration hashes without changing immutable reconciliation identity.'); $assert(array() === ((new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
None
')))->toArray()['source_reports']['wordpress_site_plan']['runtime_declarations'] ?? null), 'Absent runtime declarations remain an explicit empty collection.'); +$corruptByteStylesheet = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/%FFform.css' => '.contact{display:grid}')))->toArray(); +$corruptByteDeclaration = current(array_filter($corruptByteStylesheet['source_reports']['wordpress_site_plan']['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$corruptByteProvenance = $corruptByteDeclaration['payload']['entities'][0]['layout_graph']['nodes'][0]['provenance'][0]['source_path'] ?? null; +$assert('assets/%FFform.css' === $corruptByteProvenance && isset($corruptByteStylesheet['source_reports']['wordpress_site_plan']), 'Malformed percent-encoded stylesheet bytes retain canonical ASCII provenance through runtime declaration hashing.'); $authorLayoutPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
One
Two
Three
Four
Five
')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); $authorLayoutMarkup = (string) (($authorLayoutPlan['pages'][0]['canonical_block_markup'] ?? '')); $authorLayoutAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $authorLayoutPlan['assets'] ?? array()));