Files
2026-07-13 13:00:08 +08:00

697 lines
18 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/joho/godotenv"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
)
const (
CredentialsStoreAuto = "auto"
CredentialsStoreKeyring = "keyring"
CredentialsStoreFile = "file"
credentialsKeyringService = "reasonix"
credentialClearedPrefix = "# reasonix-cleared "
)
const (
CredentialSourceEnvironment = "environment"
CredentialSourceProjectEnv = "project_env"
CredentialSourceCredentials = "credentials"
CredentialSourceHomeEnv = "home_env"
CredentialSourceLegacy = "legacy_credentials"
)
type CredentialSource struct {
Kind string `json:"kind"`
Path string `json:"path,omitempty"`
Label string `json:"label,omitempty"`
}
type CredentialResolution struct {
Name string `json:"name"`
Set bool `json:"set"`
Value string `json:"-"`
Source CredentialSource `json:"source,omitempty"`
Shadowed []CredentialSource `json:"shadowed,omitempty"`
}
type trackedCredentialSource struct {
source CredentialSource
value string
}
var credentialSourceTracker = struct {
sync.Mutex
byKey map[string]trackedCredentialSource
}{byKey: map[string]trackedCredentialSource{}}
var storedCredentialValueLookup = storedCredentialValue
var legacyKeyringCredentialValueLookup = legacyKeyringCredentialValue
// CredentialResolver resolves credentials repeatedly for one caller-owned view
// build. It keeps expensive global credential-store lookups bounded to one per
// key while preserving the same source/shadow reporting as the one-shot helpers.
type CredentialResolver struct {
root string
mu sync.Mutex
globalFirstCache map[string]CredentialResolution
}
// NewCredentialResolverForRoot returns a resolver scoped to a workspace root.
func NewCredentialResolverForRoot(root string) *CredentialResolver {
return &CredentialResolver{root: resolveRoot(root)}
}
// ResolveGlobalFirst resolves key from Reasonix's global .env only. Repeated
// calls for the same key reuse the first result so UI views with multiple
// provider entries sharing api_key_env stay consistent.
func (r *CredentialResolver) ResolveGlobalFirst(key string) CredentialResolution {
key = strings.TrimSpace(key)
if key == "" {
return CredentialResolution{Name: key}
}
if r == nil {
return resolveCredentialForRootGlobalFirst(".", key)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.globalFirstCache == nil {
r.globalFirstCache = map[string]CredentialResolution{}
}
if cached, ok := r.globalFirstCache[key]; ok {
return cloneCredentialResolution(cached)
}
res := resolveCredentialForRootGlobalFirst(r.root, key)
r.globalFirstCache[key] = cloneCredentialResolution(res)
return res
}
func cloneCredentialResolution(res CredentialResolution) CredentialResolution {
if len(res.Shadowed) > 0 {
res.Shadowed = append([]CredentialSource(nil), res.Shadowed...)
}
return res
}
func normalizeCredentialsStore(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case CredentialsStoreKeyring:
return CredentialsStoreKeyring
case CredentialsStoreFile:
return CredentialsStoreFile
default:
return CredentialsStoreAuto
}
}
func credentialsStoreMode() string {
if mode := strings.TrimSpace(os.Getenv("REASONIX_CREDENTIALS_STORE")); mode != "" {
return normalizeCredentialsStore(mode)
}
var partial struct {
CredentialsStore string `toml:"credentials_store"`
}
if path := userConfigLoadPath(); path != "" {
_, _ = decodeTOMLFile(path, &partial)
}
return normalizeCredentialsStore(partial.CredentialsStore)
}
func credentialEnvNamesForRoot(root string) []string {
root = resolveRoot(root)
cfg := Default()
projectTOML := "reasonix.toml"
if root != "." {
projectTOML = filepath.Join(root, "reasonix.toml")
}
if uc := userConfigLoadPath(); uc != "" {
_ = mergeFile(cfg, uc)
}
_ = mergeFile(cfg, projectTOML)
var tomlSources []string
if uc := userConfigLoadPath(); uc != "" {
tomlSources = append(tomlSources, uc)
}
tomlSources = append(tomlSources, projectTOML)
if providers, _, _, ok, err := mergeTOMLProviders(tomlSources); err == nil && ok {
cfg.Providers = providers
}
return credentialEnvNamesFromConfig(cfg)
}
func credentialEnvNamesFromConfig(cfg *Config) []string {
seen := map[string]bool{}
var out []string
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
seen[name] = true
out = append(out, name)
}
for _, p := range cfg.Providers {
add(p.APIKeyEnv)
}
add(cfg.Bot.QQ.AppSecretEnv)
add(cfg.Bot.Feishu.AppSecretEnv)
add(cfg.Bot.Weixin.TokenEnv)
for _, conn := range cfg.Bot.Connections {
add(conn.Credential.AppSecretEnv)
add(conn.Credential.TokenEnv)
}
sort.Strings(out)
return out
}
func resolveProviderCredentialsForRoot(root string, cfg *Config) {
if cfg == nil || len(cfg.Providers) == 0 {
return
}
resolver := NewCredentialResolverForRoot(root)
for i := range cfg.Providers {
resolveProviderCredentialWithResolver(&cfg.Providers[i], resolver)
}
}
func resolveProviderCredentialWithResolver(entry *ProviderEntry, resolver *CredentialResolver) {
if entry == nil {
return
}
key := strings.TrimSpace(entry.APIKeyEnv)
if key == "" {
entry.resolvedAPIKey = ""
entry.resolvedSource = CredentialSource{}
return
}
if resolver == nil {
resolver = NewCredentialResolverForRoot(".")
}
res := resolver.ResolveGlobalFirst(key)
if !res.Set || res.Value == "" {
entry.resolvedAPIKey = ""
entry.resolvedSource = CredentialSource{}
return
}
entry.resolvedAPIKey = res.Value
entry.resolvedSource = res.Source
}
func (e *ProviderEntry) ResolveAPIKeyForRoot(root string) {
resolveProviderCredentialWithResolver(e, NewCredentialResolverForRoot(root))
}
func loadCredentialStoreForRoot(root string) {
names := credentialEnvNamesForRoot(root)
if len(names) == 0 {
return
}
if p := UserCredentialsPath(); p != "" {
loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials (.env)"})
}
}
// StoreCredentialLines stores KEY=value assignments in Reasonix's global .env
// and pins them into the current process environment.
func StoreCredentialLines(lines []string) (string, error) {
assignments := parseCredentialLines(lines)
if len(assignments) == 0 {
return CredentialsTargetDescription(), nil
}
if err := storeCredentialsInFile(UserCredentialsPath(), assignments); err != nil {
return "", err
}
pinCredentialAssignments(assignments)
return UserCredentialsPath(), nil
}
func SetCredential(key, value string) (string, error) {
key = strings.TrimSpace(key)
if !isCredentialKey(key) {
return "", fmt.Errorf("invalid credential key %q", key)
}
if strings.ContainsAny(value, "\r\n") {
return "", fmt.Errorf("credential value for %s contains a newline", key)
}
return StoreCredentialLines([]string{key + "=" + value})
}
// IsValidCredentialKey reports whether key can be stored in Reasonix's dotenv
// credential file and exposed as an environment variable.
func IsValidCredentialKey(key string) bool {
return isCredentialKey(strings.TrimSpace(key))
}
func RemoveCredential(key string) error {
key = strings.TrimSpace(key)
if key == "" || !isCredentialKey(key) {
return nil
}
if path := UserCredentialsPath(); path != "" {
if err := removeCredentialFromFile(path, key); err != nil {
return err
}
}
return os.Unsetenv(key)
}
func CredentialIsSet(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
return CredentialStored(key)
}
func CredentialStored(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
return envFileHasValue(UserCredentialsPath(), key)
}
func credentialCurrentStoreHasKey(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
return envFileHasValue(UserCredentialsPath(), key)
}
func credentialCurrentStoreClearedKey(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
return envFileHasClearedKey(UserCredentialsPath(), key)
}
func CredentialsTargetDescription() string {
return UserCredentialsPath()
}
func parseCredentialLines(lines []string) map[string]string {
out := map[string]string{}
for _, raw := range lines {
if strings.ContainsAny(raw, "\r\n") {
continue
}
values, err := godotenv.Unmarshal(raw)
if err != nil {
continue
}
for key, value := range values {
key = strings.TrimSpace(key)
if !isCredentialKey(key) || strings.ContainsAny(value, "\r\n") {
continue
}
out[key] = value
}
}
return out
}
func pinCredentialAssignments(assignments map[string]string) {
for key, value := range assignments {
_ = os.Setenv(key, value)
recordCredentialSource(key, value, CredentialSource{Kind: CredentialSourceCredentials, Path: UserCredentialsPath(), Label: "Reasonix credentials (.env)"})
}
}
func recordExistingCredentialSource(key string) {
key = strings.TrimSpace(key)
value := os.Getenv(key)
if key == "" || value == "" {
return
}
credentialSourceTracker.Lock()
defer credentialSourceTracker.Unlock()
if current, ok := credentialSourceTracker.byKey[key]; ok && current.value == value {
return
}
if _, ok := credentialSourceTracker.byKey[key]; ok {
return
}
credentialSourceTracker.byKey[key] = trackedCredentialSource{
source: CredentialSource{Kind: CredentialSourceEnvironment, Label: "environment variable"},
value: value,
}
}
func recordCredentialSource(key, value string, source CredentialSource) {
key = strings.TrimSpace(key)
if key == "" || value == "" {
return
}
source.Label = credentialSourceLabel(source)
credentialSourceTracker.Lock()
credentialSourceTracker.byKey[key] = trackedCredentialSource{source: source, value: value}
credentialSourceTracker.Unlock()
}
func trackedCredential(key, value string) (CredentialSource, bool) {
credentialSourceTracker.Lock()
defer credentialSourceTracker.Unlock()
current, ok := credentialSourceTracker.byKey[key]
if !ok || current.value != value {
return CredentialSource{}, false
}
return current.source, true
}
func credentialSourceLabel(source CredentialSource) string {
if strings.TrimSpace(source.Label) != "" {
return source.Label
}
switch source.Kind {
case CredentialSourceProjectEnv:
return "project .env"
case CredentialSourceCredentials:
return "Reasonix credentials"
case CredentialSourceHomeEnv:
return "home .env"
case CredentialSourceLegacy:
return "legacy Reasonix credentials"
case CredentialSourceEnvironment:
return "environment variable"
default:
return ""
}
}
func ResolveCredential(key string) CredentialResolution {
return ResolveCredentialForRoot(".", key)
}
func ResolveCredentialForRoot(root, key string) CredentialResolution {
key = strings.TrimSpace(key)
res := CredentialResolution{Name: key}
if key == "" {
return res
}
value := os.Getenv(key)
if value == "" {
return res
}
res.Set = true
res.Value = value
if source, ok := trackedCredential(key, value); ok {
res.Source = source
} else if source, ok := inferCredentialSource(root, key, value); ok {
res.Source = source
} else {
res.Source = CredentialSource{Kind: CredentialSourceEnvironment, Label: credentialSourceLabel(CredentialSource{Kind: CredentialSourceEnvironment})}
}
res.Source.Label = credentialSourceLabel(res.Source)
res.Shadowed = shadowedCredentialSources(root, key, value, res.Source)
return res
}
func ResolveCredentialForRootGlobalFirst(root, key string) CredentialResolution {
key = strings.TrimSpace(key)
return NewCredentialResolverForRoot(root).ResolveGlobalFirst(key)
}
func resolveCredentialForRootGlobalFirst(root, key string) CredentialResolution {
root = resolveRoot(root)
res := CredentialResolution{Name: key}
if key == "" {
return res
}
if value, source, ok := storedCredentialValueLookup(key); ok {
res.Set = true
res.Value = value
res.Source = source
res.Source.Label = credentialSourceLabel(res.Source)
res.Shadowed = shadowedCredentialSources(root, key, value, res.Source)
return res
}
return res
}
func storedCredentialValue(key string) (string, CredentialSource, bool) {
if p := UserCredentialsPath(); p != "" {
if value, ok := envFileValue(p, key); ok && value != "" {
return value, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials (.env)"}, true
}
}
return "", CredentialSource{}, false
}
func inferCredentialSource(root, key, value string) (CredentialSource, bool) {
for _, candidate := range credentialSourceCandidates(root) {
if v, ok := envFileValue(candidate.Path, key); ok && v == value {
candidate.Label = credentialSourceLabel(candidate)
return candidate, true
}
}
return CredentialSource{}, false
}
func shadowedCredentialSources(root, key, activeValue string, active CredentialSource) []CredentialSource {
var out []CredentialSource
for _, candidate := range credentialSourceCandidates(root) {
if sameCredentialSource(candidate, active) {
continue
}
if v, ok := envFileValue(candidate.Path, key); ok && v != activeValue {
candidate.Label = credentialSourceLabel(candidate)
out = append(out, candidate)
}
}
return out
}
func credentialSourceCandidates(root string) []CredentialSource {
root = resolveRoot(root)
var out []CredentialSource
dotEnvPath := ".env"
if root != "" && root != "." {
dotEnvPath = filepath.Join(root, ".env")
}
out = append(out, CredentialSource{Kind: CredentialSourceProjectEnv, Path: dotEnvPath})
if p := UserCredentialsPath(); p != "" {
out = append(out, CredentialSource{Kind: CredentialSourceCredentials, Path: p})
}
if IsolatedHomeDir() == "" {
if home, err := os.UserHomeDir(); err == nil {
out = append(out, CredentialSource{Kind: CredentialSourceHomeEnv, Path: filepath.Join(home, ".env")})
}
}
return out
}
func sameCredentialSource(a, b CredentialSource) bool {
if a.Kind != b.Kind {
return false
}
if a.Path == "" || b.Path == "" {
return a.Path == b.Path
}
return samePath(a.Path, b.Path)
}
func storeCredentialsInFile(path string, assignments map[string]string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("credentials store unavailable")
}
lines, err := readCredentialFileLines(path)
if err != nil {
return err
}
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if key, ok := credentialClearedLineKey(line); ok {
if _, hit := assignments[key]; hit {
continue
}
}
filtered = append(filtered, line)
}
lines = filtered
replaced := map[string]bool{}
for i, line := range lines {
key, ok := credentialLineKey(line)
if !ok {
continue
}
if value, hit := assignments[key]; hit {
lines[i] = formatCredentialLine(key, value)
replaced[key] = true
}
}
keys := make([]string, 0, len(assignments))
for key := range assignments {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if !replaced[key] {
lines = append(lines, formatCredentialLine(key, assignments[key]))
}
}
return writeCredentialFileLines(path, lines)
}
func formatCredentialLine(key, value string) string {
if isBareDotEnvValue(value) {
return key + "=" + value
}
line, err := godotenv.Marshal(map[string]string{key: value})
if err != nil {
return key + "=" + value
}
return line
}
func isBareDotEnvValue(value string) bool {
if value == "" {
return true
}
return !strings.ContainsAny(value, " \t\r\n#'\"\\")
}
func removeCredentialFromFile(path, key string) error {
lines, err := readCredentialFileLines(path)
if err != nil {
return err
}
out := make([]string, 0, len(lines))
for _, line := range lines {
if k, ok := credentialLineKey(line); ok && k == key {
continue
}
if k, ok := credentialClearedLineKey(line); ok && k == key {
continue
}
out = append(out, line)
}
out = append(out, credentialClearedPrefix+key)
return writeCredentialFileLines(path, out)
}
func readCredentialFileLines(path string) ([]string, error) {
data, err := fileencoding.ReadFileUTF8(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
text := strings.TrimRight(string(data), "\n")
if text == "" {
return nil, nil
}
return strings.Split(text, "\n"), nil
}
func writeCredentialFileLines(path string, lines []string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("credentials store unavailable")
}
out := ""
if len(lines) > 0 {
out = strings.Join(lines, "\n") + "\n"
}
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.WriteString(out); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Chmod(tmpPath, 0o600); err != nil {
os.Remove(tmpPath)
return err
}
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
func credentialLineKey(line string) (string, bool) {
trimmed := strings.TrimPrefix(strings.TrimSpace(line), "export ")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return "", false
}
key, _, ok := strings.Cut(trimmed, "=")
key = strings.TrimSpace(key)
return key, ok && isCredentialKey(key)
}
func credentialClearedLineKey(line string) (string, bool) {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, credentialClearedPrefix) {
return "", false
}
key := strings.TrimSpace(strings.TrimPrefix(trimmed, credentialClearedPrefix))
return key, isCredentialKey(key)
}
func isCredentialKey(key string) bool {
if key == "" {
return false
}
for i, r := range key {
if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
continue
}
return false
}
return true
}
func envFileHasValue(path, key string) bool {
if strings.TrimSpace(path) == "" {
return false
}
value, ok := envFileValue(path, key)
return ok && strings.TrimSpace(value) != ""
}
func envFileHasClearedKey(path, key string) bool {
if strings.TrimSpace(path) == "" {
return false
}
lines, err := readCredentialFileLines(path)
if err != nil {
return false
}
for _, line := range lines {
if k, ok := credentialClearedLineKey(line); ok && k == key {
return true
}
}
return false
}