package configkit import ( "cmp" "encoding/json" "fmt" "io" "reflect" "slices" "strconv" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) // Secret is an opaque reference to an environment variable. Its name is public // configuration; its resolved value is available only through Value. type Secret struct { name string value string specified bool resolved bool } // SecretEnv constructs an unresolved environment-backed secret reference. // Invalid names are reported if the reference survives a configuration load. func SecretEnv(name string) Secret { return Secret{name: name, specified: true} } // EnvName returns the public environment-variable name. func (s Secret) EnvName() string { return s.name } // Value returns the resolved credential, or an empty string when unresolved. func (s Secret) Value() string { if !s.resolved { return "" } return s.value } // IsResolved reports whether this reference resolved to a nonempty value during // its configuration load. func (s Secret) IsResolved() bool { return s.resolved } // Format renders only the public reference name for every formatting verb. func (s Secret) Format(state fmt.State, _ rune) { _, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")") } // MarshalJSON emits only the public environment-variable name. func (s Secret) MarshalJSON() ([]byte, error) { return json.Marshal(s.name) } type secretLookup func(string) (string, bool) type secretLookupResult struct { value string present bool } type secretResolutionResult struct { lookups map[string]secretLookupResult references map[string]string diagnostics []Diagnostic } func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult { result := secretResolutionResult{ lookups: make(map[string]secretLookupResult), references: make(map[string]string), } if schema == nil || schema.root == nil || !root.IsValid() || root.Type() != schema.root.typeOf { result.diagnostics = []Diagnostic{{Code: CodeSecretRequired, Severity: SeverityError, Message: "configuration value does not match its secret-reference schema"}} return result } if lookup == nil { lookup = func(string) (string, bool) { return "", false } } resolveSecretValue(root, schema.root, Path{}, false, input, lookup, &result) result.diagnostics = canonicalDiagnostics(result.diagnostics) return result } func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) { if descriptor.kind == descriptorPointer { if value.IsNil() { return } resolveSecretValue(value.Elem(), descriptor.element, path, descriptor.element.kind == descriptorSecret, input, lookup, result) return } switch descriptor.kind { case descriptorSecret: reference := value.Interface().(Secret) name := reference.EnvName() if name == "" { code := CodeSecretRequired message := "required secret reference is absent" if optional || reference.specified { code = CodeSecretInvalidName message = "secret environment name is empty" } result.diagnostics = append(result.diagnostics, secretDiagnostic(code, path, message, input)) return } if !validEnvironmentName(name) { result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretInvalidName, path, "secret environment name is invalid", input)) return } found, exists := result.lookups[name] if !exists { found.value, found.present = lookup(name) result.lookups[name] = found } if !found.present { result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretUnset, path, "secret environment variable is not set", input)) return } if found.value == "" { result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input)) return } value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true})) result.references[path.String()] = name case descriptorStruct: for _, member := range descriptor.members { memberPath := path if !member.inline { memberPath = path.Key(member.name) } resolveSecretValue(value.Field(member.index), member.descriptor, memberPath, false, input, lookup, result) } case descriptorMap: if value.IsNil() { return } keys := value.MapKeys() slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) }) for _, key := range keys { entry := reflect.New(value.Type().Elem()).Elem() entry.Set(value.MapIndex(key)) resolveSecretValue(entry, descriptor.element, path.Key(key.String()), false, input, lookup, result) value.SetMapIndex(key, entry) } case descriptorSlice, descriptorArray: if descriptor.kind == descriptorSlice && value.IsNil() { return } for index := 0; index < value.Len(); index++ { resolveSecretValue(value.Index(index), descriptor.element, path.Index(index), false, input, lookup, result) } } } func secretDiagnostic(code Code, path Path, message string, input *document.Node) Diagnostic { diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message} if location, ok := nearestDocumentLocation(input, path); ok { diagnostic.Primary = documentLocationPointer(location) } return diagnostic } func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path { var changed []Path collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed) slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) }) return changed } func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, path Path, lookups map[string]secretLookupResult, changed *[]Path) { if descriptor.kind == descriptorPointer { if !value.IsNil() { collectChangedSecretStates(value.Elem(), descriptor.element, path, lookups, changed) } return } switch descriptor.kind { case descriptorSecret: reference := value.Interface().(Secret) expected, ok := lookups[reference.EnvName()] if ok && (!expected.present || expected.value == "" || !reference.resolved || reference.value != expected.value) { *changed = append(*changed, path.clone()) } case descriptorStruct: for _, member := range descriptor.members { memberPath := path if !member.inline { memberPath = path.Key(member.name) } collectChangedSecretStates(value.Field(member.index), member.descriptor, memberPath, lookups, changed) } case descriptorMap: if value.IsNil() { return } keys := value.MapKeys() slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) }) for _, key := range keys { collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed) } case descriptorSlice, descriptorArray: if descriptor.kind == descriptorSlice && value.IsNil() { return } for index := 0; index < value.Len(); index++ { collectChangedSecretStates(value.Index(index), descriptor.element, path.Index(index), lookups, changed) } } }