PluginProbe
WindPress – Tailwind CSS integration for WordPress / 3.2.89
WindPress – Tailwind CSS integration for WordPress v3.2.89
3.2.89 3.2.88 3.2.87 3.2.86 3.2.85 3.2.84 3.2.83 3.2.82 3.2.81 trunk 3.0.0 3.0.1 3.0.10 3.0.11 3.0.12 3.0.13 3.0.14 3.0.15 3.0.16 3.0.17 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 All 143 releases
windpress / src / Core / Scanner / FileScanner.php

FileScanner.php in WindPress – Tailwind CSS integration for WordPress 3.2.89, at src/Core/Scanner/FileScanner.php

342 lines 16.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare (strict_types=1);
4 namespace WindPress\WindPress\Core\Scanner;
5
6 use WindPressDeps\Symfony\Component\Finder\Glob;
7 /**
8 * Resumable file discovery and bounded content reads.
9 */
10 class FileScanner
11 {
12 private const CURSOR_TTL = 900;
13 public static function scan_local(string $root, array $patterns, array $exclude_patterns = [], $cursor = \false, bool $batched = \true, array $limits = []): array
14 {
15 $configuration = self::local_configuration($root, $patterns, $exclude_patterns);
16 [$root, $patterns, $exclude_patterns, $allowed_roots] = $configuration;
17 $scope = self::configuration_scope($configuration);
18 return self::scan($scope, $cursor, $batched, $limits, static function () use ($root, $patterns, $exclude_patterns, $allowed_roots): array {
19 $roots = [];
20 foreach ($patterns as $pattern) {
21 $prefix = substr($pattern, 0, strcspn($pattern, '*?[{'));
22 $separator = strrpos($prefix, '/');
23 $directory = $separator === \false ? $root : $root . '/' . substr($prefix, 0, $separator);
24 if (is_dir($directory)) {
25 $roots[$directory] = $directory;
26 }
27 }
28 sort($roots, \SORT_STRING);
29 $narrowed_roots = [];
30 foreach ($roots as $directory) {
31 foreach ($narrowed_roots as $parent) {
32 if (self::within($directory, $parent)) {
33 continue 2;
34 }
35 }
36 $narrowed_roots[] = $directory;
37 }
38 return ['kind' => 'local', 'root' => $root, 'roots' => array_reverse($narrowed_roots), 'allowed_roots' => $allowed_roots, 'patterns' => array_map([self::class, 'pattern_regex'], $patterns), 'exclude_patterns' => array_map([self::class, 'pattern_regex'], $exclude_patterns), 'stack' => [], 'seen_directories' => [], 'seen_files' => [], 'done' => \false];
39 });
40 }
41 public static function local_scope(string $root, array $patterns, array $exclude_patterns = []): string
42 {
43 return self::configuration_scope(self::local_configuration($root, $patterns, $exclude_patterns));
44 }
45 /**
46 * Snapshot provider-selected paths once, preserving Finder extension hooks.
47 * The factory returns records with a path and any source metadata to retain.
48 */
49 public static function scan_files(string $scope, callable $factory, $cursor = \false, array $limits = []): array
50 {
51 return self::scan('provider:' . $scope, $cursor, \true, $limits, static function () use ($factory): array {
52 $files = [];
53 foreach ($factory() as $file) {
54 if (!is_array($file) || !isset($file['path']) || !is_string($file['path'])) {
55 throw new \InvalidArgumentException(__('A scanner returned an invalid file path.', 'windpress'));
56 }
57 $real_path = realpath($file['path']);
58 if ($real_path === \false || !is_file($real_path)) {
59 throw new \RuntimeException(__('A source file no longer exists: ', 'windpress') . $file['path']);
60 }
61 $file['path'] = $real_path;
62 $file['name'] = $file['name'] ?? basename($real_path);
63 if (!isset($files[$real_path])) {
64 $files[$real_path] = $file;
65 }
66 }
67 ksort($files, \SORT_STRING);
68 return ['kind' => 'files', 'files' => array_values($files), 'offset' => 0, 'done' => \false];
69 });
70 }
71 private static function local_configuration(string $root, array $patterns, array $exclude_patterns): array
72 {
73 $root = realpath($root);
74 if ($root === \false || !is_dir($root)) {
75 throw new \InvalidArgumentException(__('The source directory does not exist.', 'windpress'));
76 }
77 $patterns = self::normalize_patterns($patterns, $root);
78 $exclude_patterns = self::normalize_patterns($exclude_patterns, $root);
79 $allowed_roots = apply_filters('f!windpress/core/scanner/file:allowed_roots', [$root], $root);
80 if (!is_array($allowed_roots)) {
81 throw new \InvalidArgumentException(__('Allowed source directories must be an array.', 'windpress'));
82 }
83 foreach ($allowed_roots as $allowed_root) {
84 if (!is_string($allowed_root)) {
85 throw new \InvalidArgumentException(__('Allowed source directories must contain paths.', 'windpress'));
86 }
87 }
88 $allowed_roots = array_values(array_unique(array_filter(array_map('realpath', $allowed_roots))));
89 sort($allowed_roots, \SORT_STRING);
90 return [$root, $patterns, $exclude_patterns, $allowed_roots];
91 }
92 private static function configuration_scope(array $configuration): string
93 {
94 return hash('sha256', serialize(['files-v1', $configuration]));
95 }
96 private static function scan(string $scope, $cursor, bool $batched, array $limits, callable $factory): array
97 {
98 $limits = array_replace(['files' => 100, 'bytes' => 2 * 1024 * 1024, 'seconds' => 1.0], $limits);
99 $limits = apply_filters('f!windpress/core/scanner/file:limits', $limits, $scope);
100 if (!is_array($limits) || !isset($limits['files'], $limits['bytes'], $limits['seconds']) || !is_int($limits['files']) || !is_int($limits['bytes']) || !is_numeric($limits['seconds']) || !is_finite((float) $limits['seconds']) || $limits['files'] < 1 || $limits['bytes'] < 1 || $limits['bytes'] === \PHP_INT_MAX || $limits['seconds'] <= 0) {
101 throw new \InvalidArgumentException(__('File scan limits must be positive.', 'windpress'));
102 }
103 $page = 0;
104 $key = '';
105 if ($cursor !== \false && $cursor !== null) {
106 if (!$batched || !is_string($cursor) || !preg_match('/^fs_([a-f0-9]{32})_([0-9]+)$/D', $cursor, $matches)) {
107 throw new \InvalidArgumentException(__('The file scan cursor is invalid.', 'windpress'));
108 }
109 $key = 'windpress_scan_' . get_current_user_id() . '_' . $matches[1];
110 $page = (int) $matches[2];
111 } elseif ($batched) {
112 $key = 'windpress_scan_' . get_current_user_id() . '_' . bin2hex(random_bytes(16));
113 }
114 $lease = $batched ? \WindPress\WindPress\Core\Scanner\ScanLock::acquire($key) : null;
115 if ($batched && $lease === null) {
116 throw new \RuntimeException(__('This file scan is already processing a batch. Retry the same cursor.', 'windpress'), 409);
117 }
118 try {
119 return self::scan_page($scope, $cursor, $batched, $limits, $factory, $key, $page, $lease);
120 } finally {
121 if ($lease !== null) {
122 \WindPress\WindPress\Core\Scanner\ScanLock::release($key, $lease);
123 }
124 }
125 }
126 private static function scan_page(string $scope, $cursor, bool $batched, array $limits, callable $factory, string $key, int $page, ?string $lease): array
127 {
128 if ($cursor !== \false && $cursor !== null) {
129 $job = get_transient($key);
130 if (!is_array($job) || $job['scope'] !== $scope) {
131 throw new \InvalidArgumentException(__('The file scan expired or its sources changed. Start a new scan.', 'windpress'));
132 }
133 if ($page === $job['previous_page']) {
134 return $job['previous_response'];
135 }
136 if ($page !== $job['page']) {
137 throw new \InvalidArgumentException(__('The file scan cursor is out of sequence. Start a new scan.', 'windpress'));
138 }
139 $state = $job['state'];
140 } else {
141 $state = $factory();
142 }
143 $contents = [];
144 $bytes = 0;
145 $deadline = $batched ? microtime(\true) + $limits['seconds'] : \PHP_FLOAT_MAX;
146 while (!$state['done'] && (!$batched || count($contents) < $limits['files'] && microtime(\true) < $deadline)) {
147 if (!isset($state['pending'])) {
148 $state['pending'] = self::next_file($state, $deadline);
149 }
150 if ($state['pending'] === null) {
151 unset($state['pending']);
152 break;
153 }
154 $file = $state['pending'];
155 $path = $file['path'];
156 if ($state['kind'] === 'local') {
157 $path = self::allowed_path($state, $path);
158 }
159 clearstatcache(\true, $path);
160 if (!is_file($path) || !is_readable($path)) {
161 throw new \RuntimeException(__('A source file cannot be read: ', 'windpress') . $file['name']);
162 }
163 $size = @filesize($path);
164 if ($size === \false) {
165 throw new \RuntimeException(__('The source file size could not be read: ', 'windpress') . $file['name']);
166 }
167 if ($batched && $size > $limits['bytes']) {
168 throw new \RuntimeException(__('A source file exceeds the scan byte limit: ', 'windpress') . $file['name']);
169 }
170 if ($batched && $bytes + $size > $limits['bytes']) {
171 break;
172 }
173 $content = $batched ? @file_get_contents($path, \false, null, 0, $limits['bytes'] + 1) : @file_get_contents($path);
174 if ($content === \false) {
175 throw new \RuntimeException(__('A source file could not be read: ', 'windpress') . $file['name']);
176 }
177 if ($batched && strlen($content) > $limits['bytes']) {
178 throw new \RuntimeException(__('A source file exceeds the scan byte limit: ', 'windpress') . $file['name']);
179 }
180 if ($batched && $bytes + strlen($content) > $limits['bytes']) {
181 break;
182 }
183 $bytes += strlen($content);
184 unset($file['path'], $state['pending']);
185 $file['source_id'] = 'file:' . hash('sha256', $path);
186 $file['content'] = $content;
187 $contents[] = $file;
188 }
189 $next_batch = \false;
190 if ($batched && !$state['done']) {
191 $next_batch = 'fs_' . substr($key, -32) . '_' . ($page + 1);
192 }
193 $response = ['contents' => $contents, 'metadata' => ['next_batch' => $next_batch, 'scanned_files' => count($contents), 'scanned_bytes' => $bytes]];
194 if ($batched && ($next_batch !== \false || $cursor !== \false && $cursor !== null)) {
195 if ($lease === null || !\WindPress\WindPress\Core\Scanner\ScanLock::is_owner($key, $lease)) {
196 throw new \RuntimeException(__('The file scan lease expired. Retry the same cursor.', 'windpress'), 409);
197 }
198 $job = ['scope' => $scope, 'page' => $page + 1, 'state' => $state['done'] ? [] : $state, 'previous_page' => $page, 'previous_response' => $response];
199 if (!set_transient($key, $job, self::CURSOR_TTL)) {
200 throw new \RuntimeException(__('The file scan continuation could not be saved.', 'windpress'));
201 }
202 }
203 return $response;
204 }
205 private static function next_file(array &$state, float $deadline): ?array
206 {
207 if ($state['kind'] === 'files') {
208 if ($state['offset'] >= count($state['files'])) {
209 $state['done'] = \true;
210 return null;
211 }
212 return $state['files'][$state['offset']++];
213 }
214 while (microtime(\true) < $deadline) {
215 if ($state['stack'] === []) {
216 if ($state['roots'] === []) {
217 $state['done'] = \true;
218 return null;
219 }
220 self::open_directory($state, array_pop($state['roots']));
221 continue;
222 }
223 $index = count($state['stack']) - 1;
224 $frame =& $state['stack'][$index];
225 if ($frame['offset'] >= count($frame['entries'])) {
226 unset($frame);
227 array_pop($state['stack']);
228 continue;
229 }
230 $name = $frame['entries'][$frame['offset']++];
231 $path = $frame['path'] . '/' . $name;
232 unset($frame);
233 // Preserve Finder's default hidden-file and VCS-directory exclusions.
234 if ($name[0] === '.' || in_array($name, ['CVS', '_darcs', '_svn'], \true)) {
235 continue;
236 }
237 $relative_path = substr($path, strlen($state['root']) + 1);
238 if (self::matches($relative_path, $state['exclude_patterns']) || is_dir($path) && self::matches($relative_path . '/', $state['exclude_patterns'])) {
239 continue;
240 }
241 if (is_dir($path)) {
242 self::open_directory($state, $path);
243 continue;
244 }
245 if (!self::matches($relative_path, $state['patterns'])) {
246 continue;
247 }
248 $real_path = self::allowed_path($state, $path);
249 if (isset($state['seen_files'][$real_path])) {
250 continue;
251 }
252 $state['seen_files'][$real_path] = \true;
253 return ['path' => $real_path, 'name' => $name, 'relative_path' => $relative_path];
254 }
255 return null;
256 }
257 private static function open_directory(array &$state, string $path): void
258 {
259 $relative_path = substr($path, strlen($state['root']) + 1);
260 if (self::matches($relative_path, $state['exclude_patterns']) || self::matches($relative_path . '/', $state['exclude_patterns'])) {
261 return;
262 }
263 $real_path = self::allowed_path($state, $path);
264 if (isset($state['seen_directories'][$path])) {
265 return;
266 }
267 foreach ($state['stack'] as $frame) {
268 if ($frame['real_path'] === $real_path) {
269 return;
270 }
271 }
272 $entries = @scandir($path);
273 if ($entries === \false) {
274 throw new \RuntimeException(__('A source directory could not be read: ', 'windpress') . $relative_path);
275 }
276 $state['seen_directories'][$path] = \true;
277 $state['stack'][] = ['path' => $path, 'real_path' => $real_path, 'entries' => $entries, 'offset' => 0];
278 }
279 private static function allowed_path(array $state, string $path): string
280 {
281 $real_path = realpath($path);
282 if ($real_path === \false) {
283 throw new \RuntimeException(__('A source path no longer exists: ', 'windpress') . substr($path, strlen($state['root']) + 1));
284 }
285 foreach ($state['allowed_roots'] as $root) {
286 if (self::within($real_path, $root)) {
287 return $real_path;
288 }
289 }
290 throw new \RuntimeException(__('A source symlink points outside the allowed directories: ', 'windpress') . substr($path, strlen($state['root']) + 1));
291 }
292 private static function within(string $path, string $root): bool
293 {
294 return $path === $root || strpos($path, rtrim($root, '/') . '/') === 0;
295 }
296 private static function normalize_patterns(array $patterns, string $root): array
297 {
298 $normalized = [];
299 foreach ($patterns as $pattern) {
300 if (!is_string($pattern) || $pattern === '' || strlen($pattern) > 4096 || strpos($pattern, "\x00") !== \false) {
301 throw new \InvalidArgumentException(__('Source patterns must be nonempty strings up to 4096 bytes.', 'windpress'));
302 }
303 $pattern = str_replace('\\', '/', $pattern);
304 if ($pattern[0] === '/' || preg_match('#(^|/)\.\.(/|$)|^[a-zA-Z]+:#', $pattern)) {
305 throw new \InvalidArgumentException(__('Source patterns must stay within the source directory.', 'windpress'));
306 }
307 while (strpos($pattern, './') === 0) {
308 $pattern = substr($pattern, 2);
309 }
310 $pattern = rtrim($pattern, '/');
311 if ($pattern === '') {
312 throw new \InvalidArgumentException(__('Source patterns must not be empty.', 'windpress'));
313 }
314 if (is_dir($root . '/' . $pattern)) {
315 $pattern .= '/**';
316 }
317 self::pattern_regex($pattern);
318 $normalized[$pattern] = $pattern;
319 }
320 sort($normalized, \SORT_STRING);
321 return $normalized;
322 }
323 private static function pattern_regex(string $pattern): string
324 {
325 // Symfony recognizes recursive ** after a slash, including at pattern start here.
326 $regex = Glob::toRegex('windpress/' . $pattern);
327 if (@preg_match($regex, '') === \false) {
328 throw new \InvalidArgumentException(__('A source pattern is invalid.', 'windpress'));
329 }
330 return $regex;
331 }
332 private static function matches(string $path, array $patterns): bool
333 {
334 foreach ($patterns as $pattern) {
335 if (preg_match($pattern, 'windpress/' . $path) === 1) {
336 return \true;
337 }
338 }
339 return \false;
340 }
341 }
342