Skip to content

Commit a92e488

Browse files
author
Callin Mullaney
committed
fix: harden child theme machine name validation
1 parent 11585b5 commit a92e488

2 files changed

Lines changed: 307 additions & 0 deletions

File tree

src/Drush/Commands/SubThemeCommands.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ final class SubThemeCommands extends DrushCommands {
2323

2424
use AutowireTrait;
2525

26+
/**
27+
* The Emulsify base theme machine name.
28+
*/
29+
private const EMULSIFY_THEME = 'emulsify';
30+
2631
/**
2732
* Creates the command.
2833
*/
@@ -45,6 +50,8 @@ public function __construct(
4550
#[CLI\Usage(name: 'emulsify_tools:bake MyThemeName')]
4651
public function generateSubTheme(string $name): int {
4752
$machineName = $this->convertLabelToMachineName($name);
53+
$this->logResolvedMachineName($name, $machineName);
54+
4855
$sourceDirectory = $this->getStarterRecipeDirectory();
4956
$destinationDirectory = "themes/custom/{$machineName}";
5057
$state = ['srcDir' => $sourceDirectory];
@@ -148,9 +155,62 @@ private function convertLabelToMachineName(string $label): string {
148155
throw new \InvalidArgumentException('Theme name must contain at least one alphanumeric character.');
149156
}
150157

158+
$this->validateMachineName($machineName, $label);
159+
151160
return $machineName;
152161
}
153162

163+
/**
164+
* Validates a Drupal theme machine name.
165+
*
166+
* @throws \InvalidArgumentException
167+
* Thrown when the machine name cannot be used for a child theme.
168+
*/
169+
private function validateMachineName(string $machineName, string $label): void {
170+
if (preg_match('/^[a-z]/', $machineName) !== 1) {
171+
throw new \InvalidArgumentException(sprintf(
172+
'Theme machine name "%s" derived from "%s" must start with a lowercase letter. Start the theme name with a letter, for example "my_theme".',
173+
$machineName,
174+
$label,
175+
));
176+
}
177+
178+
if (strlen($machineName) > \DRUPAL_EXTENSION_NAME_MAX_LENGTH) {
179+
throw new \InvalidArgumentException(sprintf(
180+
'Theme machine name "%s" is %d characters long, but Drupal theme machine names must be %d characters or fewer. Choose a shorter name.',
181+
$machineName,
182+
strlen($machineName),
183+
\DRUPAL_EXTENSION_NAME_MAX_LENGTH,
184+
));
185+
}
186+
187+
if ($machineName === self::EMULSIFY_THEME) {
188+
throw new \InvalidArgumentException('Theme machine name "emulsify" is reserved by the Emulsify base theme. Choose a unique child theme name.');
189+
}
190+
191+
if (isset($this->themeExtensionList->getList()[$machineName])) {
192+
throw new \InvalidArgumentException(sprintf(
193+
'Theme machine name "%s" is already used by an existing Drupal theme. Choose a unique child theme name.',
194+
$machineName,
195+
));
196+
}
197+
}
198+
199+
/**
200+
* Logs when a human-readable label resolves to a different machine name.
201+
*/
202+
private function logResolvedMachineName(string $name, string $machineName): void {
203+
if ($machineName === trim($name)) {
204+
return;
205+
}
206+
207+
$this->logger()->notice(sprintf(
208+
'Using "%s" as the Drupal theme machine name for "%s".',
209+
$machineName,
210+
$name,
211+
));
212+
}
213+
154214
/**
155215
* Resolves the Emulsify starter recipe directory.
156216
*
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drupal\Tests\emulsify_tools\Unit;
6+
7+
use Drupal\Core\Extension\ThemeExtensionList;
8+
use Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor;
9+
use Drupal\emulsify_tools\Drush\Commands\SubThemeCommands;
10+
use Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer;
11+
use Drupal\emulsify_tools\SubThemeGenerator;
12+
use Drupal\Tests\UnitTestCase;
13+
use PHPUnit\Framework\Attributes\CoversClass;
14+
use PHPUnit\Framework\Attributes\Group;
15+
use Psr\Log\AbstractLogger;
16+
use Psr\Log\LogLevel;
17+
use Symfony\Component\Filesystem\Filesystem;
18+
19+
/**
20+
* Tests child theme Drush command validation.
21+
*/
22+
#[CoversClass(SubThemeCommands::class)]
23+
#[Group('emulsify_tools')]
24+
final class SubThemeCommandsTest extends UnitTestCase {
25+
26+
/**
27+
* Filesystem helper.
28+
*/
29+
private Filesystem $filesystem;
30+
31+
/**
32+
* Temporary fixture directory.
33+
*/
34+
private string $temporaryDirectory;
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
protected function setUp(): void {
40+
parent::setUp();
41+
42+
$this->filesystem = new Filesystem();
43+
$this->temporaryDirectory = sys_get_temp_dir() . '/emulsify_tools_command_' . bin2hex(random_bytes(8));
44+
$this->filesystem->mkdir($this->temporaryDirectory);
45+
}
46+
47+
/**
48+
* {@inheritdoc}
49+
*/
50+
protected function tearDown(): void {
51+
if (isset($this->temporaryDirectory) && $this->filesystem->exists($this->temporaryDirectory)) {
52+
$this->filesystem->remove($this->temporaryDirectory);
53+
}
54+
55+
parent::tearDown();
56+
}
57+
58+
/**
59+
* Tests leading-digit names are rejected.
60+
*/
61+
public function testGenerateSubThemeRejectsLeadingDigitMachineName(): void {
62+
$command = $this->createCommand();
63+
64+
$this->expectException(\InvalidArgumentException::class);
65+
$this->expectExceptionMessage('must start with a lowercase letter');
66+
$command->generateSubTheme('123 Theme');
67+
}
68+
69+
/**
70+
* Tests names over Drupal's extension-name length limit are rejected.
71+
*/
72+
public function testGenerateSubThemeRejectsTooLongMachineName(): void {
73+
$command = $this->createCommand();
74+
75+
$this->expectException(\InvalidArgumentException::class);
76+
$this->expectExceptionMessage(sprintf(
77+
'must be %d characters or fewer',
78+
\DRUPAL_EXTENSION_NAME_MAX_LENGTH,
79+
));
80+
$command->generateSubTheme(str_repeat('a', \DRUPAL_EXTENSION_NAME_MAX_LENGTH + 1));
81+
}
82+
83+
/**
84+
* Tests the Emulsify base theme machine name is reserved.
85+
*/
86+
public function testGenerateSubThemeRejectsEmulsifyBaseThemeName(): void {
87+
$command = $this->createCommand();
88+
89+
$this->expectException(\InvalidArgumentException::class);
90+
$this->expectExceptionMessage('reserved by the Emulsify base theme');
91+
$command->generateSubTheme('emulsify');
92+
}
93+
94+
/**
95+
* Tests names that collide with existing themes are rejected.
96+
*/
97+
public function testGenerateSubThemeRejectsExistingThemeName(): void {
98+
$command = $this->createCommand(['stark']);
99+
100+
$this->expectException(\InvalidArgumentException::class);
101+
$this->expectExceptionMessage('already used by an existing Drupal theme');
102+
$command->generateSubTheme('Stark');
103+
}
104+
105+
/**
106+
* Tests a valid label still generates a child theme.
107+
*/
108+
public function testGenerateSubThemeAcceptsValidLabelAndLogsMachineName(): void {
109+
$emulsifyPath = $this->temporaryDirectory . '/themes/contrib/emulsify';
110+
$this->writeStarterRecipe($emulsifyPath . '/whisk');
111+
$this->filesystem->mkdir($this->temporaryDirectory . '/themes/custom');
112+
113+
$logger = new SubThemeCommandRecordingLogger();
114+
$command = $this->createCommand(['emulsify', 'stark'], $emulsifyPath, $logger);
115+
116+
$workingDirectory = getcwd();
117+
if ($workingDirectory === FALSE) {
118+
throw new \RuntimeException('Unable to determine the current working directory.');
119+
}
120+
121+
chdir($this->temporaryDirectory);
122+
try {
123+
self::assertSame(0, $command->generateSubTheme('Happy Theme'));
124+
}
125+
finally {
126+
chdir($workingDirectory);
127+
}
128+
129+
$generatedInfoFile = $this->temporaryDirectory . '/themes/custom/happy_theme/happy_theme.info.yml';
130+
self::assertFileExists($generatedInfoFile);
131+
self::assertSame("name: Happy Theme\n", $this->readFile($generatedInfoFile));
132+
self::assertTrue($logger->hasNoticeContaining('Using "happy_theme"', 'Happy Theme'));
133+
}
134+
135+
/**
136+
* Creates the command under test.
137+
*
138+
* @param string[] $existingThemes
139+
* Existing theme machine names.
140+
* @param string|null $emulsifyPath
141+
* Drupal-root-relative or absolute Emulsify theme path.
142+
* @param \Drupal\Tests\emulsify_tools\Unit\SubThemeCommandRecordingLogger|null $logger
143+
* Optional command logger.
144+
*/
145+
private function createCommand(
146+
array $existingThemes = [],
147+
?string $emulsifyPath = NULL,
148+
?SubThemeCommandRecordingLogger $logger = NULL,
149+
): SubThemeCommands {
150+
$themeExtensionList = $this->createMock(ThemeExtensionList::class);
151+
$themeExtensionList->method('getList')->willReturn(array_fill_keys($existingThemes, (object) []));
152+
$themeExtensionList->method('getPath')
153+
->willReturnCallback(static fn (string $themeName): string => $themeName === 'emulsify' ? (string) $emulsifyPath : '');
154+
155+
$command = new SubThemeCommands(
156+
$themeExtensionList,
157+
new StarterRecipeArchiveExtractor($this->filesystem),
158+
new SubThemeGenerator($this->filesystem),
159+
$this->filesystem,
160+
new ChildThemeFaviconConfigRepairer($this->temporaryDirectory, $themeExtensionList, $this->filesystem),
161+
);
162+
if ($logger !== NULL) {
163+
$command->setLogger($logger);
164+
}
165+
166+
return $command;
167+
}
168+
169+
/**
170+
* Writes a minimal Whisk starter recipe.
171+
*/
172+
private function writeStarterRecipe(string $directory): void {
173+
$this->filesystem->mkdir($directory);
174+
$this->writeFile($directory . '/whisk.info.emulsify.yml', "hidden: false\n");
175+
$this->writeFile($directory . '/whisk.info.yml', "name: EMULSIFY_NAME\n");
176+
}
177+
178+
/**
179+
* Writes a test fixture file.
180+
*/
181+
private function writeFile(string $path, string $contents): void {
182+
$result = file_put_contents($path, $contents);
183+
if ($result === FALSE) {
184+
throw new \RuntimeException(sprintf('Failed to write fixture file "%s".', $path));
185+
}
186+
}
187+
188+
/**
189+
* Reads a generated file.
190+
*/
191+
private function readFile(string $path): string {
192+
$contents = file_get_contents($path);
193+
if ($contents === FALSE) {
194+
throw new \RuntimeException(sprintf('Failed to read fixture file "%s".', $path));
195+
}
196+
197+
return $contents;
198+
}
199+
200+
}
201+
202+
/**
203+
* Records command log messages.
204+
*/
205+
final class SubThemeCommandRecordingLogger extends AbstractLogger {
206+
207+
/**
208+
* Recorded log entries.
209+
*
210+
* @var list<array{level: mixed, message: string}>
211+
*/
212+
private array $records = [];
213+
214+
/**
215+
* {@inheritdoc}
216+
*/
217+
public function log($level, \Stringable|string $message, array $context = []): void {
218+
$message = strtr((string) $message, array_map(
219+
static fn (mixed $value): string => (string) $value,
220+
$context,
221+
));
222+
$this->records[] = [
223+
'level' => $level,
224+
'message' => $message,
225+
];
226+
}
227+
228+
/**
229+
* Returns whether a notice contains all provided fragments.
230+
*/
231+
public function hasNoticeContaining(string ...$fragments): bool {
232+
foreach ($this->records as $record) {
233+
if ($record['level'] !== LogLevel::NOTICE) {
234+
continue;
235+
}
236+
foreach ($fragments as $fragment) {
237+
if (!str_contains($record['message'], $fragment)) {
238+
continue 2;
239+
}
240+
}
241+
return TRUE;
242+
}
243+
244+
return FALSE;
245+
}
246+
247+
}

0 commit comments

Comments
 (0)