|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +use Drupal\emulsify\Favicon\FaviconPackageGenerator; |
| 6 | +use Drupal\emulsify\Favicon\FaviconSettings; |
| 7 | + |
| 8 | +$mode = getenv('EMULSIFY_FAVICON_MODE') ?: 'assert-generated'; |
| 9 | +$theme_name = getenv('EMULSIFY_FAVICON_THEME'); |
| 10 | +$theme_name = is_string($theme_name) && $theme_name !== '' |
| 11 | + ? $theme_name |
| 12 | + : 'emulsify'; |
| 13 | +$site_name = (string) \Drupal::config('system.site')->get('name'); |
| 14 | +$svg = <<<'SVG' |
| 15 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> |
| 16 | + <rect width="512" height="512" rx="96" fill="#0b5d1e"/> |
| 17 | + <path d="M256 112c79.5 0 144 64.5 144 144s-64.5 144-144 144-144-64.5-144-144 64.5-144 144-144Zm0 78c-36.5 0-66 29.5-66 66s29.5 66 66 66 66-29.5 66-66-29.5-66-66-66Z" fill="#ffffff"/> |
| 18 | +</svg> |
| 19 | +SVG; |
| 20 | + |
| 21 | +/** |
| 22 | + * Creates the generator used by the smoke assertions. |
| 23 | + */ |
| 24 | +function emulsify_favicon_generator(): FaviconPackageGenerator { |
| 25 | + return new FaviconPackageGenerator( |
| 26 | + \Drupal::service('file_system'), |
| 27 | + \Drupal::service('file_url_generator'), |
| 28 | + \Drupal::service('config.factory'), |
| 29 | + \Drupal::service('cache_tags.invalidator'), |
| 30 | + \Drupal::service('datetime.time'), |
| 31 | + \Drupal::service('lock'), |
| 32 | + ); |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Fails the smoke script with a clear message. |
| 37 | + */ |
| 38 | +function emulsify_favicon_fail(string $message): void { |
| 39 | + fwrite(STDERR, $message . PHP_EOL); |
| 40 | + exit(1); |
| 41 | +} |
| 42 | + |
| 43 | +/** |
| 44 | + * Asserts a condition and exits if it fails. |
| 45 | + */ |
| 46 | +function emulsify_favicon_assert(bool $condition, string $message): void { |
| 47 | + if (!$condition) { |
| 48 | + emulsify_favicon_fail($message); |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Asserts that a callback throws an InvalidArgumentException. |
| 54 | + */ |
| 55 | +function emulsify_favicon_assert_invalid(callable $callback, string $expected_message): void { |
| 56 | + try { |
| 57 | + $callback(); |
| 58 | + } |
| 59 | + catch (\InvalidArgumentException $exception) { |
| 60 | + emulsify_favicon_assert(str_contains($exception->getMessage(), $expected_message), sprintf('Expected exception containing "%s", got "%s".', $expected_message, $exception->getMessage())); |
| 61 | + return; |
| 62 | + } |
| 63 | + |
| 64 | + emulsify_favicon_fail(sprintf('Expected InvalidArgumentException containing "%s".', $expected_message)); |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * Loads normalized theme settings. |
| 69 | + */ |
| 70 | +function emulsify_favicon_load_settings(string $theme_name, string $site_name): array { |
| 71 | + $stored = \Drupal::configFactory()->get($theme_name . '.settings')->getRawData(); |
| 72 | + return FaviconSettings::normalize(is_array($stored) ? $stored : [], $site_name); |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * Persists generated package metadata back to theme settings. |
| 77 | + */ |
| 78 | +function emulsify_favicon_save_generated_state(string $theme_name, array $settings): void { |
| 79 | + $config = \Drupal::configFactory()->getEditable($theme_name . '.settings'); |
| 80 | + foreach (FaviconSettings::DEFAULTS as $key => $default) { |
| 81 | + $config->set($key, $settings[$key] ?? $default); |
| 82 | + } |
| 83 | + $config->save(); |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Exercises the SVG sanitizer allow/strip/reject matrix. |
| 88 | + */ |
| 89 | +function emulsify_favicon_run_sanitizer_matrix(FaviconPackageGenerator $generator): void { |
| 90 | + $simple = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#000"/></svg>'; |
| 91 | + $symbols = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><symbol id="mark"><circle cx="32" cy="32" r="16"/></symbol><use href="#mark"/></svg>'; |
| 92 | + $gradients = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g"><stop offset="0%" stop-color="#000"/><stop offset="100%" stop-color="#fff"/></linearGradient></defs><rect width="64" height="64" fill="url(#g)"/></svg>'; |
| 93 | + $raster = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z5x8AAAAASUVORK5CYII=" width="64" height="64"/></svg>'; |
| 94 | + |
| 95 | + $analysis = $generator->validateSourceSvg($simple, FALSE); |
| 96 | + emulsify_favicon_assert(($analysis['sanitized_svg'] ?? '') !== '', 'Simple square SVG should be accepted.'); |
| 97 | + |
| 98 | + $analysis = $generator->validateSourceSvg($symbols, FALSE); |
| 99 | + emulsify_favicon_assert(str_contains((string) $analysis['sanitized_svg'], 'href="#mark"'), 'Symbol/use references should be preserved.'); |
| 100 | + |
| 101 | + $analysis = $generator->validateSourceSvg($gradients, FALSE); |
| 102 | + emulsify_favicon_assert(str_contains((string) $analysis['sanitized_svg'], 'linearGradient'), 'Inline gradients should be preserved.'); |
| 103 | + |
| 104 | + $analysis = $generator->validateSourceSvg($raster, FALSE); |
| 105 | + emulsify_favicon_assert(!empty($analysis['has_embedded_raster_images']), 'Base64 embedded raster images should be detected.'); |
| 106 | + |
| 107 | + $analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><script>alert(1)</script><rect width="64" height="64"/></svg>', FALSE); |
| 108 | + emulsify_favicon_assert(!str_contains((string) $analysis['sanitized_svg'], '<script'), 'Script tags should be stripped from sanitized SVG output.'); |
| 109 | + |
| 110 | + $analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><foreignObject><div>bad</div></foreignObject><rect width="64" height="64"/></svg>', FALSE); |
| 111 | + emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'foreignobject'), 'foreignObject nodes should be stripped from sanitized SVG output.'); |
| 112 | + |
| 113 | + $analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><use href="https://example.com/icon.svg#mark"/></svg>', FALSE); |
| 114 | + emulsify_favicon_assert(!str_contains((string) $analysis['sanitized_svg'], 'https://example.com/icon.svg'), 'External href values should be stripped.'); |
| 115 | + |
| 116 | + $analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><use href="javascript:alert(1)"/></svg>', FALSE); |
| 117 | + emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'javascript:'), 'javascript: href values should be stripped.'); |
| 118 | + |
| 119 | + $analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><image href="https://example.com/icon.png" width="64" height="64"/></svg>', FALSE); |
| 120 | + emulsify_favicon_assert(!str_contains((string) $analysis['sanitized_svg'], 'https://example.com/icon.png'), 'Remote image href values should be stripped.'); |
| 121 | + |
| 122 | + $analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" onload="alert(1)"><rect width="64" height="64" onclick="alert(1)"/></svg>', FALSE); |
| 123 | + emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'onclick='), 'Inline event handlers should be stripped.'); |
| 124 | + emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'onload='), 'Root event handlers should be stripped.'); |
| 125 | + |
| 126 | + emulsify_favicon_assert_invalid( |
| 127 | + fn() => $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><rect width="64" height="32"/></svg>', FALSE), |
| 128 | + 'square viewBox', |
| 129 | + ); |
| 130 | + |
| 131 | + $oversized_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><desc>' . str_repeat('x', FaviconPackageGenerator::MAX_FILE_SIZE) . '</desc></svg>'; |
| 132 | + emulsify_favicon_assert_invalid( |
| 133 | + fn() => $generator->validateSourceSvg($oversized_svg, FALSE), |
| 134 | + 'smaller than 5 MB', |
| 135 | + ); |
| 136 | +} |
| 137 | + |
| 138 | +$generator = emulsify_favicon_generator(); |
| 139 | + |
| 140 | +switch ($mode) { |
| 141 | + case 'prepare': |
| 142 | + \Drupal::configFactory() |
| 143 | + ->getEditable($theme_name . '.settings') |
| 144 | + ->set('favicon_package_enabled', TRUE) |
| 145 | + ->set('favicon_source_fid', []) |
| 146 | + ->set('favicon_source_svg', $svg) |
| 147 | + ->set('favicon_source_filename', 'portable-release-check.svg') |
| 148 | + ->set('favicon_background_color', '#0b5d1e') |
| 149 | + ->set('favicon_theme_color', '#0b5d1e') |
| 150 | + ->set('favicon_ios_background_color', '#ffffff') |
| 151 | + ->set('favicon_ios_padding', 16) |
| 152 | + ->set('favicon_ios_icon_name', 'Emulsify') |
| 153 | + ->set('favicon_android_background_color', '#0b5d1e') |
| 154 | + ->set('favicon_android_padding', 20) |
| 155 | + ->set('favicon_android_maskable_enabled', TRUE) |
| 156 | + ->set('favicon_manifest_name', $site_name) |
| 157 | + ->set('favicon_manifest_short_name', 'Emulsify') |
| 158 | + ->set('favicon_manifest_display', 'standalone') |
| 159 | + ->set('favicon_package_hash', '') |
| 160 | + ->set('favicon_package_path', '') |
| 161 | + ->set('favicon_package_generated_at', 0) |
| 162 | + ->save(); |
| 163 | + fwrite(STDOUT, "Prepared portable favicon config for {$theme_name}.\n"); |
| 164 | + return; |
| 165 | + |
| 166 | + case 'delete-package': |
| 167 | + $settings = emulsify_favicon_load_settings($theme_name, $site_name); |
| 168 | + $definition = $generator->getPackageDefinition($theme_name, $settings, $svg); |
| 169 | + $realpath = \Drupal::service('file_system')->realpath($definition['path']); |
| 170 | + if ($realpath && is_dir($realpath)) { |
| 171 | + \Drupal::service('file_system')->deleteRecursive($realpath); |
| 172 | + } |
| 173 | + emulsify_favicon_assert(!$generator->packageExists($definition['path']), sprintf('Expected the favicon package at %s to be deleted.', $definition['path'])); |
| 174 | + fwrite(STDOUT, "Deleted generated package at {$definition['path']}.\n"); |
| 175 | + return; |
| 176 | + |
| 177 | + case 'generate': |
| 178 | + $settings = emulsify_favicon_load_settings($theme_name, $site_name); |
| 179 | + $source_svg = FaviconSettings::getPortableSourceSvg($settings); |
| 180 | + emulsify_favicon_assert($source_svg !== '', 'Expected a portable SVG source before generation.'); |
| 181 | + |
| 182 | + $result = $generator->generateFromSvg( |
| 183 | + $theme_name, |
| 184 | + $source_svg, |
| 185 | + $settings, |
| 186 | + [ |
| 187 | + 'filename' => (string) ($settings['favicon_source_filename'] ?: 'portable-release-check.svg'), |
| 188 | + ], |
| 189 | + ); |
| 190 | + $settings['favicon_package_hash'] = $result['hash']; |
| 191 | + $settings['favicon_package_path'] = $result['path']; |
| 192 | + $settings['favicon_package_generated_at'] = $result['generated_at']; |
| 193 | + emulsify_favicon_save_generated_state($theme_name, $settings); |
| 194 | + fwrite(STDOUT, "Generated portable favicon package at {$result['path']}.\n"); |
| 195 | + return; |
| 196 | + |
| 197 | + case 'assert-generated': |
| 198 | + $settings = emulsify_favicon_load_settings($theme_name, $site_name); |
| 199 | + $definition = $generator->getPackageDefinition($theme_name, $settings, $svg); |
| 200 | + emulsify_favicon_assert($settings['favicon_package_hash'] === $definition['hash'], 'Theme config should store the deterministic favicon package hash.'); |
| 201 | + emulsify_favicon_assert($settings['favicon_package_path'] === $definition['path'], 'Theme config should store the deterministic favicon package path.'); |
| 202 | + emulsify_favicon_assert($generator->packageExists($definition['path']), sprintf('Expected generated favicon package at %s.', $definition['path'])); |
| 203 | + |
| 204 | + $realpath = \Drupal::service('file_system')->realpath($definition['path']); |
| 205 | + emulsify_favicon_assert(is_string($realpath) && is_dir($realpath), 'Expected a real generated package directory.'); |
| 206 | + |
| 207 | + $expected_files = [ |
| 208 | + 'favicon.svg', |
| 209 | + 'favicon.ico', |
| 210 | + 'favicon-96x96.png', |
| 211 | + 'apple-touch-icon.png', |
| 212 | + 'web-app-manifest-192x192.png', |
| 213 | + 'web-app-manifest-512x512.png', |
| 214 | + 'web-app-manifest-512x512-maskable.png', |
| 215 | + 'site.webmanifest', |
| 216 | + 'metadata.json', |
| 217 | + ]; |
| 218 | + foreach ($expected_files as $expected_file) { |
| 219 | + emulsify_favicon_assert(is_file($realpath . DIRECTORY_SEPARATOR . $expected_file), sprintf('Missing generated favicon asset %s.', $expected_file)); |
| 220 | + } |
| 221 | + |
| 222 | + $metadata = $generator->readPackageMetadata($definition['path']); |
| 223 | + emulsify_favicon_assert(is_array($metadata), 'Expected metadata.json to decode into an array.'); |
| 224 | + emulsify_favicon_assert(($metadata['hash'] ?? '') === $definition['hash'], 'metadata.json should preserve the deterministic package hash.'); |
| 225 | + emulsify_favicon_assert(($metadata['source']['filename'] ?? '') === 'portable-release-check.svg', 'metadata.json should preserve the source filename.'); |
| 226 | + |
| 227 | + emulsify_favicon_run_sanitizer_matrix($generator); |
| 228 | + fwrite(STDOUT, "Verified portable favicon regeneration and sanitizer coverage for {$theme_name}.\n"); |
| 229 | + return; |
| 230 | + |
| 231 | + case 'assert-reset': |
| 232 | + $settings = emulsify_favicon_load_settings($theme_name, $site_name); |
| 233 | + foreach (FaviconSettings::DEFAULTS as $key => $expected_value) { |
| 234 | + emulsify_favicon_assert($settings[$key] === $expected_value, sprintf('Expected %s to reset to its default value.', $key)); |
| 235 | + } |
| 236 | + fwrite(STDOUT, "Verified favicon reset defaults for {$theme_name}.\n"); |
| 237 | + return; |
| 238 | + |
| 239 | + case 'reset': |
| 240 | + $settings = emulsify_favicon_load_settings($theme_name, $site_name); |
| 241 | + $package_paths = array_filter([(string) ($settings['favicon_package_path'] ?? '')]); |
| 242 | + $source_svg = FaviconSettings::getPortableSourceSvg($settings); |
| 243 | + if ($source_svg !== '') { |
| 244 | + $definition = $generator->getPackageDefinition($theme_name, $settings, $source_svg); |
| 245 | + $package_paths[] = $definition['path']; |
| 246 | + } |
| 247 | + |
| 248 | + foreach (array_unique($package_paths) as $package_path) { |
| 249 | + $realpath = \Drupal::service('file_system')->realpath($package_path); |
| 250 | + if ($realpath && is_dir($realpath)) { |
| 251 | + \Drupal::service('file_system')->deleteRecursive($realpath); |
| 252 | + } |
| 253 | + } |
| 254 | + |
| 255 | + $config = \Drupal::configFactory()->getEditable($theme_name . '.settings'); |
| 256 | + foreach (FaviconSettings::DEFAULTS as $key => $value) { |
| 257 | + $config->set($key, $value); |
| 258 | + } |
| 259 | + $config |
| 260 | + ->set('features.favicon', TRUE) |
| 261 | + ->set('favicon.use_default', TRUE) |
| 262 | + ->set('favicon.path', '') |
| 263 | + ->save(); |
| 264 | + fwrite(STDOUT, "Reset favicon settings for {$theme_name}.\n"); |
| 265 | + return; |
| 266 | + |
| 267 | + default: |
| 268 | + emulsify_favicon_fail(sprintf('Unsupported portability smoke mode %s.', $mode)); |
| 269 | +} |
0 commit comments