Files
vps-commander/internal/executor/edit.go
T
2026-10-02 13:23:30 +08:00

131 lines
2.9 KiB
Go

package executor
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var ErrBlockNotUnique = errors.New("old_text must occur exactly once")
var ErrEditConflict = errors.New("file changed during edit")
var editMu sync.Mutex
type EditBlockResult struct {
BytesWritten int64
}
func EditBlock(path, oldText, newText string, expectedHash ...string) (EditBlockResult, error) {
editMu.Lock()
defer editMu.Unlock()
if path == "" {
return EditBlockResult{}, errors.New("path required")
}
if oldText == "" {
return EditBlockResult{}, errors.New("old_text required")
}
data, err := os.ReadFile(path)
if err != nil {
return EditBlockResult{}, err
}
initial := sha256.Sum256(data)
initialHash := hex.EncodeToString(initial[:])
if len(expectedHash) > 0 && expectedHash[0] != "" && expectedHash[0] != initialHash {
return EditBlockResult{}, ErrEditConflict
}
src := string(data)
count := countNonOverlapping(src, oldText)
if count != 1 {
if count == 0 {
return EditBlockResult{}, fmt.Errorf("%w: found 0 matches", ErrBlockNotUnique)
}
return EditBlockResult{}, fmt.Errorf("%w: found %d matches", ErrBlockNotUnique, count)
}
updated := replaceOnce(src, oldText, newText)
info, err := os.Stat(path)
if err != nil {
return EditBlockResult{}, err
}
// Re-read immediately before commit. This is the CAS check against
// writers outside this process; editMu serializes our own writers.
latest, err := os.ReadFile(path)
if err != nil {
return EditBlockResult{}, err
}
latestSum := sha256.Sum256(latest)
if hex.EncodeToString(latestSum[:]) != initialHash {
return EditBlockResult{}, ErrEditConflict
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".vps-commander-edit-*")
if err != nil {
return EditBlockResult{}, err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if _, err := tmp.WriteString(updated); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if err := tmp.Close(); err != nil {
return EditBlockResult{}, err
}
if err := os.Rename(tmpName, path); err != nil {
return EditBlockResult{}, err
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return EditBlockResult{BytesWritten: int64(len(updated))}, nil
}
func countNonOverlapping(src, old string) int {
n, count := 0, 0
for {
i := indexString(src[n:], old)
if i < 0 {
return count
}
count++
n += i + len(old)
if n > len(src) {
return count
}
}
}
func replaceOnce(src, old, new string) string {
i := indexString(src, old)
return src[:i] + new + src[i+len(old):]
}
func indexString(src, needle string) int {
if len(needle) == 0 {
return 0
}
for i := 0; i+len(needle) <= len(src); i++ {
if src[i:i+len(needle)] == needle {
return i
}
}
return -1
}