Skip to content

Commit 8114999

Browse files
committed
wip
1 parent a53c87d commit 8114999

File tree

2 files changed

+248
-36
lines changed

2 files changed

+248
-36
lines changed

components/Blueprints/BlueprintParser.php

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@ class BlueprintParser {
1919
*/
2020
private $configuration;
2121

22+
/**
23+
* @var BundleValidator
24+
*/
25+
private $bundle_validator;
26+
2227
public function __construct( RunnerConfiguration $configuration ) {
2328
$this->configuration = $configuration;
29+
$this->bundle_validator = new BundleValidator( $configuration );
2430
}
2531

2632
public function parse( string $blueprint_string ): Blueprint {
@@ -397,7 +403,7 @@ private function buildThemeStep( $theme ): array {
397403
throw new InvalidArgumentException( 'Invalid theme reference format in "themes" array.' );
398404
}
399405

400-
$error = $this->validateDataSource( $step['args']['source'], 'wp-content/themes/*' );
406+
$error = $this->bundle_validator->validate_theme_source( $step['args']['source'] );
401407
$step['errors'] = $error ? [$error] : [];
402408
return $step;
403409
}
@@ -417,7 +423,7 @@ private function buildPluginStep( $plugin ): array {
417423
$plugin = [ 'source' => $plugin ];
418424
}
419425

420-
$error = $this->validateDataSource( $plugin['source'], 'wp-content/plugins/*' );
426+
$error = $this->bundle_validator->validate_plugin_source( $plugin['source'] );
421427
return [
422428
'key' => 'plugins',
423429
'name' => 'installPlugin',
@@ -430,7 +436,7 @@ private function buildMediaStep( $media ): array {
430436
$errors = [];
431437
foreach ( $media as $media_def ) {
432438
$source = is_array( $media_def ) ? $media_def['source'] : $media_def;
433-
$error = $this->validateDataSource( $source, 'wp-content/uploads/*' );
439+
$error = $this->bundle_validator->validate_media_source( $source );
434440
if ( $error ) {
435441
$errors[] = $error;
436442
}
@@ -497,7 +503,7 @@ private function buildContentStep( $content ): array {
497503
}
498504

499505
foreach ( $sources as $source ) {
500-
$error = $this->validateDataSource( $source, 'wp-content/content/posts/*' );
506+
$error = $this->bundle_validator->validate_content_source( $source );
501507
if ( $error ) {
502508
$errors[] = $error;
503509
}
@@ -522,38 +528,6 @@ private function buildAdditionalStepsAfterExecution( $step_data ): array {
522528
];
523529
}
524530

525-
private function validateDataSource( string $source, string $allowed_pattern ): ?string {
526-
if ( 0 === strlen( $source ) ) {
527-
return 'Source must be a non-empty string.';
528-
}
529-
530-
// 1. Absolute URL.
531-
if ( str_contains( $source, '://' ) ) {
532-
return null;
533-
}
534-
535-
// 2. Bundle-relative path.
536-
$byte_1 = $source[0];
537-
$byte_2 = $source[1] ?? null;
538-
if ( str_contains( $source, '/' ) ) {
539-
if ( '/' === $byte_1 ) {
540-
$source = substr( $source, 1 );
541-
} elseif ( '.' === $byte_1 && '/' === $byte_2 ) {
542-
$source = substr( $source, 2 );
543-
}
544-
545-
if ( ! fnmatch( $allowed_pattern, $source ) ) {
546-
return sprintf(
547-
'Invalid path "%s". Expected to match "%s".',
548-
$source,
549-
$allowed_pattern
550-
);
551-
}
552-
}
553-
554-
return null;
555-
}
556-
557531
/**
558532
* Detect on which line a top-level JSON key is defined in the input string.
559533
* This is a simple helper that only supports top-level keys in a top-level
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
<?php
2+
3+
namespace WordPress\Blueprints;
4+
5+
use WordPress\ByteStream\ReadStream\FileReadStream;
6+
use WordPress\Filesystem\Filesystem;
7+
use WordPress\Filesystem\LocalFilesystem;
8+
use WordPress\Zip\ZipFilesystem;
9+
10+
class BundleValidator {
11+
/**
12+
* @var RunnerConfiguration
13+
*/
14+
private $configuration;
15+
16+
public function __construct( RunnerConfiguration $configuration ) {
17+
$this->configuration = $configuration;
18+
}
19+
20+
public function validate_plugin_source( string $source ): ?string {
21+
if ( ! $this->is_bundle_source( $source ) ) {
22+
return null;
23+
}
24+
25+
$error = $this->validate_bundle_source( $source, 'wp-content/plugins/*' );
26+
if ( null === $error ) {
27+
if ( str_ends_with( $source, '.php' ) ) {
28+
$error = $this->validate_plugin_file( $source );
29+
} elseif ( str_ends_with( $source, '.zip' ) ) {
30+
$error = $this->validate_plugin_zip( $source );
31+
} else {
32+
$error = 'Invalid plugin source. Expected a .php or .zip file.';
33+
}
34+
}
35+
return $error;
36+
}
37+
38+
public function validate_theme_source( string $source ): ?string {
39+
if ( ! $this->is_bundle_source( $source ) ) {
40+
return null;
41+
}
42+
43+
$error = $this->validate_bundle_source( $source, 'wp-content/themes/*' );
44+
if ( null === $error ) {
45+
if ( str_ends_with( $source, '.zip' ) ) {
46+
$error = $this->validate_theme_zip( $source );
47+
} else {
48+
$error = $this->validate_theme_dir( $source );
49+
}
50+
}
51+
return $error;
52+
}
53+
54+
public function validate_content_source( string $source ): ?string {
55+
if ( ! $this->is_bundle_source( $source ) ) {
56+
return null;
57+
}
58+
59+
$error = $this->validate_bundle_source( $source, 'wp-content/content/*' );
60+
if ( null === $error ) {
61+
if ( ! str_ends_with( $source, '.sql' ) && ! str_ends_with( $source, '.xml' ) ) {
62+
$error = 'Invalid content source. Expected a .sql or .xml file.';
63+
}
64+
}
65+
return $error;
66+
}
67+
68+
public function validate_media_source( string $source ): ?string {
69+
if ( ! $this->is_bundle_source( $source ) ) {
70+
return null;
71+
}
72+
73+
// TODO: Do we want to check for allowed file formats?
74+
return $this->validate_bundle_source( $source, 'wp-content/uploads/*' );
75+
}
76+
77+
/**
78+
* Validate a plugin file as per WordPress plugin requirements.
79+
*
80+
* The implementation mirrors "get_file_data()" from WordPress core.
81+
*
82+
* @param string $source The path to the plugin file.
83+
* @return string|null An error message if the plugin file is invalid, or null if it is valid.
84+
*/
85+
private function validate_plugin_file( string $source ): ?string {
86+
$root = $this->configuration->getTargetSiteRoot();
87+
$fs = LocalFilesystem::create( $root );
88+
return $this->validate_plugin_header( $fs, $source );
89+
}
90+
91+
private function validate_plugin_zip( string $source ): ?string {
92+
$root = $this->configuration->getTargetSiteRoot();
93+
$file = rtrim( $root, '/' ) . ltrim( $source, '.' );
94+
95+
$stream = FileReadStream::from_path( $file );
96+
$zip = ZipFilesystem::create( $stream );
97+
$paths = $zip->ls();
98+
99+
// Plugin files in the root of the ZIP archive.
100+
$plugin_files = [];
101+
foreach ( $paths as $path ) {
102+
if ( $zip->is_file( $path ) && str_ends_with( $path, '.php' ) ) {
103+
if ( $this->validate_plugin_header( $zip, $path ) ) {
104+
$plugin_files[] = $path;
105+
}
106+
}
107+
}
108+
109+
if ( count( $plugin_files ) > 1 ) {
110+
return 'Invalid plugin zip. Contains multiple plugin files.';
111+
} elseif ( count( $plugin_files ) === 1 ) {
112+
return null;
113+
}
114+
115+
// Plugin directories in the root of the ZIP archive.
116+
$plugin_dirs = [];
117+
foreach ( $paths as $path ) {
118+
if ( ! $zip->is_dir( $path ) || $path === '__MACOSX' ) {
119+
continue;
120+
}
121+
foreach ( $zip->ls( $path ) as $subpath ) {
122+
$full_path = "$path/$subpath";
123+
if ( $zip->is_file( $full_path ) && str_ends_with( $subpath, '.php' ) ) {
124+
if ( $this->validate_plugin_header( $zip, $full_path ) ) {
125+
$plugin_dirs[] = $path;
126+
}
127+
}
128+
}
129+
}
130+
131+
if ( count( $plugin_dirs ) > 1 ) {
132+
return 'Invalid plugin zip. Contains multiple plugin directories.';
133+
} elseif ( count( $plugin_dirs ) === 1 ) {
134+
return null;
135+
}
136+
137+
return 'Invalid plugin zip. Contains no plugin file or directory.';
138+
}
139+
140+
private function validate_plugin_header( Filesystem $fs, string $path ): ?string {
141+
// Pull only the first 8 KB of the file in.
142+
$stream = $fs->open_read_stream( $path );
143+
$bytes = $stream->pull( 8 * 1024 );
144+
$file_data = $stream->consume( $bytes );
145+
146+
// Make sure we catch CR-only line endings.
147+
$file_data = str_replace( "\r", "\n", $file_data );
148+
149+
// We only need to check for the plugin name header.
150+
$all_headers = array( 'Name' => 'Plugin Name' );
151+
foreach ( $all_headers as $field => $regex ) {
152+
if ( preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && $match[1] ) {
153+
$all_headers[ $field ] = $match[1];
154+
} else {
155+
$all_headers[ $field ] = '';
156+
}
157+
}
158+
159+
if ( empty( $all_headers['Name'] ) ) {
160+
return 'Invalid plugin file. Missing "Plugin Name" header.';
161+
}
162+
return null;
163+
}
164+
165+
private function validate_theme_dir( string $source ): ?string {
166+
$root = $this->configuration->getTargetSiteRoot();
167+
$fs = LocalFilesystem::create( $root );
168+
return $this->validate_theme_path( $fs, $source );
169+
}
170+
171+
private function validate_theme_zip( string $source ): ?string {
172+
$root = $this->configuration->getTargetSiteRoot();
173+
$file = rtrim( $root, '/' ) . ltrim( $source, '.' );
174+
175+
$stream = FileReadStream::from_path( $file );
176+
$zip = ZipFilesystem::create( $stream );
177+
$paths = $zip->ls();
178+
179+
// Theme directories in the root of the ZIP archive.
180+
$theme_dirs = [];
181+
foreach ( $paths as $path ) {
182+
if ( ! $zip->is_dir( $path ) || $path === '__MACOSX' ) {
183+
continue;
184+
}
185+
if ( null === $this->validate_theme_path( $zip, $path ) ) {
186+
$theme_dirs[] = $path;
187+
}
188+
}
189+
190+
if ( count( $theme_dirs ) === 0 ) {
191+
return sprintf( 'Invalid theme ZIP. No theme directories found: %s', $file );
192+
} elseif ( count( $theme_dirs ) > 1 ) {
193+
return sprintf( 'Invalid theme ZIP. Contains multiple theme directories: %s', $file );
194+
}
195+
return null;
196+
}
197+
198+
private function validate_theme_path( Filesystem $fs, string $path ): ?string {
199+
if ( ! $fs->exists( $path ) ) {
200+
return sprintf( 'Invalid theme directory. Directory does not exist: %s', $path );
201+
}
202+
if ( ! $fs->is_dir( $path ) ) {
203+
return sprintf( 'Invalid theme directory. Not a directory: %s', $path );
204+
}
205+
if ( ! $fs->exists( "$path/style.css" ) ) {
206+
return sprintf( 'Invalid theme directory. Missing "style.css" file: %s', $path );
207+
}
208+
if ( ! $fs->is_file( "$path/style.css" ) ) {
209+
return sprintf( 'Invalid theme directory. "style.css" is not a file: %s', $path );
210+
}
211+
return null;
212+
}
213+
214+
private function validate_bundle_source( string $source, string $allowed_pattern ): ?string {
215+
$byte_1 = $source[0];
216+
$byte_2 = $source[1] ?? null;
217+
218+
if ( '/' === $byte_1 ) {
219+
$source = substr( $source, 1 );
220+
} elseif ( '.' === $byte_1 && '/' === $byte_2 ) {
221+
$source = substr( $source, 2 );
222+
}
223+
224+
if ( ! fnmatch( $allowed_pattern, $source ) ) {
225+
return sprintf(
226+
'Invalid path "%s". Expected to match "%s".',
227+
$source,
228+
$allowed_pattern
229+
);
230+
}
231+
232+
return null;
233+
}
234+
235+
private function is_bundle_source( string $source ): bool {
236+
return ! str_contains( $source, '://' ) && str_contains( $source, '/' );
237+
}
238+
}

0 commit comments

Comments
 (0)