From 8c0cbc32fb8e124c08603b15fa12183845f97031 Mon Sep 17 00:00:00 2001 From: paulpham157 Date: Tue, 15 Sep 2026 14:43:40 +0700 Subject: [PATCH 1/5] chore: standardize package version to 0.0.0 placeholder semantic-release derives the published version from git tags at CI time, so the committed version field was diverging (0.1.3 vs published 0.3.0). Set it to 0.0.0 to signal it's a placeholder managed by release tooling. Co-Authored-By: Claude Code --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7ec1d4d..6a779f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@paulpham157/apply-patch", - "version": "0.1.3", + "version": "0.0.0", "description": "Universal apply_patch for every model on Pi, not just gpt-*: GPT, Claude, Gemini, DeepSeek, Kimi, GLM, Qwen, and more. One patch tool across providers with grammar support and JSON fallback.", "type": "module", "license": "MIT", From 00020dac06c487fbab77f216729c5d9799ccf296 Mon Sep 17 00:00:00 2001 From: paulpham157 Date: Tue, 15 Sep 2026 14:54:44 +0700 Subject: [PATCH 2/5] fix: guard against patching binary files prepareOperation read files as UTF-8, decoding binary into replacement characters so a patch could match garbage or silently corrupt the file on write. Read as Buffer, reject on a null byte with an EBINARY code before any write, then decode. Delete hunks never read content and are unaffected. Co-Authored-By: Claude Code --- src/index.ts | 10 +++++++++- test/index.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a409bb2..f9e18d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1194,7 +1194,15 @@ async function prepareOperation(cwd: string, hunk: ParsedPatch): Promise { expect(await readFile(path.join(directory, "sample.txt"), "utf-8")).toBe("after\n"); }); + it("#given binary file with null byte #when applying patch #then rejects with EBINARY and preserves file", async () => { + // given + const directory = await createTempDirectory(); + const binaryPath = path.join(directory, "binary.bin"); + await writeFile(binaryPath, Buffer.from([0x00, 0x01, 0x02, 0xff])); + + // when + let caught: unknown; + try { + await applyPatch( + directory, + `*** Begin Patch +*** Update File: binary.bin +@@ +-some ++changed +*** End Patch`, + ); + } catch (error) { + caught = error; + } + + // then + expect(caught).toBeInstanceOf(ApplyPatchError); + const failure = (caught as ApplyPatchError).failures[0]; + expect(failure).toMatchObject({ + filePath: "binary.bin", + operation: "update", + code: "EBINARY", + }); + expect(failure?.message ?? "").toMatch(/binary|non-text|not text/); + expect(await readFile(binaryPath)).toEqual(Buffer.from([0x00, 0x01, 0x02, 0xff])); + }); + it("#given parent traversal path #when applying patch #then rejects outside cwd", async () => { // given const directory = await createTempDirectory(); From 2262cc06f0f682f3629329577640874224901a81 Mon Sep 17 00:00:00 2001 From: paulpham157 Date: Tue, 15 Sep 2026 15:18:06 +0700 Subject: [PATCH 3/5] Revert "chore: standardize package version to 0.0.0 placeholder" This reverts commit 8c0cbc32fb8e124c08603b15fa12183845f97031. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6a779f2..7ec1d4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@paulpham157/apply-patch", - "version": "0.0.0", + "version": "0.1.3", "description": "Universal apply_patch for every model on Pi, not just gpt-*: GPT, Claude, Gemini, DeepSeek, Kimi, GLM, Qwen, and more. One patch tool across providers with grammar support and JSON fallback.", "type": "module", "license": "MIT", From ab7b95870d0cec2c53064809d86368c33c27db0d Mon Sep 17 00:00:00 2001 From: paulpham157 Date: Tue, 15 Sep 2026 15:18:56 +0700 Subject: [PATCH 4/5] test: narrow binary patch failure type Co-Authored-By: Claude Code --- test/index.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 47ca08a..db00777 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -251,8 +251,10 @@ describe("pi-apply-patch", () => { } // then - expect(caught).toBeInstanceOf(ApplyPatchError); - const failure = (caught as ApplyPatchError).failures[0]; + if (!(caught instanceof ApplyPatchError)) { + throw new Error("Expected apply_patch to reject with ApplyPatchError"); + } + const failure = caught.failures[0]; expect(failure).toMatchObject({ filePath: "binary.bin", operation: "update", From 7143f905a2c275e53cb1dae37cb800025ed222c3 Mon Sep 17 00:00:00 2001 From: paulpham157 Date: Tue, 15 Sep 2026 16:47:15 +0700 Subject: [PATCH 5/5] fix: skip binary patch previews Reuse the null-byte guard when generating update previews so apply_patch falls back to a progress-only pending update rather than decoding binary content. Keep deletes unguarded and cover binary update, move, delete, and preview behavior. Co-Authored-By: Claude Code --- src/index.ts | 24 +++++++----- test/index.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index f9e18d3..ec95da7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -519,6 +519,18 @@ async function readExistingFileForPreview(absolutePath: string): Promise } } +async function readTextFileRejectingBinary(absolutePath: string, filePath: string): Promise { + // Sniff for null bytes before decoding: decoding binary as UTF-8 mangles + // bytes into replacement characters, so a patch could either match + // garbage or silently corrupt the file on write. Reject early with a + // distinct code instead of letting apply_patch guess at binary content. + const raw = await readFile(absolutePath); + if (raw.includes(0)) { + throw Object.assign(new Error(`Refusing to patch binary file: ${filePath}`), { code: "EBINARY" }); + } + return raw.toString("utf-8"); +} + function formatLineCountSummary(added: number, removed: number): string { return `(+${added} -${removed})`; } @@ -916,7 +928,7 @@ async function createPatchPreview(cwd: string, hunks: ParsedPatch[]): Promise { expect(await readFile(binaryPath)).toEqual(Buffer.from([0x00, 0x01, 0x02, 0xff])); }); + it("#given binary file with null byte #when moving with apply_patch then rejects with EBINARY and leaves both paths unchanged", async () => { + // given + const directory = await createTempDirectory(); + const sourcePath = path.join(directory, "binary.bin"); + const destinationPath = path.join(directory, "moved.bin"); + const original = Buffer.from("before\0after\n"); + await writeFile(sourcePath, original); + const patch = `*** Begin Patch +*** Update File: binary.bin +*** Move to: moved.bin +@@ +-before\0after ++changed +*** End Patch`; + + // when + let caught: unknown; + try { + await applyPatch(directory, patch); + } catch (error) { + caught = error; + } + + // then + if (!(caught instanceof ApplyPatchError)) { + throw new Error("Expected apply_patch to reject with ApplyPatchError"); + } + expect(caught.failures[0]).toMatchObject({ + filePath: "binary.bin", + operation: "update", + code: "EBINARY", + }); + expect(await readFile(sourcePath)).toEqual(original); + await expect(readFile(destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("#given binary file with null byte #when deleting with apply_patch then deletes it", async () => { + // given + const directory = await createTempDirectory(); + const binaryPath = path.join(directory, "binary.bin"); + await writeFile(binaryPath, Buffer.from([0x00, 0x01, 0x02, 0xff])); + const patch = `*** Begin Patch +*** Delete File: binary.bin +*** End Patch`; + + // when + await applyPatch(directory, patch); + + // then + await expect(readFile(binaryPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("#given binary file with null byte #when apply_patch tool previews then omits text preview and reports EBINARY", async () => { + // given + const directory = await createTempDirectory(); + const binaryPath = path.join(directory, "binary.bin"); + const original = Buffer.from("before\0after\n"); + await writeFile(binaryPath, original); + const patch = `*** Begin Patch +*** Update File: binary.bin +@@ +-before\0after ++changed +*** End Patch`; + const updates: Array<{ text: string; update: ApplyPatchUpdate }> = []; + + // when + const result = await createApplyPatchTool().execute( + "binary-preview-test", + { input: patch }, + undefined, + (update) => { + const text = update.content.find((block) => block.type === "text")?.text; + if (text) updates.push({ text, update }); + }, + { cwd: directory } as never, + ); + + // then + const initialUpdate = updates[0]; + if (!initialUpdate) { + throw new Error("apply_patch did not emit an initial update"); + } + expect(initialUpdate.text).toBe("Applying patch (0/1)..."); + expect(initialUpdate.update.details?.preview).toBeUndefined(); + expect(initialUpdate.text).not.toContain("before\0after"); + expect(result.details?.preview).toBeUndefined(); + expect(result.details?.result?.failures[0]).toMatchObject({ + filePath: "binary.bin", + operation: "update", + code: "EBINARY", + }); + const resultText = result.content.find((block) => block.type === "text")?.text ?? ""; + expect(resultText).toContain("Refusing to patch binary file: binary.bin"); + expect(resultText).not.toContain("MUST read"); + expect(await readFile(binaryPath)).toEqual(original); + }); + it("#given parent traversal path #when applying patch #then rejects outside cwd", async () => { // given const directory = await createTempDirectory();