From 16aa5c36d0ec213adb9698ba8cafe15df56615ac Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Mon, 5 Oct 2026 05:01:20 +0100 Subject: [PATCH] fix: reject conflicting output file and directory paths I validate the complete output layout before cleaning or writing so a file path cannot also be a directory needed by another generated file. I added a fail-first regression that preserves existing output on error. --- test/finalize-symlink.test.ts | 16 ++++++++++++++++ vendor/forge/forge.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/test/finalize-symlink.test.ts b/test/finalize-symlink.test.ts index 2159618..87d694c 100644 --- a/test/finalize-symlink.test.ts +++ b/test/finalize-symlink.test.ts @@ -106,3 +106,19 @@ test('finalize rejects file paths resolving to the output root before cleaning', } } finally { await rm(dir, { recursive: true, force: true }); } }); + +test('finalize rejects conflicting file and directory paths before cleaning', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spec2mcp-conflicting-files-')); + try { + const forge = await init(DOC as never); + const out = join(dir, 'out'); + await mkdir(out); + const existing = join(out, 'keep.txt'); + await writeFile(existing, 'keep'); + await assert.rejects(forge.finalize(out, [ + { path: 'sub', content: 'a file' }, + { path: 'sub/nested.txt', content: 'nested file' }, + ], { clean: true }), /conflicting output paths/i); + assert.equal(await readFile(existing, 'utf8'), 'keep'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/vendor/forge/forge.ts b/vendor/forge/forge.ts index bbf41de..699148b 100644 --- a/vendor/forge/forge.ts +++ b/vendor/forge/forge.ts @@ -238,6 +238,18 @@ export class Forge { resolvedFiles.set(fullPath, file.content); } + // A file cannot also be an ancestor directory of another output file. + // Reject impossible layouts before cleaning or writing any output. + for (const fullPath of resolvedFiles.keys()) { + let parent = dirname(fullPath); + while (parent !== resolvedOutputDir) { + if (resolvedFiles.has(parent)) { + throw new Error(`Conflicting output paths: "${parent}" is both a file and a directory`); + } + parent = dirname(parent); + } + } + if (options?.clean) { await rm(resolvedOutputDir, { recursive: true, force: true }); }