vivarium 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "http/2"
5
+ rescue LoadError
6
+ # http/2 gem is optional; without it we can still parse frame headers but not HPACK-decompress HEADERS.
7
+ end
8
+
9
+ module Vivarium
10
+ # Decodes payloads captured from OpenSSL `SSL_write` into a human-readable
11
+ # one-liner. Auto-detects HTTP/1.x request/response lines and HTTP/2 binary
12
+ # frames; HPACK-decompresses HEADERS / CONTINUATION when the `http-2` gem
13
+ # is available, otherwise reports frame types only.
14
+ #
15
+ # HPACK decompressor state is kept per pid. This is sufficient for the
16
+ # common "one HTTPS connection per process" case; with multiple concurrent
17
+ # TLS connections per pid the HPACK table can diverge and decoding may
18
+ # fail — in that case the decompressor for that pid is reset on the next
19
+ # decode error.
20
+ class HttpDecoder
21
+ HTTP1_METHODS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS TRACE CONNECT].freeze
22
+ HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".b
23
+ H2_FRAME_HEADER_SIZE = 9
24
+ H2_FLAG_END_HEADERS = 0x04
25
+ H2_FLAG_PADDED = 0x08
26
+ H2_FLAG_PRIORITY = 0x20
27
+ FRAME_TYPE_NAMES = {
28
+ 0x0 => "DATA",
29
+ 0x1 => "HEADERS",
30
+ 0x2 => "PRIORITY",
31
+ 0x3 => "RST_STREAM",
32
+ 0x4 => "SETTINGS",
33
+ 0x5 => "PUSH_PROMISE",
34
+ 0x6 => "PING",
35
+ 0x7 => "GOAWAY",
36
+ 0x8 => "WINDOW_UPDATE",
37
+ 0x9 => "CONTINUATION"
38
+ }.freeze
39
+
40
+ def initialize
41
+ @hpack_available = load_http2_gem
42
+ @decompressors = {}
43
+ @continuation = {}
44
+ end
45
+
46
+ def hpack_available?
47
+ @hpack_available
48
+ end
49
+
50
+ def render(pid:, data:, data_len:)
51
+ data = data.to_s.b
52
+ data_len = data_len.to_i
53
+
54
+ return "data_len=0" if data_len <= 0
55
+ return "len=#{data_len} <no-capture>" if data.empty?
56
+
57
+ if (summary = http1_summary(data))
58
+ kind, line = summary
59
+ return "http/1.x #{kind}: #{line}#{truncation_note(data, data_len)}"
60
+ end
61
+
62
+ rest = data
63
+ preface_note = ""
64
+ if rest.start_with?(HTTP2_PREFACE)
65
+ preface_note = "preface "
66
+ rest = rest.byteslice(HTTP2_PREFACE.bytesize..) || "".b
67
+ end
68
+
69
+ frames = parse_h2_frames(rest)
70
+ if frames.empty?
71
+ if !preface_note.empty?
72
+ return "h2 preface only#{truncation_note(data, data_len)}"
73
+ end
74
+ return "binary len=#{data_len}#{truncation_note(data, data_len)}"
75
+ end
76
+
77
+ rendered = frames.map { |f| render_h2_frame(pid, f) }.join(" | ")
78
+ "h2 #{preface_note}#{rendered}#{truncation_note(data, data_len)}"
79
+ end
80
+
81
+ private
82
+
83
+ def truncation_note(data, data_len)
84
+ return "" if data.bytesize >= data_len
85
+
86
+ " (captured #{data.bytesize}/#{data_len}B)"
87
+ end
88
+
89
+ def load_http2_gem
90
+ require "http/2"
91
+ true
92
+ rescue LoadError
93
+ false
94
+ end
95
+
96
+ def http1_summary(data)
97
+ head = data.byteslice(0, 512).to_s
98
+ first_line = head.split("\r\n", 2).first
99
+ return nil if first_line.nil? || first_line.empty?
100
+
101
+ first_line = first_line.dup.force_encoding(Encoding::UTF_8)
102
+ return nil unless first_line.valid_encoding?
103
+
104
+ if HTTP1_METHODS.any? { |m| first_line.start_with?("#{m} ") }
105
+ return ["request", first_line]
106
+ end
107
+
108
+ if first_line.start_with?("HTTP/1.1 ") || first_line.start_with?("HTTP/1.0 ")
109
+ return ["response", first_line]
110
+ end
111
+
112
+ nil
113
+ end
114
+
115
+ # @return [Array<Array(Integer, Integer, Integer, String, Boolean)>]
116
+ # each entry: [frame_type, flags, stream_id, frame_payload, truncated?]
117
+ def parse_h2_frames(payload)
118
+ frames = []
119
+ i = 0
120
+ total = payload.bytesize
121
+
122
+ while i + H2_FRAME_HEADER_SIZE <= total
123
+ length = (payload.getbyte(i) << 16) |
124
+ (payload.getbyte(i + 1) << 8) |
125
+ payload.getbyte(i + 2)
126
+ frame_type = payload.getbyte(i + 3)
127
+ flags = payload.getbyte(i + 4)
128
+ stream_id = payload.byteslice(i + 5, 4).unpack1("N") & 0x7fff_ffff
129
+ i += H2_FRAME_HEADER_SIZE
130
+
131
+ if i + length > total
132
+ remaining = payload.byteslice(i, total - i) || "".b
133
+ frames << [frame_type, flags, stream_id, remaining, true]
134
+ break
135
+ end
136
+
137
+ frame_payload = payload.byteslice(i, length) || "".b
138
+ i += length
139
+ frames << [frame_type, flags, stream_id, frame_payload, false]
140
+ end
141
+
142
+ # Heuristic: if the very first "frame" doesn't look like a valid HTTP/2
143
+ # frame, refuse the whole parse so we fall back to "binary".
144
+ first_type = frames.first && frames.first[0]
145
+ return [] if first_type && !FRAME_TYPE_NAMES.key?(first_type)
146
+
147
+ frames
148
+ end
149
+
150
+ def render_h2_frame(pid, frame)
151
+ frame_type, flags, stream_id, frame_payload, truncated = frame
152
+ frame_name = FRAME_TYPE_NAMES.fetch(frame_type, "TYPE0x#{format('%02x', frame_type)}")
153
+ header = "#{frame_name} stream=#{stream_id} flags=0x#{format('%02x', flags)} len=#{frame_payload.bytesize}#{truncated ? '*' : ''}"
154
+
155
+ case frame_type
156
+ when 0x1 # HEADERS
157
+ fragment = headers_fragment(flags, frame_payload)
158
+ return "#{header} <bad_payload>" if fragment.nil?
159
+
160
+ if (flags & H2_FLAG_END_HEADERS) != 0
161
+ pseudo = decode_hpack(pid, fragment)
162
+ "#{header}#{format_pseudo(pseudo)}"
163
+ else
164
+ @continuation[[pid, stream_id]] = fragment.dup
165
+ "#{header} <collecting>"
166
+ end
167
+ when 0x9 # CONTINUATION
168
+ key = [pid, stream_id]
169
+ unless @continuation.key?(key)
170
+ return "#{header} <orphan>"
171
+ end
172
+
173
+ @continuation[key] << frame_payload
174
+ if (flags & H2_FLAG_END_HEADERS) == 0
175
+ "#{header} <collecting>"
176
+ else
177
+ buf = @continuation.delete(key)
178
+ pseudo = decode_hpack(pid, buf)
179
+ "#{header}#{format_pseudo(pseudo)}"
180
+ end
181
+ else
182
+ header
183
+ end
184
+ end
185
+
186
+ def headers_fragment(flags, frame_payload)
187
+ start_idx = 0
188
+ end_idx = frame_payload.bytesize
189
+
190
+ if (flags & H2_FLAG_PADDED) != 0
191
+ return nil if end_idx.zero?
192
+
193
+ pad_len = frame_payload.getbyte(0)
194
+ start_idx += 1
195
+ end_idx = [start_idx, end_idx - pad_len].max
196
+ end
197
+
198
+ if (flags & H2_FLAG_PRIORITY) != 0
199
+ return nil if start_idx + 5 > end_idx
200
+
201
+ start_idx += 5
202
+ end
203
+
204
+ frame_payload.byteslice(start_idx, end_idx - start_idx) || "".b
205
+ end
206
+
207
+ def decompressor_for(pid)
208
+ return nil unless @hpack_available
209
+
210
+ @decompressors[pid] ||= HTTP2::Header::Decompressor.new
211
+ end
212
+
213
+ def decode_hpack(pid, header_block)
214
+ dec = decompressor_for(pid)
215
+ return { ":error" => "hpack-unavailable" } unless dec
216
+
217
+ pairs = dec.decode(header_block.b)
218
+ pairs.each_with_object({}) { |(k, v), h| h[k] = v }
219
+ rescue StandardError => e
220
+ @decompressors.delete(pid)
221
+ { ":error" => "#{e.class}: #{e.message}" }
222
+ end
223
+
224
+ def format_pseudo(pseudo)
225
+ return " <error: #{pseudo[':error']}>" if pseudo.key?(":error")
226
+
227
+ parts = []
228
+ parts << ":method=#{pseudo[':method']}" if pseudo[':method']
229
+ parts << ":path=#{pseudo[':path']}" if pseudo[':path']
230
+ parts << ":authority=#{pseudo[':authority']}" if pseudo[':authority']
231
+ parts << ":status=#{pseudo[':status']}" if pseudo[':status']
232
+ return "" if parts.empty?
233
+
234
+ " #{parts.join(' ')}"
235
+ end
236
+ end
237
+ end