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' => '