diff --git a/docs/content.md b/docs/content.md
index 89320ae..d6b7058 100644
--- a/docs/content.md
+++ b/docs/content.md
@@ -114,7 +114,7 @@ extra:
- **authors** — list of author slugs (referencing files in `content/authors/`)
- **image** — featured image URL (absolute, or root-relative path resolved against `base_url`); used as `og:image` for social sharing. Falls back to the site-level `image` in `config.yaml`
- **summary** — manual excerpt; if omitted, auto-generated from content
-- **permalink** — per-entry URL override; takes precedence over collection pattern
+- **permalink** — per-entry URL override; takes precedence over collection pattern. Must start with `/` and must not contain `.` or `..` path segments
- **layout** — template layout name (default: collection-specific or `entry`)
- **theme** — theme name for this entry; overrides the site-level default (see [Templates](templates.md))
- **weight** — integer for custom sorting in non-blog collections (lower = first)
diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php
index 4e1e959..8f66014 100644
--- a/src/Console/BuildCommand.php
+++ b/src/Console/BuildCommand.php
@@ -397,6 +397,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$fileToPermalink[$relativePath] = PermalinkResolver::applyLanguagePrefix($basePermalink, $page->language, $siteConfig->i18n);
}
+ $duplicatePermalinks = $this->duplicatePermalinks($fileToPermalink);
+ if ($duplicatePermalinks !== []) {
+ foreach ($duplicatePermalinks as $permalink => $sources) {
+ $output->writeln(sprintf(
+ 'Duplicate permalink "%s" is used by: %s',
+ OutputFormatter::escape($permalink),
+ OutputFormatter::escape(implode(', ', $sources)),
+ ));
+ }
+
+ $this->writeProfile($output, $profile);
+ return ExitCode::DATAERR;
+ }
+
$crossRefResolver = new CrossReferenceResolver($fileToPermalink);
$profile->switchTo('diagnostics');
@@ -441,7 +455,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$sourcePath = $entry->filePath;
$relativePath = substr($sourcePath, strlen($contentDir) + 1);
$permalink = $fileToPermalink[$relativePath];
- $filePath = $outputDir . $permalink . 'index.html';
+ try {
+ $filePath = $this->outputFilePath($outputDir, $permalink);
+ } catch (RuntimeException $e) {
+ $output->writeln('' . OutputFormatter::escape($e->getMessage()) . '');
+ $this->writeProfile($output, $profile);
+ return ExitCode::DATAERR;
+ }
if ($entry->redirectTo !== '') {
$redirectTasks[] = ['entry' => $entry, 'filePath' => $filePath, 'permalink' => $permalink, 'sourcePath' => $sourcePath];
@@ -558,7 +578,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$sourcePath = $page->filePath;
$basePermalink = $page->permalink !== '' ? $page->permalink : '/' . $page->slug . '/';
$permalink = PermalinkResolver::applyLanguagePrefix($basePermalink, $page->language, $siteConfig->i18n);
- $filePath = $outputDir . $permalink . 'index.html';
+ try {
+ $filePath = $this->outputFilePath($outputDir, $permalink);
+ } catch (RuntimeException $e) {
+ $output->writeln('' . OutputFormatter::escape($e->getMessage()) . '');
+ $this->writeProfile($output, $profile);
+ return ExitCode::DATAERR;
+ }
if ($changedSet !== null && !isset($changedSet[$sourcePath])) {
$manifest?->record($sourcePath, [$filePath]);
@@ -1216,6 +1242,48 @@ private function prepareOutputDir(string $outputDir): void
}
}
+ /**
+ * @param array $fileToPermalink
+ * @return array>
+ */
+ private function duplicatePermalinks(array $fileToPermalink): array
+ {
+ $sourcesByPermalink = [];
+ foreach ($fileToPermalink as $source => $permalink) {
+ $sourcesByPermalink[$permalink][] = $source;
+ }
+
+ return array_filter(
+ $sourcesByPermalink,
+ static fn (array $sources): bool => count($sources) > 1,
+ );
+ }
+
+ private function outputFilePath(string $outputDir, string $permalink): string
+ {
+ return $outputDir . '/' . $this->permalinkOutputPath($permalink) . 'index.html';
+ }
+
+ private function permalinkOutputPath(string $permalink): string
+ {
+ if (!str_starts_with($permalink, '/')) {
+ throw new RuntimeException(sprintf('Permalink "%s" must start with "/".', $permalink));
+ }
+
+ if ($permalink === '/') {
+ return '';
+ }
+
+ $segments = explode('/', trim($permalink, '/'));
+ foreach ($segments as $segment) {
+ if ($segment === '' || $segment === '.' || $segment === '..') {
+ throw new RuntimeException(sprintf('Permalink "%s" contains an unsafe path segment.', $permalink));
+ }
+ }
+
+ return implode('/', $segments) . '/';
+ }
+
private function resolvePath(string $path, string $rootPath): string
{
if (str_starts_with($path, '/')) {
diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php
index 6670f79..4f65503 100644
--- a/tests/Unit/Console/BuildCommandTest.php
+++ b/tests/Unit/Console/BuildCommandTest.php
@@ -177,6 +177,67 @@ public function testBuildReportsInvalidSiteConfigWithoutTrace(): void
assertStringNotContainsString('#0 ', $outputText);
}
+ public function testBuildFailsForDuplicatePermalinks(): void
+ {
+ $tempDir = sys_get_temp_dir() . '/yiipress-build-duplicate-permalink-test-' . uniqid();
+ $contentDir = $tempDir . '/content';
+ $outputDir = $tempDir . '/output';
+ mkdir($contentDir . '/blog', 0o755, true);
+ $this->tempContentDirs[] = $tempDir;
+
+ file_put_contents($contentDir . '/config.yaml', "title: Duplicate Site\nlanguages: [en]\n");
+ file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n");
+ file_put_contents($contentDir . '/blog/first.md', "---\ntitle: First\npermalink: /same/\n---\n\nFirst.\n");
+ file_put_contents($contentDir . '/blog/second.md', "---\ntitle: Second\npermalink: /same/\n---\n\nSecond.\n");
+
+ $yii = dirname(__DIR__, 3) . '/yii';
+ exec(
+ $yii . ' build'
+ . ' --content-dir=' . escapeshellarg($contentDir)
+ . ' --output-dir=' . escapeshellarg($outputDir)
+ . ' --no-cache'
+ . ' 2>&1',
+ $output,
+ $exitCode,
+ );
+
+ $outputText = implode("\n", $output);
+
+ assertSame(ExitCode::DATAERR, $exitCode, $outputText);
+ assertStringContainsString('Duplicate permalink "/same/"', $outputText);
+ assertFalse(is_file($outputDir . '/same/index.html'));
+ }
+
+ public function testBuildFailsForUnsafePermalinkPathSegments(): void
+ {
+ $tempDir = sys_get_temp_dir() . '/yiipress-build-unsafe-permalink-test-' . uniqid();
+ $contentDir = $tempDir . '/content';
+ $outputDir = $tempDir . '/output';
+ mkdir($contentDir . '/blog', 0o755, true);
+ $this->tempContentDirs[] = $tempDir;
+
+ file_put_contents($contentDir . '/config.yaml', "title: Unsafe Site\nlanguages: [en]\n");
+ file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n");
+ file_put_contents($contentDir . '/blog/post.md', "---\ntitle: Escape\npermalink: /../../outside/\n---\n\nEscape.\n");
+
+ $yii = dirname(__DIR__, 3) . '/yii';
+ exec(
+ $yii . ' build'
+ . ' --content-dir=' . escapeshellarg($contentDir)
+ . ' --output-dir=' . escapeshellarg($outputDir)
+ . ' --no-cache'
+ . ' 2>&1',
+ $output,
+ $exitCode,
+ );
+
+ $outputText = implode("\n", $output);
+
+ assertSame(ExitCode::DATAERR, $exitCode, $outputText);
+ assertStringContainsString('contains an unsafe path segment', $outputText);
+ assertFalse(is_file($tempDir . '/outside/index.html'));
+ }
+
public function testBuildOutputContainsRenderedHtml(): void
{
$yii = dirname(__DIR__, 3) . '/yii';