Skip to content

Commit 5bbe0b6

Browse files
committed
fix: preserve HTML entities in diff/search/replace operations
- Remove HTML entity unescaping from applyDiffTool.ts - Remove HTML entity unescaping from multiApplyDiffTool.ts - Remove HTML entity unescaping from writeToFileTool.ts - Update tests to verify HTML entities are preserved - Add comprehensive test suite for HTML entity preservation This ensures that encoded characters like &, <, and > are preserved exactly as written during diff and file operations, preventing unintended modifications to code and markup. Fixes #8069
1 parent dcc6db0 commit 5bbe0b6

File tree

5 files changed

+360
-32
lines changed

5 files changed

+360
-32
lines changed
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import * as fs from "fs/promises"
3+
import { applyDiffToolLegacy } from "../applyDiffTool"
4+
import { applyDiffTool } from "../multiApplyDiffTool"
5+
import { writeToFileTool } from "../writeToFileTool"
6+
import { searchAndReplaceTool } from "../searchAndReplaceTool"
7+
import { ToolUse } from "../../../shared/tools"
8+
9+
// Mock dependencies
10+
vi.mock("fs/promises")
11+
vi.mock("../../../utils/fs", () => ({
12+
fileExistsAtPath: vi.fn().mockResolvedValue(true),
13+
}))
14+
vi.mock("../../../utils/path", () => ({
15+
getReadablePath: vi.fn((cwd, path) => path),
16+
}))
17+
vi.mock("../../prompts/responses", () => ({
18+
formatResponse: {
19+
toolError: vi.fn((msg) => `Error: ${msg}`),
20+
rooIgnoreError: vi.fn((path) => `Access denied: ${path}`),
21+
createPrettyPatch: vi.fn(() => "mock-diff"),
22+
},
23+
}))
24+
vi.mock("../../../integrations/editor/detect-omission", () => ({
25+
detectCodeOmission: vi.fn().mockReturnValue(false),
26+
}))
27+
vi.mock("../../../utils/pathUtils", () => ({
28+
isPathOutsideWorkspace: vi.fn().mockReturnValue(false),
29+
}))
30+
vi.mock("../../../integrations/misc/extract-text", () => ({
31+
everyLineHasLineNumbers: vi.fn().mockReturnValue(false),
32+
stripLineNumbers: vi.fn((content) => content),
33+
}))
34+
vi.mock("delay", () => ({
35+
default: vi.fn(),
36+
}))
37+
38+
describe("HTML Entity Preservation", () => {
39+
const testContent = `
40+
<div>
41+
<p>This &amp; that</p>
42+
<span>&lt;important&gt;</span>
43+
<code>if (a &lt; b &amp;&amp; c &gt; d) { return &quot;test&quot;; }</code>
44+
</div>
45+
`
46+
47+
const mockCline: any = {
48+
cwd: "/test",
49+
consecutiveMistakeCount: 0,
50+
consecutiveMistakeCountForApplyDiff: new Map(),
51+
didEditFile: false,
52+
api: {
53+
getModel: vi.fn().mockReturnValue({ id: "gpt-4" }),
54+
},
55+
providerRef: {
56+
deref: vi.fn().mockReturnValue({
57+
getState: vi.fn().mockResolvedValue({
58+
diagnosticsEnabled: true,
59+
writeDelayMs: 0,
60+
experiments: {},
61+
}),
62+
}),
63+
},
64+
rooIgnoreController: {
65+
validateAccess: vi.fn().mockReturnValue(true),
66+
},
67+
rooProtectedController: {
68+
isWriteProtected: vi.fn().mockReturnValue(false),
69+
},
70+
diffViewProvider: {
71+
editType: "modify",
72+
originalContent: testContent,
73+
open: vi.fn(),
74+
update: vi.fn(),
75+
reset: vi.fn(),
76+
revertChanges: vi.fn(),
77+
saveChanges: vi.fn().mockResolvedValue({
78+
newProblemsMessage: "",
79+
userEdits: null,
80+
finalContent: testContent,
81+
}),
82+
saveDirectly: vi.fn(),
83+
scrollToFirstDiff: vi.fn(),
84+
pushToolWriteResult: vi.fn().mockResolvedValue("Success"),
85+
},
86+
diffStrategy: {
87+
applyDiff: vi.fn().mockImplementation((original, diff) => ({
88+
success: true,
89+
content: testContent,
90+
})),
91+
getProgressStatus: vi.fn(),
92+
},
93+
fileContextTracker: {
94+
trackFileContext: vi.fn(),
95+
},
96+
say: vi.fn(),
97+
ask: vi.fn(),
98+
recordToolError: vi.fn(),
99+
sayAndCreateMissingParamError: vi.fn(),
100+
processQueuedMessages: vi.fn(),
101+
}
102+
103+
const mockAskApproval = vi.fn().mockResolvedValue(true)
104+
const mockHandleError = vi.fn()
105+
const mockPushToolResult = vi.fn()
106+
const mockRemoveClosingTag = vi.fn((tag, content) => content)
107+
108+
beforeEach(() => {
109+
vi.clearAllMocks()
110+
vi.mocked(fs.readFile).mockResolvedValue(testContent)
111+
})
112+
113+
describe("applyDiffTool", () => {
114+
it("should preserve HTML entities in diff content", async () => {
115+
const block: ToolUse = {
116+
type: "tool_use",
117+
name: "apply_diff",
118+
params: {
119+
path: "test.html",
120+
diff: `<<<<<<< SEARCH
121+
<p>This & that</p>
122+
=======
123+
<p>This &amp; that</p>
124+
>>>>>>> REPLACE`,
125+
},
126+
partial: false,
127+
}
128+
129+
await applyDiffToolLegacy(
130+
mockCline,
131+
block,
132+
mockAskApproval,
133+
mockHandleError,
134+
mockPushToolResult,
135+
mockRemoveClosingTag,
136+
)
137+
138+
// Verify that the diff content was passed to diffStrategy without modification
139+
expect(mockCline.diffStrategy.applyDiff).toHaveBeenCalledWith(
140+
testContent,
141+
expect.stringContaining("&amp;"),
142+
expect.any(Number),
143+
)
144+
})
145+
})
146+
147+
describe("multiApplyDiffTool", () => {
148+
it("should preserve HTML entities in multi-file diff operations", async () => {
149+
const block: ToolUse = {
150+
type: "tool_use",
151+
name: "apply_diff",
152+
params: {
153+
args: `<file>
154+
<path>test.html</path>
155+
<diff>
156+
<content><<<<<<< SEARCH
157+
<code>if (a < b && c > d) { return "test"; }</code>
158+
=======
159+
<code>if (a &lt; b &amp;&amp; c &gt; d) { return &quot;test&quot;; }</code>
160+
>>>>>>> REPLACE</content>
161+
</diff>
162+
</file>`,
163+
},
164+
partial: false,
165+
}
166+
167+
await applyDiffTool(
168+
mockCline,
169+
block,
170+
mockAskApproval,
171+
mockHandleError,
172+
mockPushToolResult,
173+
mockRemoveClosingTag,
174+
)
175+
176+
// Verify that HTML entities were preserved in the diff items
177+
expect(mockCline.diffStrategy.applyDiff).toHaveBeenCalledWith(
178+
testContent,
179+
expect.arrayContaining([
180+
expect.objectContaining({
181+
content: expect.stringContaining("&lt;"),
182+
}),
183+
]),
184+
)
185+
})
186+
})
187+
188+
describe("writeToFileTool", () => {
189+
it("should preserve HTML entities when writing files", async () => {
190+
const block: ToolUse = {
191+
type: "tool_use",
192+
name: "write_to_file",
193+
params: {
194+
path: "test.html",
195+
content: testContent,
196+
line_count: "7",
197+
},
198+
partial: false,
199+
}
200+
201+
await writeToFileTool(
202+
mockCline,
203+
block,
204+
mockAskApproval,
205+
mockHandleError,
206+
mockPushToolResult,
207+
mockRemoveClosingTag,
208+
)
209+
210+
// Verify that content with HTML entities was passed unchanged
211+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expect.stringContaining("&amp;"), true)
212+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expect.stringContaining("&lt;"), true)
213+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expect.stringContaining("&gt;"), true)
214+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(expect.stringContaining("&quot;"), true)
215+
})
216+
217+
it("should preserve HTML entities for both Claude and non-Claude models", async () => {
218+
const htmlContent = '<p>&lt;Hello &amp; "World"&gt;</p>'
219+
const block: ToolUse = {
220+
type: "tool_use",
221+
name: "write_to_file",
222+
params: {
223+
path: "test.html",
224+
content: htmlContent,
225+
line_count: "1",
226+
},
227+
partial: false,
228+
}
229+
230+
// Test with non-Claude model
231+
mockCline.api.getModel.mockReturnValue({ id: "gpt-4" })
232+
await writeToFileTool(
233+
mockCline,
234+
block,
235+
mockAskApproval,
236+
mockHandleError,
237+
mockPushToolResult,
238+
mockRemoveClosingTag,
239+
)
240+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(htmlContent, true)
241+
242+
// Clear mocks and test with Claude model
243+
vi.clearAllMocks()
244+
mockCline.api.getModel.mockReturnValue({ id: "claude-3-opus" })
245+
await writeToFileTool(
246+
mockCline,
247+
block,
248+
mockAskApproval,
249+
mockHandleError,
250+
mockPushToolResult,
251+
mockRemoveClosingTag,
252+
)
253+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(htmlContent, true)
254+
})
255+
})
256+
257+
describe("searchAndReplaceTool", () => {
258+
it("should preserve HTML entities in search and replace operations", async () => {
259+
const block: ToolUse = {
260+
type: "tool_use",
261+
name: "search_and_replace",
262+
params: {
263+
path: "test.html",
264+
search: "This & that",
265+
replace: "This &amp; that",
266+
use_regex: "false",
267+
ignore_case: "false",
268+
},
269+
partial: false,
270+
}
271+
272+
await searchAndReplaceTool(
273+
mockCline,
274+
block,
275+
mockAskApproval,
276+
mockHandleError,
277+
mockPushToolResult,
278+
mockRemoveClosingTag,
279+
)
280+
281+
// Verify that the search and replace preserved HTML entities
282+
expect(mockCline.diffViewProvider.update).toHaveBeenCalled()
283+
// The tool should have been called with the original search/replace values
284+
expect(block.params.search).toBe("This & that")
285+
expect(block.params.replace).toBe("This &amp; that")
286+
})
287+
})
288+
289+
describe("Integration test", () => {
290+
it("should handle complex HTML with multiple entities correctly", async () => {
291+
const complexHtml = `
292+
<!DOCTYPE html>
293+
<html>
294+
<head>
295+
<title>Test &amp; Demo</title>
296+
<meta content="width=device-width, initial-scale=1" name="viewport">
297+
</head>
298+
<body>
299+
<h1>Comparison: 5 &lt; 10 &amp;&amp; 10 &gt; 5</h1>
300+
<p>Quote: &quot;Hello, World!&quot;</p>
301+
<p>Apostrophe: It&apos;s working!</p>
302+
<code>
303+
if (x &lt; y &amp;&amp; y &gt; z) {
304+
console.log(&quot;Condition met&quot;);
305+
}
306+
</code>
307+
</body>
308+
</html>
309+
`
310+
311+
const block: ToolUse = {
312+
type: "tool_use",
313+
name: "write_to_file",
314+
params: {
315+
path: "complex.html",
316+
content: complexHtml,
317+
line_count: "18",
318+
},
319+
partial: false,
320+
}
321+
322+
await writeToFileTool(
323+
mockCline,
324+
block,
325+
mockAskApproval,
326+
mockHandleError,
327+
mockPushToolResult,
328+
mockRemoveClosingTag,
329+
)
330+
331+
// Verify all entities are preserved
332+
const calledContent = mockCline.diffViewProvider.update.mock.calls[0][0]
333+
expect(calledContent).toContain("&amp;")
334+
expect(calledContent).toContain("&lt;")
335+
expect(calledContent).toContain("&gt;")
336+
expect(calledContent).toContain("&quot;")
337+
expect(calledContent).toContain("&apos;")
338+
339+
// Ensure no unescaping happened
340+
expect(calledContent).not.toContain("&&")
341+
expect(calledContent).not.toContain("< 10 &&")
342+
expect(calledContent).not.toContain("> 5")
343+
})
344+
})
345+
})

0 commit comments

Comments
 (0)