From 96ce1e600a1aa827a0355a1d39640cb87d65d44f Mon Sep 17 00:00:00 2001 From: Kuber Sethi Date: Fri, 21 Aug 2026 11:45:33 -0400 Subject: [PATCH] protect oac generated output --- .../src/oac/handleOacGenerate.test.ts | 48 ++++++++++++++- .../src/oac/handleOacGenerate.ts | 59 +++++++++++++++++-- .../cli.cmd.typescript/src/oac/oacGenerate.ts | 5 ++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/cli.cmd.typescript/src/oac/handleOacGenerate.test.ts b/packages/cli.cmd.typescript/src/oac/handleOacGenerate.test.ts index 465f039053..2c1dfa24c1 100644 --- a/packages/cli.cmd.typescript/src/oac/handleOacGenerate.test.ts +++ b/packages/cli.cmd.typescript/src/oac/handleOacGenerate.test.ts @@ -136,7 +136,7 @@ describe(handleOacGenerate, () => { }); }); - it("rejects an existing output directory", async () => { + it("rejects an existing output directory without clean", async () => { const dir = await makeTempDir(); const irPath = join(dir, "ir.json"); const outDir = join(dir, "out"); @@ -152,7 +152,51 @@ describe(handleOacGenerate, () => { packageType: "module", ontologyIdentity: "portable", }), - ).rejects.toThrow("Output directory already exists"); + ).rejects.toThrow("Output directory must not exist unless --clean is used"); + }); + + it("replaces an existing output directory with clean", async () => { + const dir = await makeTempDir(); + const irPath = join(dir, "ir.json"); + const outDir = join(dir, "out"); + await writeFile(irPath, JSON.stringify(emptyMakerIr)); + await mkdir(outDir); + await writeFile(join(outDir, "stale.txt"), "stale"); + + await handleOacGenerate({ + ir: irPath, + outDir, + version: "0.0.0-dev", + packageName: "@example/item-sdk", + packageType: "module", + ontologyIdentity: "portable", + clean: true, + }); + + await expect( + readFile(join(outDir, "stale.txt"), "utf-8"), + ).rejects.toThrow(); + expect( + await readFile(join(outDir, "semantic-manifest.json"), "utf-8"), + ).toContain("@example/item-sdk"); + }); + + it("rejects the current working directory", async () => { + const dir = await makeTempDir(); + const irPath = join(dir, "ir.json"); + await writeFile(irPath, JSON.stringify(emptyMakerIr)); + + await expect( + handleOacGenerate({ + ir: irPath, + outDir: process.cwd(), + version: "0.0.0-dev", + packageName: "@example/item-sdk", + packageType: "module", + ontologyIdentity: "portable", + clean: true, + }), + ).rejects.toThrow("Refusing to generate into protected directory"); }); it("rejects an import that does not match imported metadata", async () => { diff --git a/packages/cli.cmd.typescript/src/oac/handleOacGenerate.ts b/packages/cli.cmd.typescript/src/oac/handleOacGenerate.ts index d2915beaa5..77576e09aa 100644 --- a/packages/cli.cmd.typescript/src/oac/handleOacGenerate.ts +++ b/packages/cli.cmd.typescript/src/oac/handleOacGenerate.ts @@ -40,16 +40,20 @@ interface ImportedEntityMapping { } export async function handleOacGenerate(args: OacGenerateArgs): Promise { + let stagingDir: string | undefined; try { const ir = await readMakerIr(args.ir); const imports = args.importMap ? await readImportMap(args.importMap) : []; validateImports(imports, ir, args.importMap); - if (fs.existsSync(args.outDir)) { + const outDir = protectedOutputDirectory(args.outDir); + if (fs.existsSync(outDir) && args.clean !== true) { throw new ExitProcessError( 1, - `Output directory already exists: ${path.resolve(args.outDir)}`, + `Output directory must not exist unless --clean is used: ${outDir}`, ); } + await fs.promises.mkdir(path.dirname(outDir), { recursive: true }); + stagingDir = await fs.promises.mkdtemp(`${outDir}.tmp-`); const metadata = OntologyIrToFullMetadataConverter.getFullMetadataFromEnvelope(ir); @@ -67,7 +71,7 @@ export async function handleOacGenerate(args: OacGenerateArgs): Promise { return await fs.promises.readdir(dirPath); }, }, - args.outDir, + stagingDir, args.packageType, toExternalMap(imports, "object"), toExternalMap(imports, "interface"), @@ -83,9 +87,11 @@ export async function handleOacGenerate(args: OacGenerateArgs): Promise { packageVersion: args.version, }); await fs.promises.writeFile( - path.join(args.outDir, "semantic-manifest.json"), + path.join(stagingDir, "semantic-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, ); + await replaceOutputDirectory(stagingDir, outDir); + stagingDir = undefined; consola.info("OSDK generated from Maker IR"); } catch (error) { if (error instanceof ExitProcessError) { @@ -93,6 +99,51 @@ export async function handleOacGenerate(args: OacGenerateArgs): Promise { } const message = error instanceof Error ? error.message : String(error); throw new ExitProcessError(1, message); + } finally { + if (stagingDir !== undefined) { + await fs.promises.rm(stagingDir, { recursive: true, force: true }); + } + } +} + +function protectedOutputDirectory(outDir: string): string { + const resolved = path.resolve(outDir); + const root = path.parse(resolved).root; + const relativeCwd = path.relative(resolved, process.cwd()); + if ( + resolved === root || + relativeCwd === "" || + (!relativeCwd.startsWith("..") && !path.isAbsolute(relativeCwd)) + ) { + throw new ExitProcessError( + 1, + `Refusing to generate into protected directory: ${resolved}`, + ); + } + return resolved; +} + +async function replaceOutputDirectory( + stagingDir: string, + outDir: string, +): Promise { + const backupDir = `${outDir}.backup`; + let movedExisting = false; + if (fs.existsSync(outDir)) { + await fs.promises.rm(backupDir, { recursive: true, force: true }); + await fs.promises.rename(outDir, backupDir); + movedExisting = true; + } + try { + await fs.promises.rename(stagingDir, outDir); + if (movedExisting) { + await fs.promises.rm(backupDir, { recursive: true, force: true }); + } + } catch (error) { + if (movedExisting && !fs.existsSync(outDir)) { + await fs.promises.rename(backupDir, outDir); + } + throw error; } } diff --git a/packages/cli.cmd.typescript/src/oac/oacGenerate.ts b/packages/cli.cmd.typescript/src/oac/oacGenerate.ts index d658001e9a..19e78d6190 100644 --- a/packages/cli.cmd.typescript/src/oac/oacGenerate.ts +++ b/packages/cli.cmd.typescript/src/oac/oacGenerate.ts @@ -23,6 +23,7 @@ export interface OacGenerateArgs { version: string; packageName: string; packageType: "module"; + clean?: boolean; ontologyIdentity: "portable" | "installationSpecific"; importMap?: string; } @@ -57,6 +58,10 @@ export const oacGenerateCommand: CommandModule<{}, OacGenerateArgs> = { default: "module", choices: ["module"], }, + clean: { + type: "boolean", + description: "Replace an existing output directory", + }, ontologyIdentity: { type: "string", choices: ["portable", "installationSpecific"],