Files
picoclaw/web/backend/api/config.go
T
SiYue-ZO ef002d9a5d fix: ensure dm_scope and dimensions stay in sync across all config paths
The reviewer identified two bugs in the original PR:

1. PATCH /api/config leaves session.dimensions stale: LoadConfig()
   derives dimensions from the old dm_scope, and the merge carries
   those stale dimensions forward. ApplyDmScope() then exits early
   because dimensions is already populated, causing a mismatch between
   dm_scope (new) and dimensions (old).

2. Legacy/default configs omit dm_scope in GET response: configs with
   explicit dimensions but no dm_scope (including DefaultConfig) return
   no dm_scope field, causing the frontend to fall back to its default
   ('per-channel-peer'), which may not match the actual dimensions.

Fix:
- Add DeriveDmScope() to reverse-map known dimensions arrays to
  dm_scope when dm_scope is empty.
- Call it in LoadConfig(), PUT handler, PATCH handler, and
  ResetToDefaults() for consistent normalization.
- In PATCH handler, clear stale dimensions from the merge result when
  the patch contains session.dm_scope but not session.dimensions,
  allowing ApplyDmScope() to re-derive from the new scope.
- Add comprehensive unit tests for DeriveDmScope() and scope
  transition scenarios.
2026-06-11 16:12:48 +08:00

821 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
mux.HandleFunc("POST /api/config/reset", h.handleResetConfig)
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
func (h *Handler) applyRuntimeLogLevel() {
if h.debug {
logger.SetLevel(logger.DEBUG)
return
}
logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
}
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(cfg); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// handleUpdateConfig updates the complete system configuration.
//
// PUT /api/config
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var raw map[string]any
if err = json.Unmarshal(body, &raw); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err = normalizeChannelArrayFields(raw); err != nil {
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
return
}
normalizedBody, err := json.Marshal(raw)
if err != nil {
http.Error(w, "Failed to normalize config payload", http.StatusBadRequest)
return
}
var cfg config.Config
if err = json.Unmarshal(normalizedBody, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
cfg.Session.ApplyDmScope()
cfg.Session.DeriveDmScope()
if execAllowRemoteOmitted(body) {
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
}
// Load existing config and copy security credentials before validation,
// so that security-managed fields (e.g. pico token) are available.
err = cfg.SecurityCopyFrom(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
applyConfigSecretsFromMap(&cfg, raw)
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
h.applyRuntimeLogLevel()
logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func execAllowRemoteOmitted(body []byte) bool {
var raw struct {
Tools *struct {
Exec *struct {
AllowRemote *bool `json:"allow_remote"`
} `json:"exec"`
} `json:"tools"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return false
}
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//
// PATCH /api/config
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Validate the patch is valid JSON
var patch map[string]any
if err = json.Unmarshal(patchBody, &patch); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
// Load existing config and marshal to a map for merging
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
return
}
var base map[string]any
if err = json.Unmarshal(existing, &base); err != nil {
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
return
}
// Recursively merge patch into base
mergeMap(base, patch)
// When the patch updates dm_scope, the old derived dimensions from the
// base must be cleared so that ApplyDmScope() can re-derive them from
// the new dm_scope value. Otherwise the stale dimensions survive the
// merge and ApplyDmScope() exits early due to its precedence guard.
if sess, ok := base["session"].(map[string]any); ok {
if patchSess, patchHasSession := patch["session"].(map[string]any); patchHasSession {
if _, hasDmScope := patchSess["dm_scope"]; hasDmScope {
if _, hasDimsInPatch := patchSess["dimensions"]; !hasDimsInPatch {
delete(sess, "dimensions")
}
}
}
}
if err = normalizeChannelArrayFields(base); err != nil {
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
return
}
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
if err != nil {
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
return
}
var newCfg config.Config
if err = json.Unmarshal(merged, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
return
}
newCfg.Session.ApplyDmScope()
newCfg.Session.DeriveDmScope()
// Restore security fields (tokens/keys) from the loaded config before validation,
// because private fields are lost during JSON round-trip.
if err = newCfg.SecurityCopyFrom(h.configPath); err != nil {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
applyConfigSecretsFromMap(&newCfg, base)
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
h.applyRuntimeLogLevel()
logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleResetConfig resets the configuration to factory defaults.
// API keys and security credentials are preserved.
//
// POST /api/config/reset
func (h *Handler) handleResetConfig(w http.ResponseWriter, r *http.Request) {
if err := config.ResetToDefaults(h.configPath); err != nil {
http.Error(w, fmt.Sprintf("Failed to reset config: %v", err), http.StatusInternalServerError)
return
}
h.applyRuntimeLogLevel()
logger.Infof("configuration reset to factory defaults")
// Restart gateway if running
status := h.gatewayStatusData()
gatewayStatus, _ := status["gateway_status"].(string)
if gatewayStatus == "running" {
if _, err := h.RestartGateway(); err != nil {
logger.ErrorF("failed to restart gateway after config reset", map[string]any{"error": err.Error()})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
//
// POST /api/config/test-command-patterns
func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
AllowPatterns []string `json:"allow_patterns"`
DenyPatterns []string `json:"deny_patterns"`
Command string `json:"command"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
lower := strings.ToLower(strings.TrimSpace(req.Command))
type result struct {
Allowed bool `json:"allowed"`
Blocked bool `json:"blocked"`
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
}
resp := result{Allowed: false, Blocked: false}
// Check whitelist first
for _, pattern := range req.AllowPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue // skip invalid patterns
}
if re.MatchString(lower) {
resp.Allowed = true
resp.MatchedWhitelist = &pattern
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
}
// Check blacklist
for _, pattern := range req.DenyPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
if re.MatchString(lower) {
resp.Blocked = true
resp.MatchedBlacklist = &pattern
break
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
var errs []string
// Validate model_list entries
if err := cfg.ValidateModelList(); err != nil {
errs = append(errs, err.Error())
}
if err := cfg.ValidateTurnProfile(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
}
for name, bc := range cfg.Channels {
streaming, ok := channelStreamingConfig(bc)
if !ok {
continue
}
if streaming.ThrottleSeconds < 0 {
errs = append(errs, fmt.Sprintf("channel %q streaming.throttle_seconds must be >= 0", name))
}
if streaming.MinGrowthChars < 0 {
errs = append(errs, fmt.Sprintf("channel %q streaming.min_growth_chars must be >= 0", name))
}
}
// Pico channel: token required when enabled
{
bc := cfg.Channels.GetByType(config.ChannelPico)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.PicoSettings); ok && c.Token.String() == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
}
}
}
// Telegram: token required when enabled
{
bc := cfg.Channels.GetByType(config.ChannelTelegram)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.TelegramSettings); ok && c.Token.String() == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
}
}
}
// Discord: token required when enabled
{
bc := cfg.Channels.GetByType(config.ChannelDiscord)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.DiscordSettings); ok && c.Token.String() == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
}
}
}
{
bc := cfg.Channels.GetByType(config.ChannelWeCom)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.WeComSettings); ok {
if c.BotID == "" {
errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
}
if c.Secret.String() == "" {
errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
}
}
}
}
}
if cfg.Tools.Exec.Enabled {
if cfg.Tools.Exec.EnableDenyPatterns {
errs = append(
errs,
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
}
errs = append(
errs,
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
}
return errs
}
func validateRegexPatterns(field string, patterns []string) []string {
var errs []string
for index, pattern := range patterns {
if _, err := regexp.Compile(pattern); err != nil {
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
}
}
return errs
}
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
// - If a key in src has a null value, it is deleted from dst.
// - If both dst and src have a nested object for the same key, merge recursively.
// - Otherwise the value from src overwrites dst.
func mergeMap(dst, src map[string]any) {
for key, srcVal := range src {
if srcVal == nil {
delete(dst, key)
continue
}
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
mergeMap(dstMap, srcMap)
} else {
dst[key] = srcVal
}
}
}
func asMapField(value map[string]any, key string) (map[string]any, bool) {
raw, exists := value[key]
if !exists {
return nil, false
}
m, isMap := raw.(map[string]any)
return m, isMap
}
var (
allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]")
allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;\r\n\t]+")
conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+")
)
type stringArrayParserOptions struct {
stripHiddenChars bool
}
func normalizeChannelArrayFields(raw map[string]any) error {
channelsMap, hasChannels := asMapField(raw, "channel_list")
if !hasChannels {
return nil
}
defaultCfg := config.DefaultConfig()
for channelName, rawChannel := range channelsMap {
chMap, ok := rawChannel.(map[string]any)
if !ok {
continue
}
if rawAllowFrom, exists := chMap["allow_from"]; exists {
normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{
stripHiddenChars: true,
})
if err != nil {
return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err)
}
chMap["allow_from"] = normalized
}
if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok {
if rawPrefixes, exists := groupTrigger["prefixes"]; exists {
normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{})
if err != nil {
return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err)
}
groupTrigger["prefixes"] = normalized
}
}
settingsMap, hasSettings := asMapField(chMap, "settings")
if !hasSettings {
continue
}
settingsType := channelSettingsType(defaultCfg, channelName, chMap)
if settingsType == nil {
continue
}
for i := range settingsType.NumField() {
field := settingsType.Field(i)
if !field.IsExported() || !isStringSliceType(field.Type) {
continue
}
jsonKey := strings.Split(field.Tag.Get("json"), ",")[0]
if jsonKey == "" || jsonKey == "-" {
continue
}
rawValue, exists := settingsMap[jsonKey]
if !exists {
continue
}
options := stringArrayParserOptions{}
if jsonKey == "allow_from" {
options.stripHiddenChars = true
}
normalized, err := normalizeStringArrayValue(rawValue, options)
if err != nil {
return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err)
}
settingsMap[jsonKey] = normalized
}
}
return nil
}
func channelSettingsType(
defaultCfg *config.Config,
channelName string,
channelMap map[string]any,
) reflect.Type {
if channelType, _ := channelMap["type"].(string); channelType != "" {
if bc := defaultCfg.Channels.GetByType(channelType); bc != nil {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
return derefType(reflect.TypeOf(decoded))
}
}
}
if bc := defaultCfg.Channels.Get(channelName); bc != nil {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
return derefType(reflect.TypeOf(decoded))
}
}
return nil
}
func derefType(typ reflect.Type) reflect.Type {
for typ != nil && typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
return typ
}
func isStringSliceType(typ reflect.Type) bool {
typ = derefType(typ)
return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String
}
func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) {
switch typed := value.(type) {
case nil:
return nil, nil
case string:
return parseStringArrayValue(typed, options), nil
case float64:
return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil
case []string:
return normalizeStringArrayItems(typed, options), nil
case []any:
items := make([]string, 0, len(typed))
for _, item := range typed {
switch raw := item.(type) {
case string:
items = append(items, raw)
case float64:
items = append(items, fmt.Sprintf("%.0f", raw))
default:
return nil, fmt.Errorf("unsupported list item type %T", item)
}
}
return normalizeStringArrayItems(items, options), nil
default:
return nil, fmt.Errorf("unsupported list field type %T", value)
}
}
func parseStringArrayValue(raw string, options stringArrayParserOptions) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
}
splitRe := conservativeSplitRe
if options.stripHiddenChars {
splitRe = allowFromSplitRe
}
return normalizeStringArrayItems(splitRe.Split(raw, -1), options)
}
func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string {
result := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
normalized := item
if options.stripHiddenChars {
normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "")
}
normalized = strings.TrimSpace(normalized)
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
if len(result) == 0 {
return []string{}
}
return result
}
func getSecretString(m map[string]any, key string) (string, bool) {
if raw, exists := m[key]; exists {
s, isString := raw.(string)
if isString {
return s, true
}
}
if raw, exists := m["_"+key]; exists {
s, isString := raw.(string)
if isString {
return s, true
}
}
return "", false
}
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
channelsMap, hasChannels := asMapField(raw, "channel_list")
if !hasChannels {
return
}
for chName, chData := range channelsMap {
chMap, ok := chData.(map[string]any)
if !ok {
continue
}
bc := cfg.Channels.Get(chName)
if bc == nil {
continue
}
decoded, err := bc.GetDecoded()
if err != nil || decoded == nil {
continue
}
rv := reflect.ValueOf(decoded)
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
continue
}
// Channel-specific settings live under the "settings" key in the raw map
settingsMap := chMap
if sm, hasSettings := asMapField(chMap, "settings"); hasSettings {
settingsMap = sm
}
applySecureStringsToStruct(rv, settingsMap)
}
// Handle tools secrets
tools, hasTools := asMapField(raw, "tools")
if !hasTools {
return
}
skills, hasSkills := asMapField(tools, "skills")
if !hasSkills {
return
}
if github, hasGithub := asMapField(skills, "github"); hasGithub {
if token, hasToken := getSecretString(github, "token"); hasToken {
cfg.Tools.Skills.Github.Token.Set(token)
}
}
if registries, hasRegistries := asMapField(skills, "registries"); hasRegistries {
for registryName, rawRegistry := range registries {
registryMap, ok := rawRegistry.(map[string]any)
if !ok {
continue
}
if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
registryCfg, _ := cfg.Tools.Skills.Registries.Get(registryName)
registryCfg.AuthToken.Set(authToken)
cfg.Tools.Skills.Registries.Set(registryName, registryCfg)
}
}
return
}
registriesList, hasRegistries := skills["registries"].([]any)
if !hasRegistries {
return
}
for _, rawRegistry := range registriesList {
registryMap, ok := rawRegistry.(map[string]any)
if !ok {
continue
}
name, _ := registryMap["name"].(string)
if name == "" {
continue
}
if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
registryCfg, _ := cfg.Tools.Skills.Registries.Get(name)
registryCfg.AuthToken.Set(authToken)
cfg.Tools.Skills.Registries.Set(name, registryCfg)
}
}
}
// applySecureStringsToStruct walks a struct and applies SecureString fields
// from the matching keys in rawMap. It recurses into nested maps and slices.
func applySecureStringsToStruct(rv reflect.Value, rawMap map[string]any) {
rt := rv.Type()
for jsonKey, rawVal := range rawMap {
for i := range rt.NumField() {
f := rt.Field(i)
if !f.IsExported() {
continue
}
tag := f.Tag.Get("json")
name := strings.Split(tag, ",")[0]
if name != jsonKey {
continue
}
sf := rv.Field(i)
if !sf.CanSet() {
continue
}
// Direct SecureString field
if s, ok := rawVal.(string); ok {
if f.Type == reflect.TypeOf(config.SecureString{}) {
sf.Set(reflect.ValueOf(*config.NewSecureString(s)))
} else if f.Type == reflect.TypeOf(&config.SecureString{}) {
sf.Set(reflect.ValueOf(config.NewSecureString(s)))
}
continue
}
// Recurse into nested struct
if sf.Kind() == reflect.Struct {
if nested, ok := rawVal.(map[string]any); ok {
applySecureStringsToStruct(sf, nested)
}
continue
}
// Recurse into map fields (e.g., map[string]SomeStruct)
if sf.Kind() == reflect.Map && sf.Type().Elem().Kind() == reflect.Struct {
if nestedMap, ok := rawVal.(map[string]any); ok {
for mapKey, mapVal := range nestedMap {
nested, ok := mapVal.(map[string]any)
if !ok {
continue
}
elemType := sf.Type().Elem()
// Get existing element or create a new zero value
var elem reflect.Value
existing := sf.MapIndex(reflect.ValueOf(mapKey))
if existing.IsValid() {
if existing.Kind() == reflect.Interface {
existing = existing.Elem()
}
if existing.Kind() == reflect.Ptr && !existing.IsNil() {
elem = reflect.New(elemType)
elem.Elem().Set(existing.Elem())
} else if existing.Kind() == reflect.Struct {
elem = reflect.New(elemType)
elem.Elem().Set(existing)
}
}
if !elem.IsValid() {
elem = reflect.New(elemType)
}
applySecureStringsToStruct(elem.Elem(), nested)
sf.SetMapIndex(reflect.ValueOf(mapKey), elem.Elem())
}
}
continue
}
// Recurse into slice elements that are structs
if sf.Kind() == reflect.Slice && sf.Type().Elem().Kind() == reflect.Struct {
if sliceRaw, ok := rawVal.([]any); ok {
for idx, elemRaw := range sliceRaw {
if nested, ok := elemRaw.(map[string]any); ok {
if idx < sf.Len() {
applySecureStringsToStruct(sf.Index(idx), nested)
}
}
}
}
}
}
}
}