diff --git a/src/lib/init/tools/apply-patchset.ts b/src/lib/init/tools/apply-patchset.ts index ff37d3f1f..c09c87078 100644 --- a/src/lib/init/tools/apply-patchset.ts +++ b/src/lib/init/tools/apply-patchset.ts @@ -14,9 +14,66 @@ import type { InitToolDefinition, ToolContext } from "./types.js"; const EMPTY_AUTH_TOKEN_RE = /^(SENTRY_AUTH_TOKEN[ \t]*=[ \t]*)(?:['"]?[ \t]*['"]?)?[ \t]*$/m; const PATH_SEGMENT_RE = /[/\\]/u; +const WINDOWS_DRIVE_RE = /^[A-Za-z]:/; const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]); +function validatePatchPath(filePath: unknown): string | undefined { + if (typeof filePath !== "string" || filePath.length === 0) { + return "Invalid patch path: expected a non-empty project-relative path"; + } + if (filePath.includes("\\")) { + return `Invalid patch path "${filePath}": use project-relative POSIX paths`; + } + if (WINDOWS_DRIVE_RE.test(filePath) || path.posix.isAbsolute(filePath)) { + return `Invalid patch path "${filePath}": absolute paths are not allowed`; + } + const segments = filePath.split("/"); + if ( + segments.some( + (segment) => segment.length === 0 || segment === "." || segment === ".." + ) + ) { + return `Invalid patch path "${filePath}": path segments must not be empty, "." or ".."`; + } + return; +} + +function validatePatch(patch: unknown, cwd: string): ToolResult | undefined { + if (!patch || typeof patch !== "object") { + return { + ok: false, + error: + "Invalid patch path: expected a patch object with a project-relative path", + }; + } + + const candidate = patch as { action?: unknown; path?: unknown }; + const pathError = validatePatchPath(candidate.path); + if (pathError) { + return { ok: false, error: pathError }; + } + const patchPath = candidate.path as string; + try { + safePath(cwd, patchPath); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + if ( + typeof candidate.action !== "string" || + !VALID_PATCH_ACTIONS.has(candidate.action) + ) { + return { + ok: false, + error: `Unknown patch action: "${String(candidate.action)}" for path "${patchPath}"`, + }; + } + return; +} + /** * Apply a batch of file creates, modifications, and deletes. */ @@ -29,12 +86,9 @@ export async function applyPatchset( } for (const patch of payload.params.patches) { - safePath(payload.cwd, patch.path); - if (!VALID_PATCH_ACTIONS.has(patch.action)) { - return { - ok: false, - error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, - }; + const validationError = validatePatch(patch, payload.cwd); + if (validationError) { + return validationError; } } @@ -66,12 +120,9 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): ToolResult { const applied: Array<{ path: string; action: string }> = []; for (const patch of payload.params.patches) { - safePath(payload.cwd, patch.path); - if (!VALID_PATCH_ACTIONS.has(patch.action)) { - return { - ok: false, - error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, - }; + const validationError = validatePatch(patch, payload.cwd); + if (validationError) { + return validationError; } applied.push({ path: patch.path, action: patch.action }); } diff --git a/test/lib/init/tools/filesystem-tools.test.ts b/test/lib/init/tools/filesystem-tools.test.ts index d0d67a25b..ca08bfcc8 100644 --- a/test/lib/init/tools/filesystem-tools.test.ts +++ b/test/lib/init/tools/filesystem-tools.test.ts @@ -128,6 +128,98 @@ describe("filesystem tools", () => { ).toContain("sntrys_test_token_123"); }); + test("rejects unsafe apply-patchset paths before writing", async () => { + const unsafePaths: unknown[] = [ + null, + "../../outside.txt", + "/tmp/outside.txt", + "C:/repo/package.json", + "C:\\repo\\package.json", + "apps/../package.json", + "./package.json", + "apps//package.json", + ]; + + for (const unsafePath of unsafePaths) { + const result = await executeTool( + { + type: "tool", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: unsafePath as never, + action: "create", + patch: "should not be written\n", + }, + ], + }, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Invalid patch path"); + } + }); + + test("applies patchsets to nested project-relative files", async () => { + fs.mkdirSync(path.join(testDir, "Cary.ConversionFunnels.API")); + fs.writeFileSync( + path.join( + testDir, + "Cary.ConversionFunnels.API", + "Cary.ConversionFunnels.API.csproj" + ), + "\n" + ); + + const result = await executeTool( + { + type: "tool", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: "Directory.Packages.props", + action: "create", + patch: "\n", + }, + { + path: "Cary.ConversionFunnels.API/Cary.ConversionFunnels.API.csproj", + action: "modify", + edits: [ + { + oldString: "", + newString: + '', + }, + ], + }, + ], + }, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect( + fs.readFileSync(path.join(testDir, "Directory.Packages.props"), "utf-8") + ).toContain(""); + expect( + fs.readFileSync( + path.join( + testDir, + "Cary.ConversionFunnels.API", + "Cary.ConversionFunnels.API.csproj" + ), + "utf-8" + ) + ).toContain("Sentry.AspNetCore"); + }); + test("greps and globs files inside the project", async () => { fs.mkdirSync(path.join(testDir, "src")); fs.writeFileSync(