Skip to content

Commit 1fedaf4

Browse files
author
Callin Mullaney
committed
chore: harden 7.x theme readiness
1 parent 9738c94 commit 1fedaf4

13 files changed

Lines changed: 1084 additions & 371 deletions

.browserslistrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Browsers we support for Emulsify 6.x.
1+
# Browsers we support for Emulsify 7.x.
22

33
[production]
44
defaults
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
if [ "$#" -lt 1 ]; then
6+
echo "Usage: $0 <fixture-dir> [theme-name]" >&2
7+
exit 1
8+
fi
9+
10+
fixture_dir="$1"
11+
theme_name="${2:-emulsify}"
12+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13+
14+
(
15+
cd "$fixture_dir"
16+
EMULSIFY_FAVICON_THEME="$theme_name" \
17+
EMULSIFY_FAVICON_MODE=prepare \
18+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
19+
20+
EMULSIFY_FAVICON_THEME="$theme_name" \
21+
EMULSIFY_FAVICON_MODE=generate \
22+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
23+
24+
EMULSIFY_FAVICON_THEME="$theme_name" \
25+
EMULSIFY_FAVICON_MODE=assert-generated \
26+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
27+
28+
EMULSIFY_FAVICON_THEME="$theme_name" \
29+
EMULSIFY_FAVICON_MODE=delete-package \
30+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
31+
32+
EMULSIFY_FAVICON_THEME="$theme_name" \
33+
EMULSIFY_FAVICON_MODE=generate \
34+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
35+
36+
EMULSIFY_FAVICON_THEME="$theme_name" \
37+
EMULSIFY_FAVICON_MODE=assert-generated \
38+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
39+
40+
EMULSIFY_FAVICON_THEME="$theme_name" \
41+
EMULSIFY_FAVICON_MODE=reset \
42+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
43+
44+
EMULSIFY_FAVICON_THEME="$theme_name" \
45+
EMULSIFY_FAVICON_MODE=assert-reset \
46+
./vendor/bin/drush php:eval "require '${script_dir}/favicon-portability-smoke.php';"
47+
)

.github/scripts/favicon-smoke.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
\Drupal::service('config.factory'),
8989
\Drupal::service('cache_tags.invalidator'),
9090
\Drupal::service('datetime.time'),
91+
\Drupal::service('lock'),
9192
);
9293
$definition = $generator->getPackageDefinition($theme_name, $settings, $svg);
9394
$realpath = \Drupal::service('file_system')->realpath($definition['path']);

.github/scripts/release-check.cjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ function runStaticChecks() {
204204
ensure(extractYamlDependencyConstraint(whiskInfoStarter, 'emulsify_tools') === composer.require['drupal/emulsify_tools'], 'whisk.info.emulsify.yml must match the composer emulsify_tools constraint.');
205205
ensure(themeReadinessWorkflow.includes(`DRUPAL_VERSION: '${minCoreVersion}.*'`), 'theme-readiness.yml should smoke test the supported Drupal patch line.');
206206
ensure(themeReadinessWorkflow.includes("PHP_VERSION: '8.4'"), 'theme-readiness.yml should run readiness smoke checks on PHP 8.4.');
207+
ensure(themeReadinessWorkflow.includes('- 7.x'), 'theme-readiness.yml should run on pushes to 7.x.');
208+
ensure(themeReadinessWorkflow.includes('- release-7'), 'theme-readiness.yml should run on pushes to release-7.');
209+
ensure(!themeReadinessWorkflow.includes('- 6.x'), 'theme-readiness.yml should not keep the retired 6.x release branch trigger.');
207210
return `Root theme metadata and CI readiness checks align to Drupal ${minCoreVersion} on PHP 8.4.`;
208211
});
209212

@@ -277,13 +280,15 @@ function runSmokeChecks() {
277280
addResult('SKIP', 'Base theme render smoke', 'Skipped with --skip-smoke.');
278281
addResult('SKIP', 'Generated theme smoke test', 'Skipped with --skip-smoke.');
279282
addResult('SKIP', 'Favicon generation', 'Skipped with --skip-smoke.');
283+
addResult('SKIP', 'Favicon portability and sanitizer coverage', 'Skipped with --skip-smoke.');
280284
return;
281285
}
282286

283287
const smokeRoot = options.workDir;
284288
const baseFixture = path.join(smokeRoot, 'base-fixture');
285289
const generatedThemeFixture = path.join(smokeRoot, 'generated-theme-fixture');
286290
const faviconFixture = path.join(smokeRoot, 'favicon-fixture');
291+
const faviconPortabilityFixture = path.join(smokeRoot, 'favicon-portability-fixture');
287292
const baseThemeOutput = path.join(smokeRoot, 'base-theme-output');
288293
const generatedThemeOutput = path.join(smokeRoot, 'generated-theme-output');
289294

@@ -306,6 +311,7 @@ function runSmokeChecks() {
306311
addResult('FAIL', 'Base theme render smoke', 'Unable to build the Drupal fixture site for smoke testing.');
307312
addResult('FAIL', 'Generated theme smoke test', 'Unable to build the Drupal fixture site for smoke testing.');
308313
addResult('FAIL', 'Favicon generation', 'Unable to build the Drupal fixture site for smoke testing.');
314+
addResult('FAIL', 'Favicon portability and sanitizer coverage', 'Unable to build the Drupal fixture site for smoke testing.');
309315
return;
310316
}
311317

@@ -342,6 +348,15 @@ function runSmokeChecks() {
342348
repoRoot,
343349
{ passMessage: 'Verified export-backed favicon package generation and head attachment smoke.' },
344350
);
351+
352+
copyDirectory(baseFixture, faviconPortabilityFixture);
353+
runSmokeCheck(
354+
'Favicon portability and sanitizer coverage',
355+
'bash',
356+
[path.join(repoRoot, '.github/scripts/favicon-portability-smoke.sh'), faviconPortabilityFixture, 'emulsify'],
357+
repoRoot,
358+
{ passMessage: 'Verified portable favicon regeneration, reset, and sanitizer portability coverage.' },
359+
);
345360
}
346361

347362
function printSummary() {

0 commit comments

Comments
 (0)