package endpointer import ( "bytes" "fmt" "io" "github.com/go-json-experiment/json/jsontext" ) // AliasConflictError is returned when both the deprecated and new field names // are specified in the same JSON object scope. For example, if "team_id" is // renamed to "fleet_id", and a request contains both, this error is returned. type AliasConflictError struct { Old string New string } func (e *AliasConflictError) Error() string { return fmt.Sprintf("Conflicting field names: cannot specify both `%s` (deprecated) and `%s` in the same request", e.Old, e.New) } // AliasRule defines a key-rename rule: the deprecated (old) key name and its // replacement (new) key name. The struct's json tag uses OldKey (the current // name), and renameto specifies NewKey (the target name). The rewriter // accepts both names in requests: OldKey passes through as-is (with // deprecation tracking) and NewKey is rewritten to OldKey for deserialization. type AliasRule struct { OldKey string NewKey string // Inline opts a renamed container into "merged" response duplication: // instead of the default clean split (the old key holds an all-old subtree // and the new key an all-new one), the old key's subtree also carries the // new-named copies of any nested renamed containers — so both names appear // together on the same object. Set via the `,inline` option on the // `renameto` struct tag (e.g. `renameto:"ab_tokens,inline"`). It only // affects response encoding (DuplicateJSONKeys); request decoding ignores // it. Inline bool } // JSONKeyRewriteReader is a streaming io.Reader that handles // JSON key aliasing while reading. It: // // - Passes through OldKey (deprecated) names as-is (the struct expects them) // and tracks them in usedDeprecated for deprecation logging. // - Rewrites NewKey names to OldKey so the struct can deserialize them. // - Detects alias conflicts: if both OldKey and NewKey appear in the same // JSON object scope, it returns an *AliasConflictError. // // It uses jsontext.Decoder/Encoder for token-level processing, delegating all // JSON lexing (string escaping, unicode, whitespace) to the library. type JSONKeyRewriteReader struct { reader *bytes.Reader initErr error // Map from old (deprecated) key to its AliasRule for fast lookup. oldKeyIndex map[string]AliasRule // Map from new key to its AliasRule for fast lookup. newKeyIndex map[string]AliasRule // Tracks which deprecated keys have been used (old key -> true). usedDeprecated map[string]bool } // NewJSONKeyRewriteReader creates a new JSONKeyRewriteReader that wraps the // given reader and applies the provided alias rules. It reads JSON tokens // from src, handles bidirectional key aliasing, detects conflicts, and // writes the result to an internal buffer. func NewJSONKeyRewriteReader(src io.Reader, rules []AliasRule) *JSONKeyRewriteReader { oldIdx := make(map[string]AliasRule, len(rules)) newIdx := make(map[string]AliasRule, len(rules)) for _, r := range rules { oldIdx[r.OldKey] = r newIdx[r.NewKey] = r } rw := &JSONKeyRewriteReader{ oldKeyIndex: oldIdx, newKeyIndex: newIdx, usedDeprecated: make(map[string]bool), } var buf bytes.Buffer if err := rw.rewrite(src, &buf); err != nil { rw.initErr = err return rw } rw.reader = bytes.NewReader(buf.Bytes()) return rw } // UsedDeprecatedKeys returns the list of deprecated key names that were // encountered during reading. This should be called after the reader has been // fully consumed (i.e., after json.Decoder.Decode or similar has returned), // which guarantees the background goroutine has finished. func (r *JSONKeyRewriteReader) UsedDeprecatedKeys() []string { keys := make([]string, 0, len(r.usedDeprecated)) for k := range r.usedDeprecated { keys = append(keys, k) } return keys } // Close closes the reader end of the pipe to unblock the transform goroutine // if the consumer stops reading early. func (r *JSONKeyRewriteReader) Close() error { return nil } // Read implements io.Reader by reading from the pipe. func (r *JSONKeyRewriteReader) Read(p []byte) (int, error) { if r.initErr != nil { return 0, r.initErr } if r.reader == nil { return 0, io.EOF } return r.reader.Read(p) } // RewriteDeprecatedKeys handles JSON key aliasing in data using // the provided alias rules. It rewrites NewKey→OldKey (so the struct can // deserialize), passes through OldKey as-is, and returns an error if both // appear in the same scope (alias conflict) or the JSON is malformed. // // This is useful when a request body is captured as json.RawMessage and later // decoded into a struct with `renameto` tags — the rewriter in MakeDecoder // won't have seen the inner fields, so this function can be called before the // deferred unmarshal. func RewriteDeprecatedKeys(data []byte, rules []AliasRule) ([]byte, map[string]string, error) { if len(rules) == 0 || len(data) == 0 { return data, nil, nil } oldIdx := make(map[string]AliasRule, len(rules)) newIdx := make(map[string]AliasRule, len(rules)) for _, r := range rules { oldIdx[r.OldKey] = r newIdx[r.NewKey] = r } rw := &JSONKeyRewriteReader{ oldKeyIndex: oldIdx, newKeyIndex: newIdx, usedDeprecated: make(map[string]bool), } var buf bytes.Buffer if err := rw.rewrite(bytes.NewReader(data), &buf); err != nil { return nil, nil, err } deprecatedKeysMap := make(map[string]string, len(rw.usedDeprecated)) for k := range rw.usedDeprecated { deprecatedKeysMap[k] = rw.oldKeyIndex[k].NewKey } return buf.Bytes(), deprecatedKeysMap, nil } // RewriteOldToNewKeys is the reverse of RewriteDeprecatedKey; it takes // the rules and reverses them before translating keys. // Use this in situations where a payload was rewritten from new to old keys // for deserialization, but you want to return a response with the new keys // for forward compatibility. func RewriteOldToNewKeys(data []byte, rules []AliasRule) ([]byte, error) { reversed := make([]AliasRule, len(rules)) for i, r := range rules { // Inline is intentionally not preserved: this only renames keys, it // never duplicates them. reversed[i] = AliasRule{OldKey: r.NewKey, NewKey: r.OldKey} } result, _, err := RewriteDeprecatedKeys(data, reversed) return result, err } // softwareScopeKey marks the JSON object/array container whose contents are // the values of TeamSpec.Software — i.e. SoftwarePackageSpec / // TeamSpecAppStoreApp / MaintainedAppSpec items. The literal `setup_experience` // install flag on those items collides with the `macos_setup`↔`setup_experience` // rename on the MDM section, so renames are skipped under this subtree. See // https://github.com/fleetdm/fleet/issues/44970. const softwareScopeKey = "software" // rewrite reads tokens from src, rewrites deprecated keys, checks for alias // conflicts, and writes the transformed JSON to w. func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { dec := jsontext.NewDecoder(src, jsontext.AllowDuplicateNames(true)) enc := jsontext.NewEncoder(w, jsontext.AllowDuplicateNames(true)) // Stack of per-object-scope key sets for conflict detection. // Pushed on '{', popped on '}'. var keyScopes []map[string]bool // Track whether we are currently inside the `software` subtree. // pendingKey is the most-recent object key whose value has not yet been // read; openContainer/closeContainer maintain softwareDepth by checking // whether the container being opened lives under `software`. pendingKey := "" softwareDepth := 0 openContainer := func() { if softwareDepth > 0 || pendingKey == softwareScopeKey { softwareDepth++ } pendingKey = "" } closeContainer := func() { if softwareDepth > 0 { softwareDepth-- } } for { tok, err := dec.ReadToken() if err != nil { if err == io.EOF { return nil } return err } kind := tok.Kind() switch kind { case '{': keyScopes = append(keyScopes, make(map[string]bool)) openContainer() if err := enc.WriteToken(tok); err != nil { return err } case '}': if len(keyScopes) > 0 { keyScopes = keyScopes[:len(keyScopes)-1] } closeContainer() if err := enc.WriteToken(tok); err != nil { return err } case '[': openContainer() if err := enc.WriteToken(tok); err != nil { return err } case ']': closeContainer() if err := enc.WriteToken(tok); err != nil { return err } case '"': // Determine if this string is an object key by checking the // decoder's stack: inside an object ('{') at an odd length // means we just read a key (name). isKey := false depth := dec.StackDepth() if depth > 0 { parentKind, length := dec.StackIndex(depth) // length is odd after reading a name (names and values // are counted separately). if parentKind == '{' && length%2 == 1 { isKey = true } } if isKey { keyName := tok.String() // Inside the `software` subtree, all inner keys are literal — // most notably each item's `setup_experience` is a bool // install flag, not the renamed `macos_setup` container. Pass // them through untouched. if softwareDepth > 0 { pendingKey = keyName if err := enc.WriteToken(tok); err != nil { return err } continue } // Use OldKey as the canonical key for scope tracking. // Both OldKey (pass-through) and NewKey (rewrite) resolve // to the same canonical key for conflict detection. if rule, ok := r.oldKeyIndex[keyName]; ok { // This is an OldKey (deprecated name). Pass through // as-is — the struct expects this name. Track it for // deprecation logging. canonicalKey := rule.OldKey r.usedDeprecated[keyName] = true // Conflict detection. if len(keyScopes) > 0 { scope := keyScopes[len(keyScopes)-1] if scope[canonicalKey] { return &AliasConflictError{Old: rule.OldKey, New: rule.NewKey} } scope[canonicalKey] = true } pendingKey = keyName // Write the key as-is (old name, which the struct expects). if err := enc.WriteToken(tok); err != nil { return err } } else if rule, ok := r.newKeyIndex[keyName]; ok { // This is a NewKey. Rewrite it to OldKey so the // struct can deserialize it. canonicalKey := rule.OldKey // Conflict detection. if len(keyScopes) > 0 { scope := keyScopes[len(keyScopes)-1] if scope[canonicalKey] { return &AliasConflictError{Old: rule.OldKey, New: rule.NewKey} } scope[canonicalKey] = true } pendingKey = canonicalKey // Write the rewritten (old) key. if err := enc.WriteToken(jsontext.String(canonicalKey)); err != nil { return err } } else { // Not an aliased key — pass through unchanged. pendingKey = keyName if err := enc.WriteToken(tok); err != nil { return err } } } else { // String value — pass through unchanged. pendingKey = "" if err := enc.WriteToken(tok); err != nil { return err } } default: // All other tokens: numbers, bools, null — scalar values. pendingKey = "" if err := enc.WriteToken(tok); err != nil { return err } } } }