diff --git a/src/generator/template-generation.ts b/src/generator/template-generation.ts index ed79f15..ef11dc0 100644 --- a/src/generator/template-generation.ts +++ b/src/generator/template-generation.ts @@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, readdirSync, + realpathSync, renameSync, rmdirSync, rmSync, @@ -124,6 +125,60 @@ function copyTemplateTree( } } +function assertDestinationContainment(root: string, destination: string): void { + const destinationParent = dirname(destination); + + if (!isWithin(destinationParent, destination)) { + throw new ValidationCliError( + 'Project destination escapes its validated parent directory.', + ); + } + + if (existsSync(root)) { + try { + const realRoot = realpathSync(root); + let existing = destination; + while (!existsSync(existing)) { + const parent = dirname(existing); + if (parent === existing) break; + existing = parent; + } + + if (existsSync(existing)) { + const realExisting = realpathSync(existing); + const rel = relative(realRoot, realExisting); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new ValidationCliError( + 'Project destination escapes its validated parent directory.', + ); + } + } + } catch (error) { + if (error instanceof ValidationCliError) throw error; + } + } + + let current = destination; + while (current !== dirname(current)) { + const parent = dirname(current); + if (existsSync(current) && existsSync(parent)) { + try { + const realCurrent = realpathSync(current); + const realParent = realpathSync(parent); + const rel = relative(realParent, realCurrent); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new ValidationCliError( + 'Project destination escapes its validated parent directory.', + ); + } + } catch (error) { + if (error instanceof ValidationCliError) throw error; + } + } + current = parent; + } +} + export function generateProjectFromTemplate( context: GenerationContext, options: TemplateGenerationOptions, @@ -131,6 +186,7 @@ export function generateProjectFromTemplate( const templateRoot = resolve(options.templateRoot); const destination = resolve(context.destination.destination); const destinationParent = dirname(destination); + const root = resolve(context.input.cwd); if (!existsSync(templateRoot) || !lstatSync(templateRoot).isDirectory()) { throw new ValidationCliError('Selected template source is unavailable.'); @@ -142,11 +198,7 @@ export function generateProjectFromTemplate( ); } - if (!isWithin(destinationParent, destination)) { - throw new ValidationCliError( - 'Project destination escapes its validated parent directory.', - ); - } + assertDestinationContainment(root, destination); const destinationExisted = existsSync(destination); diff --git a/src/generator/validation.ts b/src/generator/validation.ts index a397ead..39783ee 100644 --- a/src/generator/validation.ts +++ b/src/generator/validation.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; -import { isAbsolute, relative, resolve } from 'node:path'; +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { ValidationCliError } from '../errors/errors.js'; import type { DestinationPlan, @@ -14,6 +14,7 @@ export interface DestinationFileSystem { exists(path: string): boolean; isDirectory(path: string): boolean; entries(path: string): readonly string[]; + realpath?(path: string): string; } const nodeFileSystem: DestinationFileSystem = { @@ -22,6 +23,7 @@ const nodeFileSystem: DestinationFileSystem = { return statSync(path).isDirectory(); }, entries: readdirSync, + realpath: realpathSync, }; function validateProjectName(projectName: string): string { @@ -98,6 +100,56 @@ function assertDestinationWithinRoot(root: string, destination: string): void { } } +function assertSymlinkAncestorContainment( + root: string, + destination: string, + fileSystem: DestinationFileSystem, +): void { + if (!fileSystem.realpath || !fileSystem.exists(root)) { + return; + } + + try { + const realRoot = fileSystem.realpath(root); + let existing = destination; + while (!fileSystem.exists(existing)) { + const parent = dirname(existing); + if (parent === existing) break; + existing = parent; + } + + if (fileSystem.exists(existing)) { + const realExisting = fileSystem.realpath(existing); + const rel = relative(realRoot, realExisting); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new ValidationCliError( + 'Project destination must remain inside the current working directory.', + ); + } + } + + let current = destination; + while (current !== dirname(current)) { + const parent = dirname(current); + if (fileSystem.exists(current) && fileSystem.exists(parent)) { + const realCurrent = fileSystem.realpath(current); + const realParent = fileSystem.realpath(parent); + const rel = relative(realParent, realCurrent); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new ValidationCliError( + 'Project destination must remain inside the current working directory.', + ); + } + } + current = parent; + } + } catch (error) { + if (error instanceof ValidationCliError) { + throw error; + } + } +} + export function planProjectDestination( input: ValidatedProjectInput, fileSystem: DestinationFileSystem = nodeFileSystem, @@ -106,6 +158,7 @@ export function planProjectDestination( const destination = resolve(root, input.projectName); assertDestinationWithinRoot(root, destination); + assertSymlinkAncestorContainment(root, destination, fileSystem); if (fileSystem.exists(destination)) { if (!fileSystem.isDirectory(destination)) { diff --git a/tests/generator-symlink-ancestor.test.ts b/tests/generator-symlink-ancestor.test.ts new file mode 100644 index 0000000..6609691 --- /dev/null +++ b/tests/generator-symlink-ancestor.test.ts @@ -0,0 +1,323 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { orchestrateProjectGeneration } from '../src/generator/orchestrator.js'; +import { generateProjectFromTemplate } from '../src/generator/template-generation.js'; +import type { + GenerationContext, + ProjectGeneratorInput, +} from '../src/generator/types.js'; +import { + planProjectDestination, + validateProjectInput, +} from '../src/generator/validation.js'; +import { + createTempDirectoryFixture, + withTempDirectory, +} from './helpers/temp-directory.js'; + +function canCreateSymlinks(): boolean { + const fixture = createTempDirectoryFixture(); + try { + const target = join(fixture.path, 'probe-target'); + const link = join(fixture.path, 'probe-link'); + mkdirSync(target); + symlinkSync(target, link, 'junction'); + return true; + } catch { + return false; + } finally { + fixture.cleanup(); + } +} + +const symlinksSupported = canCreateSymlinks(); + +function createMinimalTemplate(root: string): string { + const templateRoot = join(root, 'template'); + mkdirSync(join(templateRoot, 'src'), { recursive: true }); + writeFileSync( + join(templateRoot, 'package.json'), + '{"name":"{{projectName}}","template":"{{templateId}}"}\n', + ); + writeFileSync( + join(templateRoot, 'src', 'index.ts'), + 'export const app = "{{projectName}}";\n', + ); + return templateRoot; +} + +function createContext( + cwd: string, + destination: string, + projectName = 'demo-app', +): GenerationContext { + return { + input: { + projectName, + cwd, + templateId: 'basic-app', + }, + destination: { + projectName, + destination, + }, + template: { + templateId: 'basic-app', + }, + }; +} + +function stagingLeftovers(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((name: string) => + name.startsWith('.stellarforge-'), + ); +} + +describe('generator symlink-ancestor containment (SW-032)', () => { + it.skipIf(!symlinksSupported)( + 'rejects template generation when destination parent is a symlink pointing outside root before writes', + () => + withTempDirectory((root) => { + const outsideFixture = createTempDirectoryFixture(); + try { + const templateRoot = createMinimalTemplate(root); + const outside = outsideFixture.path; + const preExistingFile = join(outside, 'canary.txt'); + writeFileSync(preExistingFile, 'untouched-canary-content'); + + const escapeLink = join(root, 'escape-link'); + symlinkSync(outside, escapeLink, 'junction'); + + const destination = join(escapeLink, 'demo-app'); + const context = createContext(root, destination); + + expect(() => + generateProjectFromTemplate(context, { templateRoot }), + ).toThrow(ValidationCliError); + + // Acceptance criterion: rejected before writes + expect(existsSync(join(outside, 'demo-app'))).toBe(false); + expect(readFileSync(preExistingFile, 'utf8')).toBe( + 'untouched-canary-content', + ); + expect(stagingLeftovers(root)).toEqual([]); + expect(stagingLeftovers(outside)).toEqual([]); + } finally { + outsideFixture.cleanup(); + } + }), + ); + + it.skipIf(!symlinksSupported)( + 'rejects destination planning when destination parent is a symlink escaping root', + () => + withTempDirectory((root) => { + const outsideFixture = createTempDirectoryFixture(); + try { + const escapeLink = join(root, 'escape-link'); + symlinkSync(outsideFixture.path, escapeLink, 'junction'); + + // Planning from within the symlink directory escaping root + const input = validateProjectInput({ + cwd: escapeLink, + projectName: 'demo-app', + }); + + expect(() => planProjectDestination(input)).toThrow( + ValidationCliError, + ); + } finally { + outsideFixture.cleanup(); + } + }), + ); + + it.skipIf(!symlinksSupported)( + 'rejects template generation when deeply nested ancestor is an escaping symlink', + () => + withTempDirectory((root) => { + const outsideFixture = createTempDirectoryFixture(); + try { + const templateRoot = createMinimalTemplate(root); + const outside = outsideFixture.path; + mkdirSync(join(outside, 'nested', 'child'), { recursive: true }); + + const escapeLink = join(root, 'escape-link'); + symlinkSync(outside, escapeLink, 'junction'); + + const destination = join(escapeLink, 'nested', 'child', 'demo-app'); + const context = createContext(root, destination); + + expect(() => + generateProjectFromTemplate(context, { templateRoot }), + ).toThrow(ValidationCliError); + + expect(existsSync(join(outside, 'nested', 'child', 'demo-app'))).toBe( + false, + ); + expect(stagingLeftovers(root)).toEqual([]); + expect(stagingLeftovers(outside)).toEqual([]); + } finally { + outsideFixture.cleanup(); + } + }), + ); + + it.skipIf(!symlinksSupported)( + 'rejects template generation when empty destination already exists inside an escaping symlink ancestor', + () => + withTempDirectory((root) => { + const outsideFixture = createTempDirectoryFixture(); + try { + const templateRoot = createMinimalTemplate(root); + const outside = outsideFixture.path; + const preExistingEmpty = join(outside, 'demo-app'); + mkdirSync(preExistingEmpty); + + const escapeLink = join(root, 'escape-link'); + symlinkSync(outside, escapeLink, 'junction'); + + const destination = join(escapeLink, 'demo-app'); + const context = createContext(root, destination); + + expect(() => + generateProjectFromTemplate(context, { templateRoot }), + ).toThrow(ValidationCliError); + + // Acceptance criterion: pre-existing directory is not touched / written into + expect(readdirSync(preExistingEmpty)).toEqual([]); + expect(stagingLeftovers(root)).toEqual([]); + expect(stagingLeftovers(outside)).toEqual([]); + } finally { + outsideFixture.cleanup(); + } + }), + ); + + it.skipIf(!symlinksSupported)( + 'allows template generation when destination ancestor is a contained symlink inside root', + () => + withTempDirectory((root) => { + const templateRoot = createMinimalTemplate(root); + const containedTarget = join(root, 'contained-target'); + mkdirSync(containedTarget); + + const containedLink = join(root, 'contained-link'); + symlinkSync(containedTarget, containedLink, 'junction'); + + const destination = join(containedLink, 'demo-app'); + const context = createContext(root, destination); + + // Acceptance criterion: valid contained destinations still work + const result = generateProjectFromTemplate(context, { templateRoot }); + + expect(result.destination).toBe(destination); + expect(existsSync(join(destination, 'package.json'))).toBe(true); + expect( + existsSync(join(containedTarget, 'demo-app', 'package.json')), + ).toBe(true); + expect( + readFileSync(join(destination, 'package.json'), 'utf8'), + ).toContain('"name":"demo-app"'); + expect(stagingLeftovers(root)).toEqual([]); + expect(stagingLeftovers(containedTarget)).toEqual([]); + }), + ); + + it.skipIf(!symlinksSupported)( + 'allows destination planning when destination ancestor is a contained symlink inside root', + () => + withTempDirectory((root) => { + const containedTarget = join(root, 'contained-target'); + mkdirSync(containedTarget); + + const containedLink = join(root, 'contained-link'); + symlinkSync(containedTarget, containedLink, 'junction'); + + const input = validateProjectInput({ + cwd: containedLink, + projectName: 'demo-app', + }); + + const plan = planProjectDestination(input); + expect(plan.destination).toBe(join(containedLink, 'demo-app')); + }), + ); + + it.skipIf(!symlinksSupported)( + 'allows template generation when pre-existing empty destination is inside a contained symlink', + () => + withTempDirectory((root) => { + const templateRoot = createMinimalTemplate(root); + const containedTarget = join(root, 'contained-target'); + mkdirSync(join(containedTarget, 'demo-app'), { recursive: true }); + + const containedLink = join(root, 'contained-link'); + symlinkSync(containedTarget, containedLink, 'junction'); + + const destination = join(containedLink, 'demo-app'); + const context = createContext(root, destination); + + const result = generateProjectFromTemplate(context, { templateRoot }); + + expect(result.destination).toBe(destination); + expect( + readFileSync( + join(containedTarget, 'demo-app', 'package.json'), + 'utf8', + ), + ).toContain('"name":"demo-app"'); + expect(stagingLeftovers(root)).toEqual([]); + }), + ); + + it.skipIf(!symlinksSupported)( + 'rejects full generator orchestration when symlink ancestor escape is attempted', + () => + withTempDirectory((root) => { + const outsideFixture = createTempDirectoryFixture(); + try { + const templateRoot = createMinimalTemplate(root); + const outside = outsideFixture.path; + const canary = join(outside, 'canary.txt'); + writeFileSync(canary, 'protected-canary'); + + const escapeLink = join(root, 'escape-link'); + symlinkSync(outside, escapeLink, 'junction'); + + const input: ProjectGeneratorInput = { + projectName: 'demo-app', + cwd: escapeLink, + templateId: 'basic-app', + }; + + expect(() => + orchestrateProjectGeneration(input, { + validate: validateProjectInput, + planDestination: planProjectDestination, + selectTemplate: () => ({ templateId: 'basic-app' }), + generate: (context) => + generateProjectFromTemplate(context, { templateRoot }), + }), + ).toThrow(ValidationCliError); + + expect(existsSync(join(outside, 'demo-app'))).toBe(false); + expect(readFileSync(canary, 'utf8')).toBe('protected-canary'); + expect(stagingLeftovers(root)).toEqual([]); + expect(stagingLeftovers(outside)).toEqual([]); + } finally { + outsideFixture.cleanup(); + } + }), + ); +});