tonic-rails 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/Cargo.lock +1242 -0
- data/Cargo.toml +11 -0
- data/LICENSE +21 -0
- data/README.md +391 -0
- data/ext/tonic_rails_native/Cargo.toml +44 -0
- data/ext/tonic_rails_native/extconf.rb +9 -0
- data/ext/tonic_rails_native/src/body.rs +289 -0
- data/ext/tonic_rails_native/src/dispatch.rs +250 -0
- data/ext/tonic_rails_native/src/framing.rs +349 -0
- data/ext/tonic_rails_native/src/gvl.rs +473 -0
- data/ext/tonic_rails_native/src/lib.rs +90 -0
- data/ext/tonic_rails_native/src/net.rs +146 -0
- data/ext/tonic_rails_native/src/server.rs +507 -0
- data/lib/tonic/rails/base.rb +215 -0
- data/lib/tonic/rails/configuration.rb +149 -0
- data/lib/tonic/rails/railtie.rb +56 -0
- data/lib/tonic/rails/reflection.rb +50 -0
- data/lib/tonic/rails/router.rb +71 -0
- data/lib/tonic/rails/rpc_error.rb +65 -0
- data/lib/tonic/rails/server.rb +224 -0
- data/lib/tonic/rails/tasks/tonic-rails.rake +36 -0
- data/lib/tonic/rails/testing.rb +85 -0
- data/lib/tonic/rails/version.rb +7 -0
- data/lib/tonic-rails.rb +91 -0
- metadata +121 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
//! gRPC wire format: length-prefixed message framing, gzip, `grpc-timeout`
|
|
2
|
+
//! parsing, and status trailers.
|
|
3
|
+
|
|
4
|
+
use std::io::{Read, Write};
|
|
5
|
+
use std::time::Duration;
|
|
6
|
+
|
|
7
|
+
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
|
8
|
+
use http::{HeaderMap, HeaderValue};
|
|
9
|
+
|
|
10
|
+
/// Responses smaller than this are never compressed — gzip overhead
|
|
11
|
+
/// outweighs the savings.
|
|
12
|
+
pub const COMPRESSION_THRESHOLD: usize = 1024;
|
|
13
|
+
|
|
14
|
+
const HEADER_LEN: usize = 5;
|
|
15
|
+
|
|
16
|
+
/// Why an incoming message could not be unframed.
|
|
17
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
18
|
+
pub enum FrameError {
|
|
19
|
+
/// Declared or decompressed size exceeds `max_message_size`.
|
|
20
|
+
TooLarge,
|
|
21
|
+
/// Compressed flag set without a negotiated `grpc-encoding`, or the
|
|
22
|
+
/// gzip payload is corrupt.
|
|
23
|
+
Compression,
|
|
24
|
+
/// The stream ended partway through a frame.
|
|
25
|
+
Truncated,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl FrameError {
|
|
29
|
+
pub fn code(&self) -> tonic::Code {
|
|
30
|
+
match self {
|
|
31
|
+
FrameError::TooLarge => tonic::Code::ResourceExhausted,
|
|
32
|
+
FrameError::Compression | FrameError::Truncated => tonic::Code::Internal,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub fn message(&self) -> &'static str {
|
|
37
|
+
match self {
|
|
38
|
+
FrameError::TooLarge => "request message exceeds max_message_size",
|
|
39
|
+
FrameError::Compression => "invalid compressed request message",
|
|
40
|
+
FrameError::Truncated => "truncated request message",
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Incremental gRPC message reader for request bodies.
|
|
46
|
+
///
|
|
47
|
+
/// Buffers incoming bytes and extracts complete messages one at a time.
|
|
48
|
+
/// Size is checked against the declared length before the payload
|
|
49
|
+
/// arrives, and decompressed output is capped (zip-bomb guard).
|
|
50
|
+
pub struct MessageReader {
|
|
51
|
+
buf: BytesMut,
|
|
52
|
+
max_message_size: usize,
|
|
53
|
+
gzip: bool,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl MessageReader {
|
|
57
|
+
pub fn new(max_message_size: usize, gzip: bool) -> Self {
|
|
58
|
+
Self {
|
|
59
|
+
buf: BytesMut::new(),
|
|
60
|
+
max_message_size,
|
|
61
|
+
gzip,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub fn push(&mut self, chunk: &[u8]) {
|
|
66
|
+
self.buf.extend_from_slice(chunk);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// `Ok(None)` means more data is needed.
|
|
70
|
+
pub fn try_next(&mut self) -> Result<Option<Vec<u8>>, FrameError> {
|
|
71
|
+
if self.buf.len() < HEADER_LEN {
|
|
72
|
+
return Ok(None);
|
|
73
|
+
}
|
|
74
|
+
let compressed = self.buf[0] != 0;
|
|
75
|
+
let length = u32::from_be_bytes([self.buf[1], self.buf[2], self.buf[3], self.buf[4]]) as usize;
|
|
76
|
+
|
|
77
|
+
if length > self.max_message_size {
|
|
78
|
+
return Err(FrameError::TooLarge);
|
|
79
|
+
}
|
|
80
|
+
if self.buf.len() < HEADER_LEN + length {
|
|
81
|
+
return Ok(None);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let payload = &self.buf[HEADER_LEN..HEADER_LEN + length];
|
|
85
|
+
let message = if compressed {
|
|
86
|
+
if !self.gzip {
|
|
87
|
+
return Err(FrameError::Compression);
|
|
88
|
+
}
|
|
89
|
+
gunzip(payload, self.max_message_size)?
|
|
90
|
+
} else {
|
|
91
|
+
payload.to_vec()
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
self.buf.advance(HEADER_LEN + length);
|
|
95
|
+
Ok(Some(message))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Call once the body has ended: leftover bytes mean a truncated frame.
|
|
99
|
+
pub fn finish(&self) -> Result<(), FrameError> {
|
|
100
|
+
if self.buf.is_empty() {
|
|
101
|
+
Ok(())
|
|
102
|
+
} else {
|
|
103
|
+
Err(FrameError::Truncated)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn gunzip(payload: &[u8], max_size: usize) -> Result<Vec<u8>, FrameError> {
|
|
109
|
+
let mut decoder = flate2::read::GzDecoder::new(payload);
|
|
110
|
+
let mut out = Vec::with_capacity(payload.len().saturating_mul(2).min(max_size));
|
|
111
|
+
let mut buf = [0u8; 8192];
|
|
112
|
+
loop {
|
|
113
|
+
let n = decoder.read(&mut buf).map_err(|_| FrameError::Compression)?;
|
|
114
|
+
if n == 0 {
|
|
115
|
+
return Ok(out);
|
|
116
|
+
}
|
|
117
|
+
if out.len() + n > max_size {
|
|
118
|
+
return Err(FrameError::TooLarge);
|
|
119
|
+
}
|
|
120
|
+
out.extend_from_slice(&buf[..n]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Frame a protobuf message, gzip-compressing it when `compress` is set.
|
|
125
|
+
pub fn frame(message: &[u8], compress: bool) -> Bytes {
|
|
126
|
+
if compress {
|
|
127
|
+
let mut encoder =
|
|
128
|
+
flate2::write::GzEncoder::new(Vec::with_capacity(message.len() / 2), flate2::Compression::fast());
|
|
129
|
+
// Writing into a Vec cannot fail.
|
|
130
|
+
if encoder.write_all(message).is_ok()
|
|
131
|
+
&& let Ok(compressed) = encoder.finish()
|
|
132
|
+
{
|
|
133
|
+
return frame_raw(&compressed, true);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
frame_raw(message, false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn frame_raw(payload: &[u8], compressed: bool) -> Bytes {
|
|
140
|
+
let mut buf = BytesMut::with_capacity(HEADER_LEN + payload.len());
|
|
141
|
+
buf.put_u8(u8::from(compressed));
|
|
142
|
+
buf.put_u32(payload.len() as u32);
|
|
143
|
+
buf.put_slice(payload);
|
|
144
|
+
buf.freeze()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Parse a `grpc-timeout` header value (`<digits><unit>`, at most 8
|
|
148
|
+
/// digits per the spec, though longer values are tolerated if they fit).
|
|
149
|
+
pub fn parse_grpc_timeout(value: &str) -> Option<Duration> {
|
|
150
|
+
if value.len() < 2 || !value.is_char_boundary(value.len() - 1) {
|
|
151
|
+
return None;
|
|
152
|
+
}
|
|
153
|
+
let (num, unit) = value.split_at(value.len() - 1);
|
|
154
|
+
if !num.bytes().all(|b| b.is_ascii_digit()) {
|
|
155
|
+
return None;
|
|
156
|
+
}
|
|
157
|
+
let num: u64 = num.parse().ok()?;
|
|
158
|
+
// checked_mul: a huge value must not wrap to a tiny deadline.
|
|
159
|
+
Some(match unit {
|
|
160
|
+
"H" => Duration::from_secs(num.checked_mul(3600)?),
|
|
161
|
+
"M" => Duration::from_secs(num.checked_mul(60)?),
|
|
162
|
+
"S" => Duration::from_secs(num),
|
|
163
|
+
"m" => Duration::from_millis(num),
|
|
164
|
+
"u" => Duration::from_micros(num),
|
|
165
|
+
"n" => Duration::from_nanos(num),
|
|
166
|
+
_ => return None,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Percent-encode a status message for the `grpc-message` trailer as
|
|
171
|
+
/// required by the gRPC HTTP/2 spec: bytes outside printable ASCII, and
|
|
172
|
+
/// `%` itself, become `%XX`. This also neutralises control characters.
|
|
173
|
+
pub fn encode_grpc_message(message: &str) -> String {
|
|
174
|
+
let mut out = String::with_capacity(message.len());
|
|
175
|
+
for &b in message.as_bytes() {
|
|
176
|
+
if (0x20..=0x7e).contains(&b) && b != b'%' {
|
|
177
|
+
out.push(b as char);
|
|
178
|
+
} else {
|
|
179
|
+
out.push_str(&format!("%{b:02X}"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Replace control characters so untrusted text cannot inject log lines.
|
|
186
|
+
pub fn sanitize_for_log(message: &str) -> String {
|
|
187
|
+
message
|
|
188
|
+
.chars()
|
|
189
|
+
.map(|c| if c.is_control() { ' ' } else { c })
|
|
190
|
+
.collect()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pub fn status_trailers(code: u32, message: &str) -> HeaderMap {
|
|
194
|
+
let mut trailers = HeaderMap::with_capacity(2);
|
|
195
|
+
trailers.insert("grpc-status", HeaderValue::from(code));
|
|
196
|
+
if !message.is_empty()
|
|
197
|
+
&& let Ok(value) = HeaderValue::from_str(&encode_grpc_message(message))
|
|
198
|
+
{
|
|
199
|
+
trailers.insert("grpc-message", value);
|
|
200
|
+
}
|
|
201
|
+
trailers
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[cfg(test)]
|
|
205
|
+
mod tests {
|
|
206
|
+
use super::*;
|
|
207
|
+
|
|
208
|
+
fn framed(payload: &[u8]) -> Vec<u8> {
|
|
209
|
+
frame(payload, false).to_vec()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn parse_timeout_units() {
|
|
214
|
+
assert_eq!(parse_grpc_timeout("5S"), Some(Duration::from_secs(5)));
|
|
215
|
+
assert_eq!(parse_grpc_timeout("100m"), Some(Duration::from_millis(100)));
|
|
216
|
+
assert_eq!(parse_grpc_timeout("2H"), Some(Duration::from_secs(7200)));
|
|
217
|
+
assert_eq!(parse_grpc_timeout("3M"), Some(Duration::from_secs(180)));
|
|
218
|
+
assert_eq!(parse_grpc_timeout("7u"), Some(Duration::from_micros(7)));
|
|
219
|
+
assert_eq!(parse_grpc_timeout("9n"), Some(Duration::from_nanos(9)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[test]
|
|
223
|
+
fn parse_timeout_overflow_returns_none() {
|
|
224
|
+
let huge = u64::MAX.to_string();
|
|
225
|
+
assert_eq!(parse_grpc_timeout(&format!("{huge}H")), None);
|
|
226
|
+
assert_eq!(parse_grpc_timeout(&format!("{huge}M")), None);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[test]
|
|
230
|
+
fn parse_timeout_malformed_returns_none() {
|
|
231
|
+
for value in ["", "S", "5", "5x", "abcS", "-5S", "+5S", "5é"] {
|
|
232
|
+
assert_eq!(parse_grpc_timeout(value), None, "{value:?}");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#[test]
|
|
237
|
+
fn frame_uncompressed_header() {
|
|
238
|
+
assert_eq!(framed(b"abc"), vec![0, 0, 0, 0, 3, b'a', b'b', b'c']);
|
|
239
|
+
assert_eq!(framed(b""), vec![0, 0, 0, 0, 0]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn frame_compressed_round_trips() {
|
|
244
|
+
let payload = vec![b'x'; 4096];
|
|
245
|
+
let bytes = frame(&payload, true);
|
|
246
|
+
assert_eq!(bytes[0], 1);
|
|
247
|
+
let mut reader = MessageReader::new(8192, true);
|
|
248
|
+
reader.push(&bytes);
|
|
249
|
+
assert_eq!(reader.try_next(), Ok(Some(payload)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#[test]
|
|
253
|
+
fn reader_single_message() {
|
|
254
|
+
let mut reader = MessageReader::new(1024, false);
|
|
255
|
+
reader.push(&framed(b"abc"));
|
|
256
|
+
assert_eq!(reader.try_next(), Ok(Some(b"abc".to_vec())));
|
|
257
|
+
assert_eq!(reader.try_next(), Ok(None));
|
|
258
|
+
assert_eq!(reader.finish(), Ok(()));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn reader_message_split_across_chunks() {
|
|
263
|
+
let mut reader = MessageReader::new(1024, false);
|
|
264
|
+
let bytes = framed(b"hello");
|
|
265
|
+
reader.push(&bytes[..3]);
|
|
266
|
+
assert_eq!(reader.try_next(), Ok(None));
|
|
267
|
+
reader.push(&bytes[3..7]);
|
|
268
|
+
assert_eq!(reader.try_next(), Ok(None));
|
|
269
|
+
reader.push(&bytes[7..]);
|
|
270
|
+
assert_eq!(reader.try_next(), Ok(Some(b"hello".to_vec())));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#[test]
|
|
274
|
+
fn reader_multiple_messages() {
|
|
275
|
+
let mut reader = MessageReader::new(1024, false);
|
|
276
|
+
let mut data = framed(b"ab");
|
|
277
|
+
data.extend(framed(b"c"));
|
|
278
|
+
data.extend(framed(b""));
|
|
279
|
+
reader.push(&data);
|
|
280
|
+
assert_eq!(reader.try_next(), Ok(Some(b"ab".to_vec())));
|
|
281
|
+
assert_eq!(reader.try_next(), Ok(Some(b"c".to_vec())));
|
|
282
|
+
assert_eq!(reader.try_next(), Ok(Some(Vec::new())));
|
|
283
|
+
assert_eq!(reader.try_next(), Ok(None));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn reader_rejects_oversized_before_payload_arrives() {
|
|
288
|
+
let mut reader = MessageReader::new(100, false);
|
|
289
|
+
reader.push(&[0, 0, 0, 0, 200]);
|
|
290
|
+
assert_eq!(reader.try_next(), Err(FrameError::TooLarge));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[test]
|
|
294
|
+
fn reader_zip_bomb_guard() {
|
|
295
|
+
let payload = vec![0u8; 10_000];
|
|
296
|
+
let mut reader = MessageReader::new(1000, true);
|
|
297
|
+
reader.push(&frame(&payload, true));
|
|
298
|
+
assert_eq!(reader.try_next(), Err(FrameError::TooLarge));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn reader_rejects_compressed_without_encoding() {
|
|
303
|
+
let mut reader = MessageReader::new(8192, false);
|
|
304
|
+
reader.push(&frame(&[b'x'; 2048], true));
|
|
305
|
+
assert_eq!(reader.try_next(), Err(FrameError::Compression));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn reader_rejects_corrupt_gzip() {
|
|
310
|
+
let mut reader = MessageReader::new(1024, true);
|
|
311
|
+
reader.push(&[1, 0, 0, 0, 3, 1, 2, 3]);
|
|
312
|
+
assert_eq!(reader.try_next(), Err(FrameError::Compression));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn reader_detects_truncated_stream() {
|
|
317
|
+
let mut reader = MessageReader::new(1024, false);
|
|
318
|
+
reader.push(&[0, 0, 0, 0, 10, b'a']);
|
|
319
|
+
assert_eq!(reader.try_next(), Ok(None));
|
|
320
|
+
assert_eq!(reader.finish(), Err(FrameError::Truncated));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[test]
|
|
324
|
+
fn grpc_message_percent_encoding() {
|
|
325
|
+
assert_eq!(encode_grpc_message("not found"), "not found");
|
|
326
|
+
assert_eq!(encode_grpc_message("100%"), "100%25");
|
|
327
|
+
assert_eq!(encode_grpc_message("a\nb\0"), "a%0Ab%00");
|
|
328
|
+
assert_eq!(encode_grpc_message("café"), "caf%C3%A9");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[test]
|
|
332
|
+
fn status_trailers_include_message() {
|
|
333
|
+
let t = status_trailers(5, "missing\r\nX-Injected: 1");
|
|
334
|
+
assert_eq!(t["grpc-status"], "5");
|
|
335
|
+
assert_eq!(t["grpc-message"], "missing%0D%0AX-Injected: 1");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#[test]
|
|
339
|
+
fn status_trailers_omit_empty_message() {
|
|
340
|
+
let t = status_trailers(0, "");
|
|
341
|
+
assert_eq!(t["grpc-status"], "0");
|
|
342
|
+
assert!(t.get("grpc-message").is_none());
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#[test]
|
|
346
|
+
fn sanitize_for_log_strips_controls() {
|
|
347
|
+
assert_eq!(sanitize_for_log("a\nb\tc"), "a b c");
|
|
348
|
+
}
|
|
349
|
+
}
|