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