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