Skip to content

Commit 8acd95a

Browse files
authored
Merge pull request #1281 from CybotTM/fix/plantuml-renderer-robustness
[BUGFIX] Improve PlantumlRenderer robustness and temp file cleanup
2 parents 5c1b721 + db41968 commit 8acd95a

File tree

2 files changed

+109
-11
lines changed

2 files changed

+109
-11
lines changed

packages/guides-graphs/src/Graphs/Renderer/PlantumlRenderer.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use function mkdir;
2626
use function sys_get_temp_dir;
2727
use function tempnam;
28+
use function unlink;
2829

2930
final class PlantumlRenderer implements DiagramRenderer
3031
{
@@ -53,11 +54,25 @@ public function render(RenderContext $renderContext, string $diagram): string|nu
5354
@enduml
5455
PUML;
5556

56-
if (!is_dir($this->tempDirectory)) {
57-
mkdir($this->tempDirectory, 0o755, true);
57+
if (!$this->ensureDirectoryExists($this->tempDirectory)) {
58+
$this->logger->error(
59+
'Failed to create temp directory: ' . $this->tempDirectory,
60+
$renderContext->getLoggerInformation(),
61+
);
62+
63+
return null;
5864
}
5965

6066
$pumlFileLocation = tempnam($this->tempDirectory, 'pu_');
67+
if ($pumlFileLocation === false) {
68+
$this->logger->error(
69+
'Failed to create temporary file for diagram',
70+
$renderContext->getLoggerInformation(),
71+
);
72+
73+
return null;
74+
}
75+
6176
file_put_contents($pumlFileLocation, $output);
6277
try {
6378
$process = new Process([$this->plantUmlBinaryPath, '-tsvg', $pumlFileLocation], __DIR__, null, null, 600.0);
@@ -86,6 +101,31 @@ public function render(RenderContext $renderContext, string $diagram): string|nu
86101
return null;
87102
}
88103

89-
return file_get_contents($pumlFileLocation . '.svg') ?: null;
104+
$svg = file_get_contents($pumlFileLocation . '.svg') ?: null;
105+
106+
@unlink($pumlFileLocation);
107+
@unlink($pumlFileLocation . '.svg');
108+
109+
return $svg;
110+
}
111+
112+
/**
113+
* Ensures the directory exists, handling race conditions safely.
114+
*
115+
* @return bool True if directory exists or was created, false on failure
116+
*/
117+
private function ensureDirectoryExists(string $directory): bool
118+
{
119+
if (is_dir($directory)) {
120+
return true;
121+
}
122+
123+
// Attempt to create the directory (suppress warning if concurrent process creates it)
124+
if (@mkdir($directory, 0o755, true)) {
125+
return true;
126+
}
127+
128+
// mkdir failed - check if another process created it (race condition)
129+
return is_dir($directory);
90130
}
91131
}

packages/guides-graphs/tests/unit/Renderer/PlantumlRendererTest.php

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,35 +13,93 @@
1313

1414
namespace phpDocumentor\Guides\Graphs\Renderer;
1515

16+
use FilesystemIterator;
1617
use phpDocumentor\Guides\RenderContext;
1718
use PHPUnit\Framework\TestCase;
1819
use Psr\Log\NullLogger;
20+
use RecursiveDirectoryIterator;
21+
use RecursiveIteratorIterator;
22+
use SplFileInfo;
23+
use Throwable;
1924

25+
use function assert;
26+
use function basename;
27+
use function is_dir;
28+
use function realpath;
2029
use function rmdir;
2130
use function sys_get_temp_dir;
2231
use function uniqid;
32+
use function unlink;
33+
34+
use const DIRECTORY_SEPARATOR;
2335

2436
final class PlantumlRendererTest extends TestCase
2537
{
38+
private const TEMP_DIR_PREFIX = 'plantuml-test-';
39+
2640
public function testRenderCreatesTempDirectoryWhenMissing(): void
2741
{
28-
$tempDir = sys_get_temp_dir() . '/plantuml-test-' . uniqid();
42+
$tempDir = sys_get_temp_dir() . '/' . self::TEMP_DIR_PREFIX . uniqid('', true);
2943

30-
// Ensure the directory does not exist
3144
self::assertDirectoryDoesNotExist($tempDir);
3245

33-
// Use a non-existent binary path - the render will fail but directory should be created first
3446
$renderer = new PlantumlRenderer(new NullLogger(), '/non/existent/plantuml', $tempDir);
3547

3648
$renderContext = $this->createMock(RenderContext::class);
3749
$renderContext->method('getLoggerInformation')->willReturn([]);
3850

39-
// The render will fail due to missing binary, but the temp directory should be created
40-
$renderer->render($renderContext, 'A -> B');
51+
try {
52+
// Render will fail (returns null) or throw due to non-existent plantuml binary.
53+
// We only care about verifying that the temp directory was created.
54+
$renderer->render($renderContext, 'A -> B');
55+
} catch (Throwable) {
56+
// Expected: plantuml binary doesn't exist
57+
}
58+
59+
self::assertDirectoryExists($tempDir, 'Temp directory should have been created');
60+
61+
$this->removeTempDirSafely($tempDir);
62+
}
63+
64+
private function removeTempDirSafely(string $dir): void
65+
{
66+
if ($dir === '' || !is_dir($dir)) {
67+
return;
68+
}
69+
70+
$realDir = realpath($dir);
71+
if ($realDir === false) {
72+
return;
73+
}
74+
75+
$realSysTmp = realpath(sys_get_temp_dir());
76+
if ($realSysTmp === false) {
77+
return;
78+
}
79+
80+
// Safety: must be under system temp and have our prefix
81+
self::assertStringStartsWith(
82+
$realSysTmp . DIRECTORY_SEPARATOR,
83+
$realDir . DIRECTORY_SEPARATOR,
84+
'Refusing to delete directory outside system temp',
85+
);
86+
self::assertStringContainsString(
87+
self::TEMP_DIR_PREFIX,
88+
basename($realDir),
89+
'Refusing to delete directory without expected prefix',
90+
);
91+
92+
/** @var RecursiveIteratorIterator<RecursiveDirectoryIterator> $iterator */
93+
$iterator = new RecursiveIteratorIterator(
94+
new RecursiveDirectoryIterator($realDir, FilesystemIterator::SKIP_DOTS),
95+
RecursiveIteratorIterator::CHILD_FIRST,
96+
);
4197

42-
self::assertDirectoryExists($tempDir);
98+
foreach ($iterator as $file) {
99+
assert($file instanceof SplFileInfo);
100+
$file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname());
101+
}
43102

44-
// Clean up
45-
@rmdir($tempDir);
103+
@rmdir($realDir);
46104
}
47105
}

0 commit comments

Comments
 (0)