返回 DeepSeek-Reasonix
mutate.go
根目录 / internal / config / mutate.go
1 package config
2
3 import (
4 "context"
5 "crypto/sha256"
6 "fmt"
7 "os"
8 osuser "os/user"
9 "path/filepath"
10 "runtime"
11 "sort"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "time"
16
17 "reasonix/internal/filelock"
18 )
19
20 // userEditMu serializes in-process read-modify-write cycles. The public lock
21 // helpers also take a path-derived advisory file lock, so CLI, Desktop, bot, and
22 // other Reasonix processes cannot save stale snapshots over one another.
23 // Desktop's read-only config loads (tray/view/bot-runtime paths) never write:
24 // they apply legacy migrations in memory only, and the migrated form reaches
25 // disk through the first locked write path (loadDesktopUserConfigForEdit,
26 // called with this lock held).
27 var (
28 userEditMu sync.Mutex
29 configEditPinsMu sync.RWMutex
30 configEditPins = map[string]string{}
31 userEditFileLockFailure atomic.Pointer[configEditLockFailure]
32 userConfigEditLockTimeout = 5 * time.Second
33 configEditLockTimeout = 5 * time.Second
34 )
35
36 type configEditLockFailure struct {
37 err error
38 }
39
40 type configEditTarget struct {
41 logicalKey string
42 resolvedPath string
43 lockPath string
44 }
45
46 // LockUserConfigEdits acquires the process-wide user-config edit lock and
47 // returns the unlock. Hold it across the full LoadForEdit→mutate→SaveTo
48 // cycle; do not hold it across controller rebuilds or other slow non-config
49 // work, and never call another LockUserConfigEdits taker while holding it.
50 func LockUserConfigEdits() func() {
51 userEditMu.Lock()
52 target, err := resolveConfigEditTarget(UserConfigPath())
53 var unlockFile func()
54 if err == nil {
55 unlockFile, err = acquireConfigEditLockPathWithTimeout(target.lockPath, userConfigEditLockTimeout)
56 }
57 clearPins := func() {}
58 if err != nil {
59 userEditFileLockFailure.Store(&configEditLockFailure{err: err})
60 } else {
61 clearPins = activateConfigEditPins([]configEditTarget{target})
62 }
63 var once sync.Once
64 return func() {
65 once.Do(func() {
66 clearPins()
67 if unlockFile != nil {
68 unlockFile()
69 }
70 userEditFileLockFailure.Store(nil)
71 userEditMu.Unlock()
72 })
73 }
74 }
75
76 // LockConfigFileEdits serializes a configuration read-modify-write transaction
77 // with both other goroutines and other Reasonix processes. The cross-process
78 // lock lives in an OS-user registry rather than beside path, so project
79 // repositories do not accumulate lock files.
80 func LockConfigFileEdits(path string) (func(), error) {
81 return lockConfigFilesEdits(path)
82 }
83
84 // LockConfigFilesEdits locks multiple config sources as one transaction.
85 // Aliases that resolve to the same final file share one advisory lock, and
86 // every logical path stays pinned to the resolved target until unlock.
87 func LockConfigFilesEdits(paths ...string) (func(), error) {
88 return lockConfigFilesEdits(paths...)
89 }
90
91 // EditConfigFile runs a strict read-modify-write transaction for one TOML
92 // config. Malformed input is returned to the caller and is never replaced with
93 // defaults. The callback must only mutate cfg; keep slow runtime work outside
94 // the transaction.
95 func EditConfigFile(path string, edit func(*Config) error) error {
96 return editConfigFile(path, true, edit)
97 }
98
99 // EditConfigFileWithoutCredentials is EditConfigFile without loading the
100 // Reasonix credential environment.
101 func EditConfigFileWithoutCredentials(path string, edit func(*Config) error) error {
102 return editConfigFile(path, false, edit)
103 }
104
105 func editConfigFile(path string, loadCredentials bool, edit func(*Config) error) error {
106 if edit == nil {
107 return fmt.Errorf("edit config: nil callback")
108 }
109 unlock, err := LockConfigFileEdits(path)
110 if err != nil {
111 return err
112 }
113 defer unlock()
114 cfg, err := loadForEditStrict(path, loadCredentials, false)
115 if err != nil {
116 return err
117 }
118 if err := edit(cfg); err != nil {
119 return err
120 }
121 return cfg.SaveTo(path)
122 }
123
124 // lockConfigFilesEdits acquires one in-process transaction lock plus every
125 // distinct path-derived file lock in a stable order. Stable ordering prevents
126 // two Reasonix processes editing overlapping config-source sets from deadlocking.
127 func lockConfigFilesEdits(paths ...string) (func(), error) {
128 userEditMu.Lock()
129 targets := make([]configEditTarget, 0, len(paths))
130 seenLogical := make(map[string]struct{}, len(paths))
131 for _, path := range paths {
132 if strings.TrimSpace(path) == "" {
133 continue
134 }
135 target, err := resolveConfigEditTarget(path)
136 if err != nil {
137 userEditMu.Unlock()
138 return nil, err
139 }
140 if _, ok := seenLogical[target.logicalKey]; ok {
141 continue
142 }
143 seenLogical[target.logicalKey] = struct{}{}
144 targets = append(targets, target)
145 }
146 if len(targets) == 0 {
147 userEditMu.Unlock()
148 return nil, fmt.Errorf("lock config edits: no config paths")
149 }
150
151 lockPaths := make([]string, 0, len(targets))
152 seenLocks := make(map[string]struct{}, len(targets))
153 for _, target := range targets {
154 if _, ok := seenLocks[target.lockPath]; ok {
155 continue
156 }
157 seenLocks[target.lockPath] = struct{}{}
158 lockPaths = append(lockPaths, target.lockPath)
159 }
160 sort.Strings(lockPaths)
161
162 ctx, cancel := context.WithTimeout(context.Background(), configEditLockTimeout)
163 defer cancel()
164 unlockFiles := make([]func(), 0, len(lockPaths))
165 for _, lockPath := range lockPaths {
166 if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
167 for i := len(unlockFiles) - 1; i >= 0; i-- {
168 unlockFiles[i]()
169 }
170 userEditMu.Unlock()
171 return nil, fmt.Errorf("lock config edits: create lock directory: %w", err)
172 }
173 unlockFile, err := acquireConfigEditLockPath(ctx, lockPath)
174 if err != nil {
175 for i := len(unlockFiles) - 1; i >= 0; i-- {
176 unlockFiles[i]()
177 }
178 userEditMu.Unlock()
179 return nil, fmt.Errorf("lock config edits: %w", err)
180 }
181 unlockFiles = append(unlockFiles, unlockFile)
182 }
183 clearPins := activateConfigEditPins(targets)
184
185 var once sync.Once
186 return func() {
187 once.Do(func() {
188 clearPins()
189 for i := len(unlockFiles) - 1; i >= 0; i-- {
190 unlockFiles[i]()
191 }
192 userEditMu.Unlock()
193 })
194 }, nil
195 }
196
197 func acquireConfigFileEditLockWithTimeout(path string, timeout time.Duration) (func(), error) {
198 target, err := resolveConfigEditTarget(path)
199 if err != nil {
200 return nil, err
201 }
202 return acquireConfigEditLockPathWithTimeout(target.lockPath, timeout)
203 }
204
205 func acquireConfigEditLockPathWithTimeout(lockPath string, timeout time.Duration) (func(), error) {
206 ctx, cancel := context.WithTimeout(context.Background(), timeout)
207 defer cancel()
208 return acquireConfigEditLockPath(ctx, lockPath)
209 }
210
211 func acquireConfigEditLockPath(ctx context.Context, lockPath string) (func(), error) {
212 lockDir := filepath.Dir(lockPath)
213 if err := os.MkdirAll(lockDir, 0o700); err != nil {
214 return nil, fmt.Errorf("lock config edits: create lock directory: %w", err)
215 }
216 info, err := os.Lstat(lockDir)
217 if err != nil {
218 return nil, fmt.Errorf("lock config edits: inspect lock directory: %w", err)
219 }
220 if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
221 return nil, fmt.Errorf("lock config edits: unsafe lock directory")
222 }
223 if err := os.Chmod(lockDir, 0o700); err != nil {
224 return nil, fmt.Errorf("lock config edits: secure lock directory: %w", err)
225 }
226 unlockFile, err := filelock.Acquire(ctx, lockPath)
227 if err != nil {
228 return nil, fmt.Errorf("lock config edits: %w", err)
229 }
230 return unlockFile, nil
231 }
232
233 func configFileEditLockPath(path string) (string, error) {
234 target, err := resolveConfigEditTarget(path)
235 if err != nil {
236 return "", err
237 }
238 return target.lockPath, nil
239 }
240
241 func resolveConfigEditTarget(path string) (configEditTarget, error) {
242 logicalKey, err := configEditPathKey(path)
243 if err != nil {
244 return configEditTarget{}, err
245 }
246 userOwned := isUserConfigPath(path) || samePath(path, legacyConfigPath()) || samePath(path, UserCredentialsPath())
247 resolved, err := resolveConfigAccessPathUnpinned(path, userOwned)
248 if err != nil {
249 return configEditTarget{}, fmt.Errorf("lock config edits: %w", err)
250 }
251 lockPath, err := configFileEditLockPathResolved(resolved)
252 if err != nil {
253 return configEditTarget{}, err
254 }
255 return configEditTarget{
256 logicalKey: logicalKey,
257 resolvedPath: resolved,
258 lockPath: lockPath,
259 }, nil
260 }
261
262 func configFileEditLockPathResolved(resolved string) (string, error) {
263 lockDir, err := configEditLockRegistryDir()
264 if err != nil {
265 return "", err
266 }
267 lockKey := filepath.Clean(resolved)
268 if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
269 lockKey = strings.ToLower(filepath.ToSlash(lockKey))
270 }
271 digest := sha256.Sum256([]byte(lockKey))
272 return filepath.Join(lockDir, fmt.Sprintf("%x.lock", digest)), nil
273 }
274
275 func configEditLockRegistryDir() (string, error) {
276 current, err := osuser.Current()
277 if err != nil {
278 return "", fmt.Errorf("lock config edits: resolve OS user: %w", err)
279 }
280 identity := strings.TrimSpace(current.Uid)
281 if identity == "" {
282 identity = strings.TrimSpace(current.Username)
283 }
284 if identity == "" {
285 identity = strings.TrimSpace(current.HomeDir)
286 }
287 if identity == "" {
288 return "", fmt.Errorf("lock config edits: OS user identity unavailable")
289 }
290 digest := sha256.Sum256([]byte(identity))
291 if runtime.GOOS != "windows" {
292 // The OS-wide temporary root is invariant across process-specific TMPDIR
293 // overrides. The per-user directory is verified and forced to mode 0700
294 // before the advisory lock file is opened.
295 return filepath.Join(string(filepath.Separator), "tmp", fmt.Sprintf("reasonix-config-locks-%x", digest[:8])), nil
296 }
297 home := strings.TrimSpace(current.HomeDir)
298 if home == "" {
299 return "", fmt.Errorf("lock config edits: OS user home unavailable")
300 }
301 return filepath.Join(filepath.Clean(home), ".reasonix", "locks", fmt.Sprintf("config-edits-%x", digest[:8])), nil
302 }
303
304 func configEditPathKey(path string) (string, error) {
305 path = strings.TrimSpace(path)
306 if path == "" {
307 return "", fmt.Errorf("lock config edits: empty config path")
308 }
309 abs, err := filepath.Abs(filepath.Clean(path))
310 if err != nil {
311 return "", fmt.Errorf("lock config edits: resolve path: %w", err)
312 }
313 if runtime.GOOS == "windows" {
314 abs = strings.ToLower(filepath.ToSlash(abs))
315 }
316 return abs, nil
317 }
318
319 func activateConfigEditPins(targets []configEditTarget) func() {
320 configEditPinsMu.Lock()
321 for _, target := range targets {
322 configEditPins[target.logicalKey] = target.resolvedPath
323 }
324 configEditPinsMu.Unlock()
325 var once sync.Once
326 return func() {
327 once.Do(func() {
328 configEditPinsMu.Lock()
329 for _, target := range targets {
330 delete(configEditPins, target.logicalKey)
331 }
332 configEditPinsMu.Unlock()
333 })
334 }
335 }
336
337 func pinnedConfigEditPath(path string) (string, bool) {
338 key, err := configEditPathKey(path)
339 if err != nil {
340 return "", false
341 }
342 configEditPinsMu.RLock()
343 resolved, ok := configEditPins[key]
344 configEditPinsMu.RUnlock()
345 return resolved, ok
346 }
347
348 func currentUserConfigEditLockError() error {
349 if failure := userEditFileLockFailure.Load(); failure != nil {
350 return failure.err
351 }
352 return nil
353 }
354
354 lines GO