From 196d17b3bbba6c515df40fc25be2365f45a7773f Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sat, 30 May 2026 13:19:26 +0800 Subject: [PATCH 1/2] fix: use os.CreateTemp for atomic rewrites Create temp files with the .find-replace-* prefix via os.CreateTemp so names are unpredictable and not vulnerable to symlink planting attacks. Fixes #3 --- file_handling.go | 27 +++++++++++++++++++++------ file_handling_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/file_handling.go b/file_handling.go index 7c0b1a7..5331f25 100644 --- a/file_handling.go +++ b/file_handling.go @@ -98,18 +98,33 @@ func (f *File) Write(content string) error { return err } - tempName := filepath.Join(f.Dir(), RandomString(20)) - if err := os.WriteFile(tempName, []byte(content), mode); err != nil { + tempFile, err := os.CreateTemp(f.Dir(), ".find-replace-*") + if err != nil { return fmt.Errorf("create tempfile in %v: %w", f.Dir(), err) } - // Make sure the temp file is removed if the rename below fails. On - // success, the rename has already moved the file to f.Path so this is - // a no-op (we deliberately ignore the not-exist error). - defer os.Remove(tempName) + tempName := tempFile.Name() + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(tempName) + } + }() + if err := tempFile.Chmod(mode); err != nil { + _ = tempFile.Close() + return fmt.Errorf("chmod temp file %v: %w", tempName, err) + } + if _, err := tempFile.WriteString(content); err != nil { + _ = tempFile.Close() + return fmt.Errorf("write temp file %v: %w", tempName, err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temp file %v: %w", tempName, err) + } log.Printf("Rewriting %v", f.Path) if err := os.Rename(tempName, f.Path); err != nil { return fmt.Errorf("atomically move temp file %v to %v: %w", tempName, f.Path, err) } + removeTemp = false return nil } diff --git a/file_handling_test.go b/file_handling_test.go index 91ee538..b5b8f64 100644 --- a/file_handling_test.go +++ b/file_handling_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "path/filepath" "testing" ) @@ -77,3 +78,28 @@ func TestNewFile(t *testing.T) { }) } } + +func TestWriteUsesCreateTempPrefix(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "target.txt") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + f, err := NewFile(path) + if err != nil { + t.Fatal(err) + } + if err := f.Write("new"); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Name() != "target.txt" { + t.Fatalf("unexpected leftover file %q", e.Name()) + } + } +} + From 489e0101bb8a2166f2a8a2d73f7614274cbc0974 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 31 May 2026 15:33:47 +0800 Subject: [PATCH 2/2] fix: skip orphan .find-replace-* temp files during walks Ignore stale temp files left by crashed runs so a subsequent traversal does not rewrite or rename them. Requires the .find-replace-* temp prefix from the safe tempfile change. Fixes #21 --- find_replace.go | 7 +++++++ find_replace_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/find_replace.go b/find_replace.go index 9bdbe79..f673fea 100644 --- a/find_replace.go +++ b/find_replace.go @@ -124,6 +124,9 @@ func (fr *findReplace) WalkDir(f *File) { } for _, file := range files { + if strings.HasPrefix(file.Name(), ".find-replace-") { + continue + } childPath := filepath.Join(f.Path, file.Name()) childFile, err := NewFile(childPath) if err != nil { @@ -156,6 +159,10 @@ func (fr *findReplace) HandleFile(f *File) error { return err } + if strings.HasPrefix(f.Base(), ".find-replace-") { + return nil + } + // If file is a directory, recurse immediately (depth-first). if info.IsDir() { // Ignore certain directories diff --git a/find_replace_test.go b/find_replace_test.go index 9a54564..e8fce56 100644 --- a/find_replace_test.go +++ b/find_replace_test.go @@ -684,3 +684,42 @@ func BenchmarkNova(b *testing.B) { fr.WalkDir(d) } } + +func TestSkipStaleTempFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("alpha"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".find-replace-stale"), []byte("alpha"), 0644); err != nil { + t.Fatal(err) + } + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + code := run([]string{"find-replace", "alpha", "beta"}, ioDiscard{t}) + if code != 0 { + t.Fatalf("run exit = %d; want 0", code) + } + + if _, err := os.Stat(filepath.Join(dir, ".find-replace-stale")); err != nil { + t.Fatalf("stale temp file should remain untouched: %v", err) + } + got, err := os.ReadFile(filepath.Join(dir, "keep.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "beta" { + t.Fatalf("keep.txt = %q; want beta", got) + } +} + +type ioDiscard struct{ t *testing.T } + +func (d ioDiscard) Write(p []byte) (int, error) { return len(p), nil }