| 1 | package sidecar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "sync" |
| 13 | |
| 14 | "reasonix/internal/extension/protocol" |
| 15 | ) |
| 16 | |
| 17 | // storeMaxEntries bounds the per-client content store; the oldest object is |
| 18 | // evicted past the cap so a chatty sidecar cannot grow host memory without |
| 19 | // limit. Mirrored from the Remote workbench content store. |
| 20 | const storeMaxEntries = 64 |
| 21 | |
| 22 | // ExternalizedField is the host-side mirror of the schema's externalized |
| 23 | // field metadata: when an externalizable payload exceeds |
| 24 | // protocol.ExternalizeFieldBytes it travels as a content ref, and this |
| 25 | // descriptor tells the extension where to page the real bytes back from. |
| 26 | type ExternalizedField = protocol.ExternalizedField |
| 27 | |
| 28 | type contentObject struct { |
| 29 | data []byte |
| 30 | sha256 string |
| 31 | order uint64 |
| 32 | } |
| 33 | |
| 34 | // Store holds externalized content for exactly one sidecar connection. It is |
| 35 | // session-scoped in memory only: refs expire with the connection, which is |
| 36 | // why a read of an unknown ref answers content_ref_expired. |
| 37 | type Store struct { |
| 38 | mu sync.Mutex |
| 39 | contents map[string]contentObject |
| 40 | nextOrder uint64 |
| 41 | } |
| 42 | |
| 43 | // NewStore returns an empty content store. |
| 44 | func NewStore() *Store { |
| 45 | return &Store{contents: make(map[string]contentObject)} |
| 46 | } |
| 47 | |
| 48 | // Put stores data and returns its ref, SHA-256 hex digest, and byte count. |
| 49 | // Objects larger than protocol.ContentRefObjectBytes are rejected with the |
| 50 | // frozen frame_too_large error; past storeMaxEntries the oldest object is |
| 51 | // evicted. |
| 52 | func (s *Store) Put(data []byte) (ref string, digest string, totalBytes int64, err error) { |
| 53 | if len(data) > protocol.ContentRefObjectBytes { |
| 54 | return "", "", 0, protocol.MustProtocolError(protocol.ErrFrameTooLarge) |
| 55 | } |
| 56 | sum := sha256.Sum256(data) |
| 57 | digest = hex.EncodeToString(sum[:]) |
| 58 | ref = "content_" + randomHex(12) |
| 59 | |
| 60 | s.mu.Lock() |
| 61 | defer s.mu.Unlock() |
| 62 | s.nextOrder++ |
| 63 | s.contents[ref] = contentObject{data: append([]byte(nil), data...), sha256: digest, order: s.nextOrder} |
| 64 | if len(s.contents) > storeMaxEntries { |
| 65 | var oldestRef string |
| 66 | var oldestOrder uint64 |
| 67 | for candidate, object := range s.contents { |
| 68 | if candidate == ref { |
| 69 | continue |
| 70 | } |
| 71 | if oldestRef == "" || object.order < oldestOrder { |
| 72 | oldestRef, oldestOrder = candidate, object.order |
| 73 | } |
| 74 | } |
| 75 | delete(s.contents, oldestRef) |
| 76 | } |
| 77 | return ref, digest, int64(len(data)), nil |
| 78 | } |
| 79 | |
| 80 | // Read pages one chunk of at most protocol.ContentRefChunkBytes from ref at |
| 81 | // offset, returning the chunk, the next offset (nil at end of object), the |
| 82 | // total byte count, and the object's SHA-256. Unknown refs and out-of-range |
| 83 | // offsets answer the frozen content_ref_expired error. This is the in-process |
| 84 | // form of ReadHandler: the host uses it to resolve content refs a peer hands |
| 85 | // back (e.g. an externalized intercept replacement) with the exact |
| 86 | // host/content/read chunking rules. |
| 87 | func (s *Store) Read(ref string, offset int64) (chunk []byte, next *int64, totalBytes int64, digest string, err error) { |
| 88 | s.mu.Lock() |
| 89 | object, ok := s.contents[ref] |
| 90 | s.mu.Unlock() |
| 91 | if !ok { |
| 92 | return nil, nil, 0, "", protocol.MustProtocolError(protocol.ErrContentRefExpired) |
| 93 | } |
| 94 | if offset < 0 || offset > int64(len(object.data)) { |
| 95 | return nil, nil, 0, "", protocol.MustProtocolError(protocol.ErrContentRefExpired) |
| 96 | } |
| 97 | end := offset + protocol.ContentRefChunkBytes |
| 98 | if end > int64(len(object.data)) { |
| 99 | end = int64(len(object.data)) |
| 100 | } |
| 101 | if end < int64(len(object.data)) { |
| 102 | value := end |
| 103 | next = &value |
| 104 | } |
| 105 | return object.data[offset:end], next, int64(len(object.data)), object.sha256, nil |
| 106 | } |
| 107 | |
| 108 | // ReadHandler answers host/content/read: one page of at most |
| 109 | // protocol.ContentRefChunkBytes starting at the exact requested offset. The |
| 110 | // final chunk omits NextOffset. Unknown refs and out-of-range offsets answer |
| 111 | // the frozen content_ref_expired error, mirroring the Remote workbench. |
| 112 | func (s *Store) ReadHandler(_ context.Context, raw json.RawMessage) (any, error) { |
| 113 | decoded, err := protocol.DecodeExtensionRequestParams(protocol.MethodHostContentRead, raw) |
| 114 | if err != nil { |
| 115 | return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError() |
| 116 | } |
| 117 | p := decoded.(protocol.ContentReadParams) |
| 118 | chunk, next, totalBytes, digest, err := s.Read(p.ContentRef, p.Offset) |
| 119 | if err != nil { |
| 120 | var protocolErr *protocol.ProtocolError |
| 121 | if errors.As(err, &protocolErr) { |
| 122 | return nil, protocolErr.RPCError() |
| 123 | } |
| 124 | return nil, err |
| 125 | } |
| 126 | return protocol.ContentReadResult{ |
| 127 | ContentRef: p.ContentRef, Offset: p.Offset, |
| 128 | DataBase64: base64.StdEncoding.EncodeToString(chunk), NextOffset: next, |
| 129 | TotalBytes: totalBytes, SHA256: digest, Encoding: protocol.ContentUTF8, |
| 130 | }, nil |
| 131 | } |
| 132 | |
| 133 | // MaybeExternalize stores value and returns its descriptor when it exceeds |
| 134 | // the frozen externalization threshold; smaller values pass through as a nil |
| 135 | // descriptor, meaning the caller keeps them inline. field is the RFC 6901 |
| 136 | // JSON pointer of the externalizable field inside its owner document. |
| 137 | func MaybeExternalize(store *Store, field string, value []byte) (*ExternalizedField, error) { |
| 138 | if !protocol.RequiresExternalization(len(value)) { |
| 139 | return nil, nil |
| 140 | } |
| 141 | if store == nil { |
| 142 | return nil, fmt.Errorf("sidecar: externalizing %s requires a content store", field) |
| 143 | } |
| 144 | ref, digest, totalBytes, err := store.Put(value) |
| 145 | if err != nil { |
| 146 | return nil, err |
| 147 | } |
| 148 | return &ExternalizedField{ |
| 149 | JSONPointer: field, ContentRef: ref, TotalBytes: totalBytes, SHA256: digest, |
| 150 | }, nil |
| 151 | } |
| 152 | |
| 153 | // randomHex returns n random bytes hex-encoded (2n chars) for content refs. |
| 154 | func randomHex(n int) string { |
| 155 | buf := make([]byte, n) |
| 156 | if _, err := rand.Read(buf); err != nil { |
| 157 | panic(fmt.Sprintf("sidecar: crypto/rand unavailable: %v", err)) |
| 158 | } |
| 159 | return hex.EncodeToString(buf) |
| 160 | } |
| 161 |