131 lines
2.9 KiB
Go
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
|
|
}
|