返回 DeepSeek-Reasonix
credentials.go
根目录 / internal / config / credentials.go
1 package config
2
3 import (
4 "crypto/sha256"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/joho/godotenv"
14
15 "reasonix/internal/fileutil"
16 )
17
18 const (
19 CredentialsStoreAuto = "auto"
20 CredentialsStoreKeyring = "keyring"
21 CredentialsStoreFile = "file"
22
23 credentialsKeyringService = "reasonix"
24 credentialClearedPrefix = "# reasonix-cleared "
25 )
26
27 const (
28 CredentialSourceEnvironment = "environment"
29 CredentialSourceProjectEnv = "project_env"
30 CredentialSourceCredentials = "credentials"
31 CredentialSourceHomeEnv = "home_env"
32 CredentialSourceLegacy = "legacy_credentials"
33 )
34
35 type CredentialSource struct {
36 Kind string `json:"kind"`
37 Path string `json:"path,omitempty"`
38 Label string `json:"label,omitempty"`
39 }
40
41 type CredentialResolution struct {
42 Name string `json:"name"`
43 Set bool `json:"set"`
44 Value string `json:"-"`
45 Source CredentialSource `json:"source,omitempty"`
46 Shadowed []CredentialSource `json:"shadowed,omitempty"`
47 }
48
49 type trackedCredentialSource struct {
50 source CredentialSource
51 value string
52 }
53
54 var credentialSourceTracker = struct {
55 sync.Mutex
56 byKey map[string]trackedCredentialSource
57 }{byKey: map[string]trackedCredentialSource{}}
58
59 // userCredentialEditMu serializes Reasonix-owned credential-store writes.
60 // LockUserCredentialEdits also takes a path-derived advisory file lock so a
61 // Desktop window, CLI process, or background catalog save can share one
62 // compare-and-apply boundary with credential rotation.
63 var userCredentialEditMu sync.Mutex
64
65 var storedCredentialValueLookup = storedCredentialValue
66
67 // legacyKeyringProbeLookup is the test-facing single-key hook returning the full
68 // four-state outcome under a caller-owned context (shared 1s migration budget).
69 var legacyKeyringProbeLookup = legacyKeyringProbe
70
71 // legacyKeyringLookupTimeout is the shared budget for one legacy keyring scan.
72 var legacyKeyringLookupTimeout = time.Second
73
74 // CredentialResolver resolves credentials repeatedly for one caller-owned view
75 // build. It keeps expensive global credential-store lookups bounded to one per
76 // key while preserving the same source/shadow reporting as the one-shot helpers.
77 type CredentialResolver struct {
78 root string
79
80 mu sync.Mutex
81 globalFirstCache map[string]CredentialResolution
82 }
83
84 // NewCredentialResolverForRoot returns a resolver scoped to a workspace root.
85 func NewCredentialResolverForRoot(root string) *CredentialResolver {
86 return &CredentialResolver{root: resolveRoot(root)}
87 }
88
89 // ResolveGlobalFirst resolves key from Reasonix's global .env only. Repeated
90 // calls for the same key reuse the first result so UI views with multiple
91 // provider entries sharing api_key_env stay consistent.
92 func (r *CredentialResolver) ResolveGlobalFirst(key string) CredentialResolution {
93 key = strings.TrimSpace(key)
94 if key == "" {
95 return CredentialResolution{Name: key}
96 }
97 if r == nil {
98 return resolveCredentialForRootGlobalFirst(".", key)
99 }
100
101 r.mu.Lock()
102 defer r.mu.Unlock()
103 if r.globalFirstCache == nil {
104 r.globalFirstCache = map[string]CredentialResolution{}
105 }
106 if cached, ok := r.globalFirstCache[key]; ok {
107 return cloneCredentialResolution(cached)
108 }
109 res := resolveCredentialForRootGlobalFirst(r.root, key)
110 r.globalFirstCache[key] = cloneCredentialResolution(res)
111 return res
112 }
113
114 func cloneCredentialResolution(res CredentialResolution) CredentialResolution {
115 if len(res.Shadowed) > 0 {
116 res.Shadowed = append([]CredentialSource(nil), res.Shadowed...)
117 }
118 return res
119 }
120
121 func normalizeCredentialsStore(mode string) string {
122 switch strings.ToLower(strings.TrimSpace(mode)) {
123 case CredentialsStoreKeyring:
124 return CredentialsStoreKeyring
125 case CredentialsStoreFile:
126 return CredentialsStoreFile
127 default:
128 return CredentialsStoreAuto
129 }
130 }
131
132 func credentialsStoreMode() string {
133 if mode := strings.TrimSpace(os.Getenv("REASONIX_CREDENTIALS_STORE")); mode != "" {
134 return normalizeCredentialsStore(mode)
135 }
136 var partial struct {
137 CredentialsStore string `toml:"credentials_store"`
138 }
139 if path := userConfigLoadPath(); path != "" {
140 _, _ = decodeTOMLFile(path, &partial)
141 }
142 return normalizeCredentialsStore(partial.CredentialsStore)
143 }
144
145 func credentialEnvNamesForRoot(root string) []string {
146 root = resolveRoot(root)
147 cfg := Default()
148
149 projectTOML := "reasonix.toml"
150 if root != "." {
151 projectTOML = filepath.Join(root, "reasonix.toml")
152 }
153 if uc := userConfigLoadPath(); uc != "" {
154 _ = mergeFile(cfg, uc)
155 }
156 _ = mergeFile(cfg, projectTOML)
157 var tomlSources []string
158 if uc := userConfigLoadPath(); uc != "" {
159 tomlSources = append(tomlSources, uc)
160 }
161 tomlSources = append(tomlSources, projectTOML)
162 if providers, _, _, ok, err := mergeTOMLProviders(tomlSources); err == nil && ok {
163 cfg.Providers = providers
164 }
165
166 return credentialEnvNamesFromConfig(cfg)
167 }
168
169 func credentialEnvNamesFromConfig(cfg *Config) []string {
170 seen := map[string]bool{}
171 var out []string
172 add := func(name string) {
173 name = strings.TrimSpace(name)
174 if name == "" || seen[name] {
175 return
176 }
177 seen[name] = true
178 out = append(out, name)
179 }
180 for _, p := range cfg.Providers {
181 add(p.APIKeyEnv)
182 }
183 add(cfg.Bot.QQ.AppSecretEnv)
184 add(cfg.Bot.Feishu.AppSecretEnv)
185 add(cfg.Bot.Weixin.TokenEnv)
186 for _, conn := range cfg.Bot.Connections {
187 add(conn.Credential.AppSecretEnv)
188 add(conn.Credential.TokenEnv)
189 }
190 for _, h := range cfg.Remote.Hosts {
191 add(h.PassphraseEnv)
192 add(h.PasswordEnv)
193 }
194 sort.Strings(out)
195 return out
196 }
197
198 // CredentialEnvNames returns every environment-variable name whose value can
199 // be loaded from Reasonix's global credential store. This includes configured
200 // provider/bot keys and stored keys that are no longer referenced by the
201 // current config: loadCredentialStoreForRoot loads the whole credential file,
202 // so stale entries must remain outside child-process environments too.
203 func (c *Config) CredentialEnvNames() []string {
204 names := credentialEnvNamesFromConfig(c)
205 seen := make(map[string]bool, len(names))
206 for _, name := range names {
207 seen[name] = true
208 }
209 if file, ok := readDotEnvFile(UserCredentialsPath()); ok {
210 for name := range file.Values {
211 name = strings.TrimSpace(name)
212 if !isCredentialKey(name) || seen[name] {
213 continue
214 }
215 seen[name] = true
216 names = append(names, name)
217 }
218 }
219 sort.Strings(names)
220 return names
221 }
222
223 func resolveProviderCredentialsForRoot(root string, cfg *Config) {
224 if cfg == nil || len(cfg.Providers) == 0 {
225 return
226 }
227 resolver := NewCredentialResolverForRoot(root)
228 for i := range cfg.Providers {
229 resolveProviderCredentialWithResolver(&cfg.Providers[i], resolver)
230 }
231 }
232
233 func resolveProviderCredentialWithResolver(entry *ProviderEntry, resolver *CredentialResolver) {
234 if entry == nil {
235 return
236 }
237 key := strings.TrimSpace(entry.APIKeyEnv)
238 if key == "" {
239 entry.resolvedAPIKey = ""
240 entry.resolvedSource = CredentialSource{}
241 return
242 }
243 if resolver == nil {
244 resolver = NewCredentialResolverForRoot(".")
245 }
246 res := resolver.ResolveGlobalFirst(key)
247 if !res.Set || res.Value == "" {
248 entry.resolvedAPIKey = ""
249 entry.resolvedSource = CredentialSource{}
250 return
251 }
252 entry.resolvedAPIKey = res.Value
253 entry.resolvedSource = res.Source
254 }
255
256 func (e *ProviderEntry) ResolveAPIKeyForRoot(root string) {
257 resolveProviderCredentialWithResolver(e, NewCredentialResolverForRoot(root))
258 }
259
260 func loadCredentialStoreForRoot(root string) {
261 names := credentialEnvNamesForRoot(root)
262 if len(names) == 0 {
263 return
264 }
265 if p := UserCredentialsPath(); p != "" {
266 loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials (.env)"})
267 }
268 }
269
270 // StoreCredentialLines stores KEY=value assignments in Reasonix's global .env
271 // and pins them into the current process environment.
272 func StoreCredentialLines(lines []string) (string, error) {
273 assignments := parseCredentialLines(lines)
274 if len(assignments) == 0 {
275 return CredentialsTargetDescription(), nil
276 }
277 unlock, err := LockUserCredentialEdits()
278 if err != nil {
279 return "", err
280 }
281 defer unlock()
282 return storeCredentialAssignmentsLocked(assignments)
283 }
284
285 // storeCredentialIfAbsentAndNotCleared writes key=value only when the current
286 // credential store still lacks a value and a cleared tombstone for key. The
287 // re-check and write share LockUserCredentialEdits so a concurrent settings
288 // save or tombstone cannot be overwritten by a stale keyring import.
289 // stored is false when the write was skipped because the key is already present
290 // or cleared; err is non-nil only on lock/IO failures.
291 func storeCredentialIfAbsentAndNotCleared(key, value string) (stored bool, err error) {
292 key = strings.TrimSpace(key)
293 if key == "" || !isCredentialKey(key) {
294 return false, nil
295 }
296 if strings.ContainsAny(value, "\r\n") {
297 return false, fmt.Errorf("credential value for %s contains a newline", key)
298 }
299 unlock, err := LockUserCredentialEdits()
300 if err != nil {
301 return false, err
302 }
303 defer unlock()
304 if credentialCurrentStoreHasKey(key) || credentialCurrentStoreClearedKey(key) {
305 return false, nil
306 }
307 if _, err := storeCredentialAssignmentsLocked(map[string]string{key: value}); err != nil {
308 return false, err
309 }
310 return true, nil
311 }
312
313 func storeCredentialAssignmentsLocked(assignments map[string]string) (string, error) {
314 if err := storeCredentialsInFile(UserCredentialsPath(), assignments); err != nil {
315 return "", err
316 }
317 pinCredentialAssignments(assignments)
318 return UserCredentialsPath(), nil
319 }
320
321 func SetCredential(key, value string) (string, error) {
322 key = strings.TrimSpace(key)
323 if !isCredentialKey(key) {
324 return "", fmt.Errorf("invalid credential key %q", key)
325 }
326 if strings.ContainsAny(value, "\r\n") {
327 return "", fmt.Errorf("credential value for %s contains a newline", key)
328 }
329 return StoreCredentialLines([]string{key + "=" + value})
330 }
331
332 // SetCredentialIfRevision stores one credential only when the global
333 // credential file still has expectedRevision. The comparison and write share
334 // the same process and advisory file lock, preventing a stale setup page in one
335 // Reasonix process from overwriting a credential saved by another process.
336 func SetCredentialIfRevision(key, value, expectedRevision string) (string, bool, error) {
337 key = strings.TrimSpace(key)
338 if !isCredentialKey(key) {
339 return "", false, fmt.Errorf("invalid credential key %q", key)
340 }
341 if strings.ContainsAny(value, "\r\n") {
342 return "", false, fmt.Errorf("credential value for %s contains a newline", key)
343 }
344 assignments := parseCredentialLines([]string{key + "=" + value})
345 if len(assignments) != 1 {
346 return "", false, fmt.Errorf("invalid credential assignment for %s", key)
347 }
348
349 unlock, err := LockUserCredentialEdits()
350 if err != nil {
351 return "", false, err
352 }
353 defer unlock()
354 if expectedRevision == "" || CredentialStoreRevision() != expectedRevision {
355 return CredentialsTargetDescription(), false, nil
356 }
357 path, err := storeCredentialAssignmentsLocked(assignments)
358 if err != nil {
359 return "", false, err
360 }
361 return path, true, nil
362 }
363
364 // IsValidCredentialKey reports whether key can be stored in Reasonix's dotenv
365 // credential file and exposed as an environment variable.
366 func IsValidCredentialKey(key string) bool {
367 return isCredentialKey(strings.TrimSpace(key))
368 }
369
370 func RemoveCredential(key string) error {
371 key = strings.TrimSpace(key)
372 if key == "" || !isCredentialKey(key) {
373 return nil
374 }
375 unlock, err := LockUserCredentialEdits()
376 if err != nil {
377 return err
378 }
379 defer unlock()
380 if path := UserCredentialsPath(); path != "" {
381 if err := removeCredentialFromFile(path, key); err != nil {
382 return err
383 }
384 }
385 return os.Unsetenv(key)
386 }
387
388 // LockUserCredentialEdits serializes credential-store compare/write
389 // transactions in this process and across Reasonix processes. When both the
390 // user config and credential store are needed, acquire LockUserConfigEdits
391 // first, then this lock.
392 func LockUserCredentialEdits() (func(), error) {
393 userCredentialEditMu.Lock()
394 path := UserCredentialsPath()
395 if strings.TrimSpace(path) == "" {
396 userCredentialEditMu.Unlock()
397 return nil, fmt.Errorf("credentials store unavailable")
398 }
399 unlockFile, err := acquireConfigFileEditLockWithTimeout(path, configEditLockTimeout)
400 if err != nil {
401 userCredentialEditMu.Unlock()
402 return nil, fmt.Errorf("lock credential edits: %w", err)
403 }
404 var once sync.Once
405 return func() {
406 once.Do(func() {
407 unlockFile()
408 userCredentialEditMu.Unlock()
409 })
410 }, nil
411 }
412
413 // CredentialStoreRevision returns a content-derived revision for the current
414 // Reasonix credential store. Callers performing compare-and-apply must hold
415 // LockUserCredentialEdits from this read through their commit.
416 func CredentialStoreRevision() string {
417 path := UserCredentialsPath()
418 if strings.TrimSpace(path) == "" {
419 return "unavailable"
420 }
421 data, err := readCredentialFile(path)
422 if err != nil {
423 if os.IsNotExist(err) {
424 return "missing"
425 }
426 return "unreadable"
427 }
428 sum := sha256.Sum256(data)
429 return fmt.Sprintf("sha256:%x", sum[:])
430 }
431
432 func CredentialIsSet(key string) bool {
433 key = strings.TrimSpace(key)
434 if key == "" {
435 return false
436 }
437 return CredentialStored(key)
438 }
439
440 func CredentialStored(key string) bool {
441 key = strings.TrimSpace(key)
442 if key == "" {
443 return false
444 }
445 return envFileHasValue(UserCredentialsPath(), key)
446 }
447
448 func credentialCurrentStoreHasKey(key string) bool {
449 key = strings.TrimSpace(key)
450 if key == "" {
451 return false
452 }
453 return envFileHasValue(UserCredentialsPath(), key)
454 }
455
456 func credentialCurrentStoreClearedKey(key string) bool {
457 key = strings.TrimSpace(key)
458 if key == "" {
459 return false
460 }
461 return envFileHasClearedKey(UserCredentialsPath(), key)
462 }
463
464 func CredentialsTargetDescription() string {
465 return UserCredentialsPath()
466 }
467
468 func parseCredentialLines(lines []string) map[string]string {
469 out := map[string]string{}
470 for _, raw := range lines {
471 if strings.ContainsAny(raw, "\r\n") {
472 continue
473 }
474 values, err := godotenv.Unmarshal(raw)
475 if err != nil {
476 continue
477 }
478 for key, value := range values {
479 key = strings.TrimSpace(key)
480 if !isCredentialKey(key) || strings.ContainsAny(value, "\r\n") {
481 continue
482 }
483 out[key] = value
484 }
485 }
486 return out
487 }
488
489 func pinCredentialAssignments(assignments map[string]string) {
490 for key, value := range assignments {
491 _ = os.Setenv(key, value)
492 recordCredentialSource(key, value, CredentialSource{Kind: CredentialSourceCredentials, Path: UserCredentialsPath(), Label: "Reasonix credentials (.env)"})
493 }
494 }
495
496 func recordExistingCredentialSource(key string) {
497 key = strings.TrimSpace(key)
498 value := os.Getenv(key)
499 if key == "" || value == "" {
500 return
501 }
502 credentialSourceTracker.Lock()
503 defer credentialSourceTracker.Unlock()
504 if current, ok := credentialSourceTracker.byKey[key]; ok && current.value == value {
505 return
506 }
507 if _, ok := credentialSourceTracker.byKey[key]; ok {
508 return
509 }
510 credentialSourceTracker.byKey[key] = trackedCredentialSource{
511 source: CredentialSource{Kind: CredentialSourceEnvironment, Label: "environment variable"},
512 value: value,
513 }
514 }
515
516 func recordCredentialSource(key, value string, source CredentialSource) {
517 key = strings.TrimSpace(key)
518 if key == "" || value == "" {
519 return
520 }
521 source.Label = credentialSourceLabel(source)
522 credentialSourceTracker.Lock()
523 credentialSourceTracker.byKey[key] = trackedCredentialSource{source: source, value: value}
524 credentialSourceTracker.Unlock()
525 }
526
527 func trackedCredential(key, value string) (CredentialSource, bool) {
528 credentialSourceTracker.Lock()
529 defer credentialSourceTracker.Unlock()
530 current, ok := credentialSourceTracker.byKey[key]
531 if !ok || current.value != value {
532 return CredentialSource{}, false
533 }
534 return current.source, true
535 }
536
537 func credentialSourceLabel(source CredentialSource) string {
538 if strings.TrimSpace(source.Label) != "" {
539 return source.Label
540 }
541 switch source.Kind {
542 case CredentialSourceProjectEnv:
543 return "project .env"
544 case CredentialSourceCredentials:
545 return "Reasonix credentials"
546 case CredentialSourceHomeEnv:
547 return "home .env"
548 case CredentialSourceLegacy:
549 return "legacy Reasonix credentials"
550 case CredentialSourceEnvironment:
551 return "environment variable"
552 default:
553 return ""
554 }
555 }
556
557 func ResolveCredential(key string) CredentialResolution {
558 return ResolveCredentialForRoot(".", key)
559 }
560
561 func ResolveCredentialForRoot(root, key string) CredentialResolution {
562 key = strings.TrimSpace(key)
563 res := CredentialResolution{Name: key}
564 if key == "" {
565 return res
566 }
567 value := os.Getenv(key)
568 if value == "" {
569 return res
570 }
571 res.Set = true
572 res.Value = value
573 if source, ok := trackedCredential(key, value); ok {
574 res.Source = source
575 } else if source, ok := inferCredentialSource(root, key, value); ok {
576 res.Source = source
577 } else {
578 res.Source = CredentialSource{Kind: CredentialSourceEnvironment, Label: credentialSourceLabel(CredentialSource{Kind: CredentialSourceEnvironment})}
579 }
580 res.Source.Label = credentialSourceLabel(res.Source)
581 res.Shadowed = shadowedCredentialSources(root, key, value, res.Source)
582 return res
583 }
584
585 func ResolveCredentialForRootGlobalFirst(root, key string) CredentialResolution {
586 key = strings.TrimSpace(key)
587 return NewCredentialResolverForRoot(root).ResolveGlobalFirst(key)
588 }
589
590 func resolveCredentialForRootGlobalFirst(root, key string) CredentialResolution {
591 root = resolveRoot(root)
592 res := CredentialResolution{Name: key}
593 if key == "" {
594 return res
595 }
596 if value, source, ok := storedCredentialValueLookup(key); ok {
597 res.Set = true
598 res.Value = value
599 res.Source = source
600 res.Source.Label = credentialSourceLabel(res.Source)
601 res.Shadowed = shadowedCredentialSources(root, key, value, res.Source)
602 return res
603 }
604 return res
605 }
606
607 func storedCredentialValue(key string) (string, CredentialSource, bool) {
608 if p := UserCredentialsPath(); p != "" {
609 if value, ok := envFileValue(p, key); ok && value != "" {
610 return value, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials (.env)"}, true
611 }
612 }
613 return "", CredentialSource{}, false
614 }
615
616 func inferCredentialSource(root, key, value string) (CredentialSource, bool) {
617 for _, candidate := range credentialSourceCandidates(root) {
618 if v, ok := envFileValue(candidate.Path, key); ok && v == value {
619 candidate.Label = credentialSourceLabel(candidate)
620 return candidate, true
621 }
622 }
623 return CredentialSource{}, false
624 }
625
626 func shadowedCredentialSources(root, key, activeValue string, active CredentialSource) []CredentialSource {
627 var out []CredentialSource
628 for _, candidate := range credentialSourceCandidates(root) {
629 if sameCredentialSource(candidate, active) {
630 continue
631 }
632 if v, ok := envFileValue(candidate.Path, key); ok && v != activeValue {
633 candidate.Label = credentialSourceLabel(candidate)
634 out = append(out, candidate)
635 }
636 }
637 return out
638 }
639
640 func credentialSourceCandidates(root string) []CredentialSource {
641 root = resolveRoot(root)
642 var out []CredentialSource
643 dotEnvPath := ".env"
644 if root != "" && root != "." {
645 dotEnvPath = filepath.Join(root, ".env")
646 }
647 out = append(out, CredentialSource{Kind: CredentialSourceProjectEnv, Path: dotEnvPath})
648 if p := UserCredentialsPath(); p != "" {
649 out = append(out, CredentialSource{Kind: CredentialSourceCredentials, Path: p})
650 }
651 if IsolatedHomeDir() == "" {
652 if home, err := os.UserHomeDir(); err == nil {
653 out = append(out, CredentialSource{Kind: CredentialSourceHomeEnv, Path: filepath.Join(home, ".env")})
654 }
655 }
656 return out
657 }
658
659 func sameCredentialSource(a, b CredentialSource) bool {
660 if a.Kind != b.Kind {
661 return false
662 }
663 if a.Path == "" || b.Path == "" {
664 return a.Path == b.Path
665 }
666 return samePath(a.Path, b.Path)
667 }
668
669 func storeCredentialsInFile(path string, assignments map[string]string) error {
670 if strings.TrimSpace(path) == "" {
671 return fmt.Errorf("credentials store unavailable")
672 }
673 lines, err := readCredentialFileLinesForWrite(path)
674 if err != nil {
675 return err
676 }
677 filtered := make([]string, 0, len(lines))
678 for _, line := range lines {
679 if key, ok := credentialClearedLineKey(line); ok {
680 if _, hit := assignments[key]; hit {
681 continue
682 }
683 }
684 filtered = append(filtered, line)
685 }
686 lines = filtered
687 replaced := map[string]bool{}
688 for i, line := range lines {
689 key, ok := credentialLineKey(line)
690 if !ok {
691 continue
692 }
693 if value, hit := assignments[key]; hit {
694 lines[i] = formatCredentialLine(key, value)
695 replaced[key] = true
696 }
697 }
698 keys := make([]string, 0, len(assignments))
699 for key := range assignments {
700 keys = append(keys, key)
701 }
702 sort.Strings(keys)
703 for _, key := range keys {
704 if !replaced[key] {
705 lines = append(lines, formatCredentialLine(key, assignments[key]))
706 }
707 }
708 return writeCredentialFileLines(path, lines)
709 }
710
711 func formatCredentialLine(key, value string) string {
712 if isBareDotEnvValue(value) {
713 return key + "=" + value
714 }
715 line, err := godotenv.Marshal(map[string]string{key: value})
716 if err != nil {
717 return key + "=" + value
718 }
719 return line
720 }
721
722 func isBareDotEnvValue(value string) bool {
723 if value == "" {
724 return true
725 }
726 return !strings.ContainsAny(value, " \t\r\n#'\"\\")
727 }
728
729 func removeCredentialFromFile(path, key string) error {
730 lines, err := readCredentialFileLinesForWrite(path)
731 if err != nil {
732 return err
733 }
734 out := make([]string, 0, len(lines))
735 for _, line := range lines {
736 if k, ok := credentialLineKey(line); ok && k == key {
737 continue
738 }
739 if k, ok := credentialClearedLineKey(line); ok && k == key {
740 continue
741 }
742 out = append(out, line)
743 }
744 out = append(out, credentialClearedPrefix+key)
745 return writeCredentialFileLines(path, out)
746 }
747
748 func writeCredentialFileLines(path string, lines []string) error {
749 if strings.TrimSpace(path) == "" {
750 return fmt.Errorf("credentials store unavailable")
751 }
752 out := ""
753 if len(lines) > 0 {
754 out = strings.Join(lines, "\n") + "\n"
755 }
756 dir := filepath.Dir(path)
757 if dir != "" && dir != "." {
758 if err := os.MkdirAll(dir, 0o700); err != nil {
759 return err
760 }
761 }
762 return fileutil.AtomicWriteFileStrict(path, []byte(out), 0o600)
763 }
764
765 func credentialLineKey(line string) (string, bool) {
766 trimmed := strings.TrimPrefix(strings.TrimSpace(line), "export ")
767 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
768 return "", false
769 }
770 key, _, ok := strings.Cut(trimmed, "=")
771 key = strings.TrimSpace(key)
772 return key, ok && isCredentialKey(key)
773 }
774
775 func credentialClearedLineKey(line string) (string, bool) {
776 trimmed := strings.TrimSpace(line)
777 if !strings.HasPrefix(trimmed, credentialClearedPrefix) {
778 return "", false
779 }
780 key := strings.TrimSpace(strings.TrimPrefix(trimmed, credentialClearedPrefix))
781 return key, isCredentialKey(key)
782 }
783
784 func isCredentialKey(key string) bool {
785 if key == "" {
786 return false
787 }
788 for i, r := range key {
789 if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
790 continue
791 }
792 return false
793 }
794 return true
795 }
796
797 func envFileHasValue(path, key string) bool {
798 if strings.TrimSpace(path) == "" {
799 return false
800 }
801 value, ok := envFileValue(path, key)
802 return ok && strings.TrimSpace(value) != ""
803 }
804
805 func envFileHasClearedKey(path, key string) bool {
806 if strings.TrimSpace(path) == "" {
807 return false
808 }
809 lines, err := readCredentialFileLines(path)
810 if err != nil {
811 return false
812 }
813 for _, line := range lines {
814 if k, ok := credentialClearedLineKey(line); ok && k == key {
815 return true
816 }
817 }
818 return false
819 }
820
820 lines GO