From c4b4f70b04ac04c7c3cb0b452bd527fd7d4229d3 Mon Sep 17 00:00:00 2001 From: dongsinhho Date: Mon, 21 Sep 2026 00:11:41 +0700 Subject: [PATCH] fix(tools): preserve CRLF line endings and UTF-8 BOM on write_file overwrite --- internal/tools/write_file.go | 32 ++++++++++++++++ internal/tools/write_tools_test.go | 60 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/internal/tools/write_file.go b/internal/tools/write_file.go index 937d0c02a..253f3f3ee 100644 --- a/internal/tools/write_file.go +++ b/internal/tools/write_file.go @@ -1,6 +1,7 @@ package tools import ( + "bytes" "context" "fmt" "os" @@ -117,6 +118,10 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an if priorContentKnown { expectedContent = &priorContent } + if existed && priorContentKnown { + hasBOM, isCRLF := detectLineEndingAndBOM([]byte(priorContent)) + content = normalizeContent(content, hasBOM, isCRLF) + } if err := commitFileContents(absolutePath, priorInfo, expectedContent, content); err != nil { return errorResult("Error writing file " + relativePath + ": " + err.Error()) } @@ -195,3 +200,30 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an func fileContentArg(args map[string]any) (string, error) { return aliasedStringArg(args, []string{"content", "contents", "text", "body", "data", "file_content"}, "", true, true) } + +func detectLineEndingAndBOM(data []byte) (hasBOM bool, crlf bool) { + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + hasBOM = true + data = data[3:] + } + if bytes.IndexByte(data, 0) != -1 { + return hasBOM, false + } + scanLen := len(data) + if scanLen > 4096 { + scanLen = 4096 + } + crlf = bytes.Contains(data[:scanLen], []byte("\r\n")) + return hasBOM, crlf +} + +func normalizeContent(content string, preserveBOM bool, preserveCRLF bool) string { + if preserveCRLF { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\n", "\r\n") + } + if preserveBOM && !strings.HasPrefix(content, "\xef\xbb\xbf") { + content = "\xef\xbb\xbf" + content + } + return content +} diff --git a/internal/tools/write_tools_test.go b/internal/tools/write_tools_test.go index 29afd57b2..dbdf6a059 100644 --- a/internal/tools/write_tools_test.go +++ b/internal/tools/write_tools_test.go @@ -1435,6 +1435,66 @@ func TestWriteFileAcceptsContentAlias(t *testing.T) { } } +func TestWriteFilePreservesCRLFAndUTF8BOMOnOverwrite(t *testing.T) { + root := t.TempDir() + tool := NewScopedWriteFileTool(root, nil) + + original := "\xef\xbb\xbfline1\r\nline2\r\n" + targetPath := filepath.Join(root, "crlf_bom.txt") + if err := os.WriteFile(targetPath, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + newContent := "updated line1\nupdated line2\n" + result := tool.Run(context.Background(), map[string]any{ + "path": "crlf_bom.txt", + "content": newContent, + "overwrite": true, + }) + if result.Status != StatusOK { + t.Fatalf("expected status OK, got %s: %s", result.Status, result.Output) + } + + written, err := os.ReadFile(targetPath) + if err != nil { + t.Fatal(err) + } + + expected := "\xef\xbb\xbfupdated line1\r\nupdated line2\r\n" + if string(written) != expected { + t.Fatalf("expected preserved CRLF & BOM: %q, got: %q", expected, string(written)) + } +} + +func TestWriteFileToolOverwriteIgnoresBinary(t *testing.T) { + root := t.TempDir() + tool := NewScopedWriteFileTool(root, nil) + + path := filepath.Join(root, "binary.bin") + original := []byte("data\r\n\x00binary") + if err := os.WriteFile(path, original, 0o644); err != nil { + t.Fatalf("write original file: %v", err) + } + + result := tool.Run(context.Background(), map[string]any{ + "path": "binary.bin", + "content": "new content\nwith lf", + "overwrite": true, + }) + if result.Status != StatusOK { + t.Fatalf("expected overwrite ok, got %s: %s", result.Status, result.Output) + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read overwritten file: %v", err) + } + + if strings.Contains(string(written), "\r\n") { + t.Fatalf("expected LF preserved for binary file, got CRLF in %q", string(written)) + } +} + // gitApplyUnavailable reports whether an apply_patch failure is due to the git // binary being absent (an environment condition worth skipping) rather than a // real regression (which must fail the test). apply_patch shells out to