diff --git a/CHANGELOG.md b/CHANGELOG.md index 517bd93..fd13084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) where applicable. -## Unreleased +## 1.2.0 - 2026-10-01 + +### Added + +- `EncoreEntrypointsLoader::fromFile()` validates local manifests, including + extensions, traversal and symlink confinement, while preserving valid entries + when optional entries are broken. +- `EncoreEntrypointsLoader::editorStyles()` and `SmallStyleConfigurator` share + editor stylesheet discovery and opt-in inlining of small hashed CSS files. ### Changed diff --git a/docs/theme-entrypoints.md b/docs/theme-entrypoints.md new file mode 100644 index 0000000..e57a181 --- /dev/null +++ b/docs/theme-entrypoints.md @@ -0,0 +1,32 @@ +# Local theme entrypoints + +`EncoreEntrypointsLoader::fromFile($manifest)` reads and validates an Encore +manifest before building assets. Each entry is validated independently: invalid +optional editor files do not discard a valid frontend entry. Missing/broken +manifests return an empty asset list. CSS and JS entries must have their matching +extension; files must be readable and remain inside the manifest directory after +canonicalization. Absolute paths, remote URLs, traversal and escaping symlinks +are rejected. The existing general-purpose `load()` API remains unchanged. + +```php +$loader = (new EncoreEntrypointsLoader())->withDirectoryUrl($themeUrl . '/build/'); +$inline = new SmallStyleConfigurator(new FilesystemPathPolicy([$themePath . '/build'])); +foreach ($loader->fromFile($themePath . '/build/entrypoints.json') as $asset) { + $inline->configure($asset); + $manager->register($asset); +} +foreach ($loader->editorStyles($themePath . '/build/entrypoints.json', 'theme-editor') as $css) { + add_editor_style($css); +} +``` + +`editorStyles()` returns relative CSS paths, prefixed with `build/` by default; +the prefix is configurable. Select only the frontend entry handles before +registering assets if the manifest also contains editor entries. + +`SmallStyleConfigurator` is opt-in and implements `AssetConfiguratorInterface`. +It only configures CSS filenames containing a hexadecimal hash of at least eight +characters. Files must be within the supplied path policy, no larger than 16 KiB +(configurable), and contain neither `url()` nor `@import`. Existing inline-content +and filesystem policies still apply when WordPress outputs the asset. The helper +has no kernel dependency and works in standalone WordPress. diff --git a/src/Loader/EncoreEntrypointsLoader.php b/src/Loader/EncoreEntrypointsLoader.php index ca59eb3..1b08534 100644 --- a/src/Loader/EncoreEntrypointsLoader.php +++ b/src/Loader/EncoreEntrypointsLoader.php @@ -13,6 +13,26 @@ */ class EncoreEntrypointsLoader extends AbstractWebpackLoader implements LoaderInterface { + /** + * Load local files only, confined to the manifest directory. + * + * @return array + */ + public function fromFile(string $file): array + { + $entries = EncoreManifest::read($file); + return $entries === null ? [] : $this->loadFromArray(['entrypoints' => $entries], $file); + } + + /** + * @return list + */ + public function editorStyles(string $file, string $entry, string $prefix = 'build/'): array + { + $entries = EncoreManifest::read($file); + return array_map(static fn (string $css): string => $prefix . preg_replace('~^\\./~', '', $css), $entries[$entry]['css'] ?? []); + } + /** * Load an already decoded and validated Encore manifest without reading it again. * The resource path anchors relative asset paths; the manifest need not exist. @@ -33,7 +53,9 @@ public function loadFromArray(array $data, string $resource): array protected function parseData(array $data, string $resource): array { $directory = Path::getDirectory($resource); - /** @var array{entrypoints:array{css?:array, js?:array}} $data */ + /** + * @var array{entrypoints:array{css?:array, js?:array}} $data + */ $data = $data['entrypoints'] ?? []; $assets = []; diff --git a/src/Loader/EncoreManifest.php b/src/Loader/EncoreManifest.php new file mode 100644 index 0000000..4d0a811 --- /dev/null +++ b/src/Loader/EncoreManifest.php @@ -0,0 +1,74 @@ +>>|null + */ + public static function read(string $file): ?array + { + if (!is_readable($file)) { + return null; + } + $contents = file_get_contents($file); + if ($contents === false) { + return null; + } + try { + $manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + $entries = is_array($manifest) ? ($manifest['entrypoints'] ?? null) : null; + if (!is_array($entries)) { + return null; + } + $valid = []; + foreach ($entries as $name => $entry) { + if (!is_string($name) || !is_array($entry)) { + continue; + } + $assets = []; + foreach (['css', 'js'] as $type) { + if (!isset($entry[$type])) { + continue; + } + if (!self::validFiles($entry[$type], dirname($file), $type)) { + continue 2; + } + $assets[$type] = $entry[$type]; + } + if (array_filter($assets) === []) { + continue; + } + + $valid[$name] = $assets; + } + return $valid === [] ? null : $valid; + } + + /** @phpstan-assert-if-true list $files */ + private static function validFiles(mixed $files, string $directory, string $type): bool + { + if (!is_array($files) || !array_is_list($files)) { + return false; + } + foreach ($files as $asset) { + if ( + !is_string($asset) || !preg_match('~^(?:\./)?(?:[a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*\.(?:css|js)$~', $asset) + || pathinfo($asset, PATHINFO_EXTENSION) !== $type + || !is_readable($directory . '/' . $asset) + || !(new FilesystemPathPolicy([$directory]))->allowsPath($directory . '/' . $asset) + ) { + return false; + } + } + return true; + } +} diff --git a/src/SmallStyleConfigurator.php b/src/SmallStyleConfigurator.php new file mode 100644 index 0000000..fde3883 --- /dev/null +++ b/src/SmallStyleConfigurator.php @@ -0,0 +1,36 @@ +filePath(); + if (!$this->paths->allowsPath($path) || !preg_match('/\.[a-f0-9]{8,}\.css$/', $path) || !is_readable($path) || filesize($path) > $this->maxBytes) { + return $asset; + } + $css = file_get_contents($path); + if ($css !== false && !preg_match('/\burl\s*\(|@import\b/i', $css)) { + $asset->useInlineFilter(); + } + return $asset; + } +} diff --git a/tests/phpunit/Unit/Loader/EncoreManifestTest.php b/tests/phpunit/Unit/Loader/EncoreManifestTest.php new file mode 100644 index 0000000..2545778 --- /dev/null +++ b/tests/phpunit/Unit/Loader/EncoreManifestTest.php @@ -0,0 +1,88 @@ +directory = sys_get_temp_dir() . '/sympress-manifest-' . bin2hex(random_bytes(6)); + mkdir($this->directory); + mkdir($this->directory . '/build'); + file_put_contents($this->directory . '/build/app.12345678.css', 'body{color:red}'); + file_put_contents($this->directory . '/outside.css', 'body{color:blue}'); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/build/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->directory . '/build'); + unlink($this->directory . '/outside.css'); + rmdir($this->directory); + parent::tearDown(); + } + + public function testUnsafeOptionalEntriesDoNotDisableValidFrontendAssets(): void + { + symlink($this->directory . '/outside.css', $this->directory . '/build/link.css'); + $file = $this->directory . '/build/entrypoints.json'; + file_put_contents($file, json_encode([ + 'entrypoints' => [ + 'app' => ['css' => ['app.12345678.css']], + 'traversal' => ['css' => ['../outside.css']], + 'symlink' => ['css' => ['link.css']], + 'wrong-type' => ['js' => ['app.12345678.css']], + 'missing-editor' => ['css' => ['missing.css']], + 'remote' => ['css' => ['https://example.test/outside.css']], + ], + ], JSON_THROW_ON_ERROR)); + self::assertSame(['app'], array_keys(EncoreManifest::read($file) ?? [])); + $loader = (new EncoreEntrypointsLoader())->withDirectoryUrl('https://example.test/build/'); + $assets = $loader->fromFile($file); + self::assertCount(1, $assets); + self::assertSame('https://example.test/build/app.12345678.css', $assets[0]->url()); + self::assertSame(['build/app.12345678.css'], $loader->editorStyles($file, 'app')); + self::assertSame([], $loader->editorStyles($file, 'missing-editor')); + } + + public function testBrokenManifestReturnsNoAssets(): void + { + $file = $this->directory . '/build/entrypoints.json'; + $loader = new EncoreEntrypointsLoader(); + self::assertSame([], $loader->fromFile($file)); + foreach (['null', '{}', '{broken', '{"entrypoints":false}'] as $json) { + file_put_contents($file, $json); + self::assertSame([], $loader->fromFile($file)); + } + } + + public function testInliningRequiresHashSmallFileAndSafeDirectory(): void + { + $file = $this->directory . '/build/app.12345678.css'; + $configurator = new SmallStyleConfigurator(new FilesystemPathPolicy([$this->directory . '/build'])); + $style = (new Style('app', '/app.css'))->withFilePath($file); + $configurator->configure($style); + self::assertNotEmpty($style->filters()); + file_put_contents($file, 'body{background:url(image.png)}'); + $external = (new Style('external', '/app.css'))->withFilePath($file); + $configurator->configure($external); + self::assertSame([], $external->filters()); + $outside = (new Style('outside', '/outside.css'))->withFilePath($this->directory . '/outside.css'); + $configurator->configure($outside); + self::assertSame([], $outside->filters()); + } +}