Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions php-transformer/src/Path/ArtifactPath.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
4 changes: 4 additions & 0 deletions php-transformer/tests/contract/wordpress-site-plan.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<main>None</main>')))->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' => '<link rel="stylesheet" href="assets/%ffform.css"><form class="contact"><input name="email"></form>', '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' => '<style>.ex-row{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}</style><main><section class="ex-row"><div>One</div><div>Two</div><div>Three</div><div>Four</div><div>Five</div></section></main>')))->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()));
Expand Down
Loading