diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 63f3a7909..0497756d8 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -1,5 +1,6 @@ import { execFile as execFileCallback } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; import { lstat, mkdir, @@ -367,8 +368,18 @@ async function ensureOutputDirectory(path: string): Promise { if (metadata?.isSymbolicLink()) { throw new Error("Multiscan output directories must not be symbolic links."); } - await mkdir(path, { recursive: true, mode: 0o700 }); - const canonical = await realpath(path); + if (metadata !== undefined && !metadata.isDirectory()) { + throw new Error("Multiscan output paths must be directories."); + } + let prepared = path; + if (metadata === undefined) { + prepared = + process.platform === "win32" + ? await canonicalWindowsCreationPath(path) + : path; + await mkdir(prepared, { recursive: true, mode: 0o700 }); + } + const canonical = await realpath(prepared); const directory = await lstat(canonical); if ( metadata !== undefined && @@ -391,6 +402,20 @@ async function ensureOutputDirectory(path: string): Promise { return canonical; } +async function canonicalWindowsCreationPath(path: string): Promise { + let ancestor = dirname(path); + for (;;) { + try { + return resolve(await realpath(ancestor), relative(ancestor, path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = dirname(ancestor); + if (parent === ancestor) throw error; + ancestor = parent; + } + } +} + async function appendReceipt(path: string, receipt: string): Promise { const file = await open(path, "a", 0o600); try { @@ -762,7 +787,15 @@ function normalizeRepository(repository: string, directory: string): string { ); } if (/^[^@\s/:]+@[^:\s/]+:.+$/u.test(repository)) return repository; - if (!repository.includes("://")) return resolve(directory, repository); + if (!repository.includes("://")) { + const path = resolve(directory, repository); + if (process.platform !== "win32") return path; + try { + return realpathSync.native(path); + } catch { + return path; + } + } let url: URL; try { url = new URL(repository); diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 33676192a..89b0263e4 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1539,6 +1539,31 @@ describe("multiscan", () => { expect(calls).toBe(2); }); + test.skipIf(process.platform !== "win32")( + "resumes campaigns across Windows repository path aliases", + async () => { + const paths = await fixture(); + const source = await repository(paths.root, "resume-alias"); + const inventory = (repositoryPath: string) => + `id,repository,revision\nresume,${repositoryPath},${source.revision}\n`; + let calls = 0; + const security = client(async (_repository, scanOptions = {}) => { + calls += 1; + return await completedScan(scanOptions.outputDir!); + }); + + await writeFile(paths.input, inventory(source.path)); + await runMultiscan(options(paths, security)); + await writeFile(paths.input, inventory(source.path.toUpperCase())); + + expect(await runMultiscan(options(paths, security))).toMatchObject({ + completed: 1, + skipped: 1, + }); + expect(calls).toBe(1); + }, + ); + test("ignores repository-local Git shims while preserving credential configuration", async () => { const paths = await fixture(); const source = await repository(paths.root, "private");