1mod client;
5mod conversions;
6mod node_provider;
7pub mod pool;
8#[cfg(with_server)]
9mod server;
10pub mod transport;
11
12pub use client::*;
13pub use conversions::*;
14pub use node_provider::*;
15#[cfg(with_server)]
16pub use server::*;
17
18pub mod api {
19 tonic::include_proto!("rpc.v1");
20}
21
22#[derive(thiserror::Error, Debug)]
23pub enum GrpcError {
24 #[error("failed to connect to address: {0}")]
25 ConnectionFailed(#[from] transport::Error),
26
27 #[error("failed to execute task to completion: {0}")]
28 Join(#[from] futures::channel::oneshot::Canceled),
29
30 #[error("failed to parse socket address: {0}")]
31 SocketAddr(#[from] std::net::AddrParseError),
32
33 #[cfg(with_server)]
34 #[error(transparent)]
35 Reflection(#[from] tonic_reflection::server::Error),
36}
37
38const MEBIBYTE: usize = 1024 * 1024;
39pub const GRPC_MAX_MESSAGE_SIZE: usize = 16 * MEBIBYTE;
40
41pub const GRPC_CHUNKED_MESSAGE_FILL_LIMIT: usize = GRPC_MAX_MESSAGE_SIZE * 7 / 10;
44
45pub const METHOD_NAME_LABEL: &str = "method_name";
47
48pub const TRAFFIC_TYPE_LABEL: &str = "traffic_type";
50
51pub const ERROR_TYPE_LABEL: &str = "error_type";
53
54const MAX_PROTO_IDENT_LEN: usize = 128;
58
59fn is_proto_identifier(s: &str) -> bool {
62 if s.is_empty() || s.len() > MAX_PROTO_IDENT_LEN {
63 return false;
64 }
65 let mut bytes = s.bytes();
66 let first = bytes.next().expect("non-empty checked above");
67 first.is_ascii_alphabetic() && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
68}
69
70fn is_proto_service_name(s: &str) -> bool {
73 let mut parts = s.split('.');
74 let Some(first) = parts.next() else {
75 return false;
76 };
77 let Some(second) = parts.next() else {
78 return false;
79 };
80 is_proto_identifier(first) && is_proto_identifier(second) && parts.all(is_proto_identifier)
81}
82
83pub fn extract_grpc_method_name(path: &str) -> &str {
92 let mut parts = path.splitn(3, '/');
93 let (Some(""), Some(service), Some(method)) = (parts.next(), parts.next(), parts.next()) else {
94 return "non_grpc";
95 };
96 if is_proto_service_name(service) && is_proto_identifier(method) {
97 method
98 } else {
99 "non_grpc"
100 }
101}
102
103#[cfg(test)]
104mod method_name_tests {
105 use super::*;
106
107 #[test]
108 fn grpc_unary_method() {
109 assert_eq!(
110 extract_grpc_method_name("/rpc.v1.ValidatorNode/HandleBlockProposal"),
111 "HandleBlockProposal"
112 );
113 }
114
115 #[test]
116 fn grpc_streaming_method() {
117 assert_eq!(
118 extract_grpc_method_name("/rpc.v1.ValidatorNode/SubscribeToNotifications"),
119 "SubscribeToNotifications"
120 );
121 }
122
123 #[test]
124 fn health_check_path() {
125 assert_eq!(
126 extract_grpc_method_name("/grpc.health.v1.Health/Check"),
127 "Check"
128 );
129 }
130
131 #[test]
132 fn non_grpc_root_path() {
133 assert_eq!(extract_grpc_method_name("/"), "non_grpc");
134 }
135
136 #[test]
137 fn non_grpc_plain_path() {
138 assert_eq!(extract_grpc_method_name("/healthz"), "non_grpc");
139 }
140
141 #[test]
142 fn non_grpc_no_dot_in_service() {
143 assert_eq!(extract_grpc_method_name("/NoDotService/Method"), "non_grpc");
144 }
145
146 #[test]
147 fn empty_path() {
148 assert_eq!(extract_grpc_method_name(""), "non_grpc");
149 }
150
151 #[test]
152 fn dot_env_bot_scan_does_not_leak_into_label() {
153 assert_eq!(
154 extract_grpc_method_name(
155 "/.env.local/.env.production/.env.staging/.env.development/.env.test"
156 ),
157 "non_grpc"
158 );
159 }
160
161 #[test]
162 fn service_starting_with_dot_is_rejected() {
163 assert_eq!(extract_grpc_method_name("/.foo.bar/Method"), "non_grpc");
164 }
165
166 #[test]
167 fn method_with_extra_path_segments_is_rejected() {
168 assert_eq!(
169 extract_grpc_method_name("/foo.bar/Method/extra"),
170 "non_grpc"
171 );
172 }
173
174 #[test]
175 fn method_with_invalid_characters_is_rejected() {
176 assert_eq!(extract_grpc_method_name("/foo.bar/Method-x"), "non_grpc");
177 assert_eq!(extract_grpc_method_name("/foo.bar/Method?x"), "non_grpc");
178 assert_eq!(extract_grpc_method_name("/foo.bar/.Method"), "non_grpc");
179 }
180
181 #[test]
182 fn identifier_starting_with_underscore_is_rejected() {
183 assert_eq!(extract_grpc_method_name("/foo.bar/_Method"), "non_grpc");
184 assert_eq!(extract_grpc_method_name("/_foo.bar/Method"), "non_grpc");
185 assert_eq!(
186 extract_grpc_method_name("/foo.bar/________________"),
187 "non_grpc"
188 );
189 }
190
191 #[test]
192 fn empty_method_segment_is_rejected() {
193 assert_eq!(extract_grpc_method_name("/foo.bar/"), "non_grpc");
194 }
195
196 #[test]
197 fn empty_service_segment_is_rejected() {
198 assert_eq!(extract_grpc_method_name("//Method"), "non_grpc");
199 }
200
201 #[test]
202 fn service_with_consecutive_dots_is_rejected() {
203 assert_eq!(extract_grpc_method_name("/foo..bar/Method"), "non_grpc");
204 }
205
206 #[test]
207 fn overlong_method_is_rejected() {
208 let long_method = "M".repeat(MAX_PROTO_IDENT_LEN + 1);
209 let path = format!("/foo.bar/{long_method}");
210 assert_eq!(extract_grpc_method_name(&path), "non_grpc");
211 }
212
213 #[test]
214 fn path_without_leading_slash_is_rejected() {
215 assert_eq!(extract_grpc_method_name("foo.bar/Method"), "non_grpc");
216 }
217}