| 1 | package sidecar |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "reflect" |
| 10 | |
| 11 | "reasonix/internal/extension/protocol" |
| 12 | ) |
| 13 | |
| 14 | // Content-ref externalization for the intercept and event calls. The wire |
| 15 | // contract: a field tagged externalizable whose serialized value exceeds |
| 16 | // protocol.ExternalizeFieldBytes travels as null in its owner document, and |
| 17 | // the owner's externalized envelope carries one descriptor per moved field |
| 18 | // naming the content ref to page back through host/content/read. Only the |
| 19 | // host creates refs (the protocol registers host/content/read in the |
| 20 | // Extension → Host direction alone), so an extension answering with a content |
| 21 | // ref always names an object in this connection's host-side store. |
| 22 | |
| 23 | // externalizablePointer resolves the schema-registered externalizable JSON |
| 24 | // pointer for one field of typ, failing loudly on schema drift instead of |
| 25 | // silently externalizing a field the schema no longer marks. |
| 26 | func externalizablePointer(typ reflect.Type, want string) (string, error) { |
| 27 | for _, pointer := range protocol.ExternalizablePointers(typ) { |
| 28 | if pointer == want { |
| 29 | return pointer, nil |
| 30 | } |
| 31 | } |
| 32 | return "", fmt.Errorf("sidecar: %s has no externalizable %s field (schema drift)", typ, want) |
| 33 | } |
| 34 | |
| 35 | // externalizePayload moves an over-threshold payload into the connection's |
| 36 | // content store and returns the null placeholder plus the wire envelope. |
| 37 | // Below the protocol.ExternalizeFieldBytes threshold the payload passes |
| 38 | // through inline with a nil envelope, keeping small calls byte-identical. |
| 39 | func externalizePayload(store *Store, pointer string, payload json.RawMessage) (json.RawMessage, []ExternalizedField, error) { |
| 40 | field, err := MaybeExternalize(store, pointer, payload) |
| 41 | if err != nil { |
| 42 | return nil, nil, err |
| 43 | } |
| 44 | if field == nil { |
| 45 | return payload, nil, nil |
| 46 | } |
| 47 | return nil, []ExternalizedField{*field}, nil |
| 48 | } |
| 49 | |
| 50 | // externalizeInterceptParams applies the outbound content-ref rule to one |
| 51 | // intercept call's payload. |
| 52 | func (c *Client) externalizeInterceptParams(params *protocol.InterceptParams) error { |
| 53 | pointer, err := externalizablePointer(reflect.TypeOf(*params), "/payload") |
| 54 | if err != nil { |
| 55 | return err |
| 56 | } |
| 57 | payload, externalized, err := externalizePayload(c.store, pointer, params.Payload) |
| 58 | if err != nil { |
| 59 | return err |
| 60 | } |
| 61 | params.Payload = payload |
| 62 | params.Externalized = externalized |
| 63 | return nil |
| 64 | } |
| 65 | |
| 66 | // externalizeEventParams applies the outbound content-ref rule to one event |
| 67 | // notification's payload. |
| 68 | func (c *Client) externalizeEventParams(params *protocol.EventParams) error { |
| 69 | pointer, err := externalizablePointer(reflect.TypeOf(*params), "/payload") |
| 70 | if err != nil { |
| 71 | return err |
| 72 | } |
| 73 | payload, externalized, err := externalizePayload(c.store, pointer, params.Payload) |
| 74 | if err != nil { |
| 75 | return err |
| 76 | } |
| 77 | params.Payload = payload |
| 78 | params.Externalized = externalized |
| 79 | return nil |
| 80 | } |
| 81 | |
| 82 | // resolveExternalizedReplacement rehydrates an intercept result whose |
| 83 | // replacement traveled as a content-ref envelope instead of inline JSON. The |
| 84 | // ref is paged out of this connection's store with the exact |
| 85 | // host/content/read chunking rules (Store.Read), the reassembled bytes are |
| 86 | // verified against the descriptor's byte count and SHA-256, and only then |
| 87 | // substituted for the strict decode the dispatcher performs. An inline |
| 88 | // replacement alongside an envelope, a pointer outside the schema's |
| 89 | // externalizable set, or unverifiable content is a protocol error: the |
| 90 | // dispatcher must never decode bytes the peer did not prove. |
| 91 | func (c *Client) resolveExternalizedReplacement(result *protocol.InterceptResult) error { |
| 92 | if len(result.Externalized) == 0 { |
| 93 | return nil |
| 94 | } |
| 95 | if inline := bytes.TrimSpace(result.Replacement); len(inline) > 0 && !bytes.Equal(inline, []byte("null")) { |
| 96 | return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "intercept result carries both an inline replacement and an externalized envelope"} |
| 97 | } |
| 98 | pointer, err := externalizablePointer(reflect.TypeOf(*result), "/replacement") |
| 99 | if err != nil { |
| 100 | return err |
| 101 | } |
| 102 | if len(result.Externalized) != 1 || result.Externalized[0].JSONPointer != pointer { |
| 103 | return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: fmt.Sprintf( |
| 104 | "intercept result externalized envelope must hold exactly the %s descriptor", pointer)} |
| 105 | } |
| 106 | descriptor := result.Externalized[0] |
| 107 | if descriptor.TotalBytes > protocol.ContentRefObjectBytes { |
| 108 | return &protocol.ProtocolError{Reason: protocol.ErrFrameTooLarge, Message: fmt.Sprintf( |
| 109 | "externalized replacement is %d bytes, above the %d byte object cap", descriptor.TotalBytes, protocol.ContentRefObjectBytes)} |
| 110 | } |
| 111 | reassembled, err := c.store.readAll(descriptor.ContentRef) |
| 112 | if err != nil { |
| 113 | return err |
| 114 | } |
| 115 | if int64(len(reassembled)) != descriptor.TotalBytes { |
| 116 | return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: fmt.Sprintf( |
| 117 | "externalized replacement reassembled to %d bytes, want %d", len(reassembled), descriptor.TotalBytes)} |
| 118 | } |
| 119 | sum := sha256.Sum256(reassembled) |
| 120 | if hex.EncodeToString(sum[:]) != descriptor.SHA256 { |
| 121 | return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "externalized replacement SHA-256 mismatch"} |
| 122 | } |
| 123 | result.Replacement = reassembled |
| 124 | return nil |
| 125 | } |
| 126 | |
| 127 | // readAll pages a whole content ref out of the store in |
| 128 | // protocol.ContentRefChunkBytes chunks, mirroring how an extension pages |
| 129 | // host/content/read. |
| 130 | func (s *Store) readAll(ref string) ([]byte, error) { |
| 131 | var out []byte |
| 132 | var offset int64 |
| 133 | for { |
| 134 | chunk, next, _, _, err := s.Read(ref, offset) |
| 135 | if err != nil { |
| 136 | return nil, err |
| 137 | } |
| 138 | out = append(out, chunk...) |
| 139 | if next == nil { |
| 140 | return out, nil |
| 141 | } |
| 142 | offset = *next |
| 143 | } |
| 144 | } |
| 145 |