| 1 | package sidecar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/extension/protocol" |
| 14 | "reasonix/internal/pluginpkg" |
| 15 | ) |
| 16 | |
| 17 | // bigInputPayload builds an input.receive payload whose text is size bytes. |
| 18 | func bigInputPayload(size int) json.RawMessage { |
| 19 | payload, _ := json.Marshal(map[string]string{"text": strings.Repeat("x", size)}) |
| 20 | return payload |
| 21 | } |
| 22 | |
| 23 | // TestInterceptExternalizesLargePayload proves the outbound content-ref rule: |
| 24 | // a >64 KiB payload leaves the host as a null placeholder plus envelope, the |
| 25 | // extension pages the real bytes back through host/content/read, and its |
| 26 | // >64 KiB inline replacement comes home verified. |
| 27 | func TestInterceptExternalizesLargePayload(t *testing.T) { |
| 28 | client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) { |
| 29 | rt.Env[fakeEnvMode] = "content_roundtrip" |
| 30 | }, nil) |
| 31 | payload := bigInputPayload(protocol.ExternalizeFieldBytes + 32<<10) |
| 32 | result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 15*time.Second) |
| 33 | if err != nil { |
| 34 | t.Fatalf("Intercept: %v", err) |
| 35 | } |
| 36 | if result.Decision != protocol.DecisionReplace { |
| 37 | t.Fatalf("decision = %q, want replace", result.Decision) |
| 38 | } |
| 39 | var replacement struct { |
| 40 | Text string `json:"text"` |
| 41 | } |
| 42 | if err := json.Unmarshal(result.Replacement, &replacement); err != nil { |
| 43 | t.Fatalf("replacement decode: %v", err) |
| 44 | } |
| 45 | sum := sha256.Sum256(payload) |
| 46 | want := fmt.Sprintf("read %d bytes sha256:%s", len(payload), hex.EncodeToString(sum[:])) |
| 47 | if !strings.Contains(replacement.Text, want) { |
| 48 | t.Fatalf("replacement text does not prove the extension read the content ref: want substring %q", want) |
| 49 | } |
| 50 | if len(result.Replacement) <= protocol.ExternalizeFieldBytes { |
| 51 | t.Fatalf("replacement = %d bytes, want above the %d byte threshold", len(result.Replacement), protocol.ExternalizeFieldBytes) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // TestInterceptSmallPayloadStaysInline proves the passthrough: below the |
| 56 | // threshold the payload travels inline and no envelope is produced. |
| 57 | func TestInterceptSmallPayloadStaysInline(t *testing.T) { |
| 58 | client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) { |
| 59 | rt.Env[fakeEnvMode] = "content_roundtrip" |
| 60 | }, nil) |
| 61 | payload := json.RawMessage(`{"text":"small"}`) |
| 62 | result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 5*time.Second) |
| 63 | if err != nil { |
| 64 | t.Fatalf("Intercept: %v", err) |
| 65 | } |
| 66 | var replacement struct { |
| 67 | Text string `json:"text"` |
| 68 | } |
| 69 | if err := json.Unmarshal(result.Replacement, &replacement); err != nil { |
| 70 | t.Fatalf("replacement decode: %v", err) |
| 71 | } |
| 72 | // The fake reports the byte count it read; an inline payload arrives |
| 73 | // directly, so the count matches the payload's exact length. |
| 74 | want := fmt.Sprintf("read %d bytes", len(payload)) |
| 75 | if !strings.Contains(replacement.Text, want) { |
| 76 | t.Fatalf("small payload did not arrive inline: replacement text lacks %q", want) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // TestInterceptResolvesExternalizedReplacement proves the inbound content-ref |
| 81 | // rule: an extension may answer with replacement:null plus an envelope naming |
| 82 | // a host-held content ref, and the host pages it back out of the connection's |
| 83 | // store (host/content/read chunking) before the payload is handed over. |
| 84 | func TestInterceptResolvesExternalizedReplacement(t *testing.T) { |
| 85 | client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) { |
| 86 | rt.Env[fakeEnvMode] = "content_echo_ref" |
| 87 | }, nil) |
| 88 | payload := bigInputPayload(protocol.ExternalizeFieldBytes + 64<<10) |
| 89 | result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 15*time.Second) |
| 90 | if err != nil { |
| 91 | t.Fatalf("Intercept: %v", err) |
| 92 | } |
| 93 | if result.Decision != protocol.DecisionReplace { |
| 94 | t.Fatalf("decision = %q, want replace", result.Decision) |
| 95 | } |
| 96 | if string(result.Replacement) != string(payload) { |
| 97 | t.Fatalf("resolved replacement = %d bytes, want the original %d byte payload", len(result.Replacement), len(payload)) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // TestInterceptRejectsPayloadBeyondObjectCap proves the hard ceiling: a |
| 102 | // payload beyond protocol.ContentRefObjectBytes cannot be externalized and |
| 103 | // fails with the frozen frame_too_large reason before touching the wire. |
| 104 | func TestInterceptRejectsPayloadBeyondObjectCap(t *testing.T) { |
| 105 | client := startFakeClient(t, nil, nil) |
| 106 | payload := bigInputPayload(protocol.ContentRefObjectBytes + 1) |
| 107 | _, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 5*time.Second) |
| 108 | if err == nil { |
| 109 | t.Fatal("Intercept accepted a payload beyond ContentRefObjectBytes") |
| 110 | } |
| 111 | if reason := protocolReason(t, err); reason != protocol.ErrFrameTooLarge { |
| 112 | t.Fatalf("reason = %q, want %q", reason, protocol.ErrFrameTooLarge) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // TestNotifyEventExternalizesLargePayload covers the event-notification share |
| 117 | // of the content-ref rule at the envelope level (the wire path is the same |
| 118 | // helper Intercept exercises end to end). |
| 119 | func TestNotifyEventExternalizesLargePayload(t *testing.T) { |
| 120 | client := &Client{store: NewStore()} |
| 121 | params := protocol.EventParams{Event: protocol.EventSessionStart, Payload: bigInputPayload(protocol.ExternalizeFieldBytes + 1024)} |
| 122 | if err := client.externalizeEventParams(¶ms); err != nil { |
| 123 | t.Fatalf("externalizeEventParams: %v", err) |
| 124 | } |
| 125 | if len(params.Payload) != 0 { |
| 126 | t.Fatal("large event payload did not move to a null placeholder") |
| 127 | } |
| 128 | if len(params.Externalized) != 1 || params.Externalized[0].JSONPointer != "/payload" { |
| 129 | t.Fatalf("envelope = %+v", params.Externalized) |
| 130 | } |
| 131 | descriptor := params.Externalized[0] |
| 132 | reassembled, err := client.store.readAll(descriptor.ContentRef) |
| 133 | if err != nil { |
| 134 | t.Fatalf("readAll: %v", err) |
| 135 | } |
| 136 | if int64(len(reassembled)) != descriptor.TotalBytes { |
| 137 | t.Fatalf("reassembled %d bytes, want %d", len(reassembled), descriptor.TotalBytes) |
| 138 | } |
| 139 | sum := sha256.Sum256(reassembled) |
| 140 | if hex.EncodeToString(sum[:]) != descriptor.SHA256 { |
| 141 | t.Fatal("descriptor SHA-256 does not match the stored object") |
| 142 | } |
| 143 | |
| 144 | small := protocol.EventParams{Event: protocol.EventSessionStart, Payload: json.RawMessage(`{"at":1}`)} |
| 145 | if err := client.externalizeEventParams(&small); err != nil { |
| 146 | t.Fatalf("externalizeEventParams small: %v", err) |
| 147 | } |
| 148 | if string(small.Payload) != `{"at":1}` || len(small.Externalized) != 0 { |
| 149 | t.Fatalf("small event payload = %s envelope %+v, want inline passthrough", small.Payload, small.Externalized) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // TestResolveExternalizedReplacementValidation pins the envelope validation: |
| 154 | // inline-plus-envelope, unknown refs, byte-count and digest mismatches, and |
| 155 | // over-cap descriptors are all protocol errors, never silent decodes. |
| 156 | func TestResolveExternalizedReplacementValidation(t *testing.T) { |
| 157 | client := &Client{store: NewStore()} |
| 158 | content := []byte(`{"text":"stored"}`) |
| 159 | ref, digest, totalBytes, err := client.store.Put(content) |
| 160 | if err != nil { |
| 161 | t.Fatalf("Put: %v", err) |
| 162 | } |
| 163 | descriptor := func() ExternalizedField { |
| 164 | return ExternalizedField{JSONPointer: "/replacement", ContentRef: ref, TotalBytes: totalBytes, SHA256: digest} |
| 165 | } |
| 166 | |
| 167 | // Happy path: the replacement is reassembled and verified. |
| 168 | result := protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{descriptor()}} |
| 169 | if err := client.resolveExternalizedReplacement(&result); err != nil { |
| 170 | t.Fatalf("resolve: %v", err) |
| 171 | } |
| 172 | if string(result.Replacement) != string(content) { |
| 173 | t.Fatalf("replacement = %s, want %s", result.Replacement, content) |
| 174 | } |
| 175 | |
| 176 | // Inline replacement alongside an envelope is malformed. |
| 177 | both := protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"text":"inline"}`), Externalized: []ExternalizedField{descriptor()}} |
| 178 | if err := client.resolveExternalizedReplacement(&both); err == nil { |
| 179 | t.Fatal("inline replacement plus envelope accepted") |
| 180 | } |
| 181 | |
| 182 | // Unknown ref answers content_ref_expired. |
| 183 | unknown := descriptor() |
| 184 | unknown.ContentRef = "content_gone" |
| 185 | result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{unknown}} |
| 186 | if err := client.resolveExternalizedReplacement(&result); err == nil { |
| 187 | t.Fatal("unknown content ref accepted") |
| 188 | } else if reason := protocolReason(t, err); reason != protocol.ErrContentRefExpired { |
| 189 | t.Fatalf("unknown ref reason = %q, want %q", reason, protocol.ErrContentRefExpired) |
| 190 | } |
| 191 | |
| 192 | // Byte-count mismatch is a protocol error. |
| 193 | short := descriptor() |
| 194 | short.TotalBytes = totalBytes - 1 |
| 195 | result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{short}} |
| 196 | if err := client.resolveExternalizedReplacement(&result); err == nil { |
| 197 | t.Fatal("byte-count mismatch accepted") |
| 198 | } |
| 199 | |
| 200 | // Digest mismatch is a protocol error. |
| 201 | tampered := descriptor() |
| 202 | tampered.SHA256 = strings.Repeat("0", 64) |
| 203 | result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{tampered}} |
| 204 | if err := client.resolveExternalizedReplacement(&result); err == nil { |
| 205 | t.Fatal("SHA-256 mismatch accepted") |
| 206 | } |
| 207 | |
| 208 | // A descriptor beyond the object cap is frame_too_large. |
| 209 | oversize := descriptor() |
| 210 | oversize.TotalBytes = protocol.ContentRefObjectBytes + 1 |
| 211 | result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{oversize}} |
| 212 | if err := client.resolveExternalizedReplacement(&result); err == nil { |
| 213 | t.Fatal("over-cap descriptor accepted") |
| 214 | } else if reason := protocolReason(t, err); reason != protocol.ErrFrameTooLarge { |
| 215 | t.Fatalf("over-cap reason = %q, want %q", reason, protocol.ErrFrameTooLarge) |
| 216 | } |
| 217 | |
| 218 | // A pointer outside the schema's externalizable set is a protocol error. |
| 219 | wrongPointer := descriptor() |
| 220 | wrongPointer.JSONPointer = "/reason" |
| 221 | result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{wrongPointer}} |
| 222 | if err := client.resolveExternalizedReplacement(&result); err == nil { |
| 223 | t.Fatal("externalized pointer outside the schema set accepted") |
| 224 | } |
| 225 | } |
| 226 |