Skip to main content

linera_rpc/grpc/
mod.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4mod 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
41/// Limit of gRPC message size up to which we will try to populate with data when estimating.
42/// We leave 30% of buffer for the rest of the message and potential underestimation.
43pub const GRPC_CHUNKED_MESSAGE_FILL_LIMIT: usize = GRPC_MAX_MESSAGE_SIZE * 7 / 10;
44
45/// Prometheus label for the gRPC method name.
46pub const METHOD_NAME_LABEL: &str = "method_name";
47
48/// Prometheus label for distinguishing organic vs synthetic (benchmark) traffic.
49pub const TRAFFIC_TYPE_LABEL: &str = "traffic_type";
50
51/// Prometheus label for the error variant name, e.g. `"WorkerError::UnexpectedBlockHeight"`.
52pub const ERROR_TYPE_LABEL: &str = "error_type";
53
54/// Maximum length of a single proto identifier accepted as a service or method name.
55/// Real proto identifiers are far shorter than this; the cap guards against attacker
56/// input being recorded verbatim as a Prometheus label value.
57const MAX_PROTO_IDENT_LEN: usize = 128;
58
59/// Returns `true` if `s` is a valid protobuf identifier (`[A-Za-z][A-Za-z0-9_]*`)
60/// within the length cap.
61fn 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
70/// Returns `true` if `s` is a fully-qualified proto Service-Name: two or more
71/// proto identifiers joined by dots, e.g. `package.Service` or `outer.inner.Service`.
72fn 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
83/// Extracts the gRPC method name from a request URI path.
84///
85/// gRPC paths follow the HTTP/2 form `"/" Service-Name "/" {method name}`, where
86/// `Service-Name` is `{proto package} "." {service name}` and the method name is a
87/// single proto identifier. Anything that does not match this exact shape — health
88/// probes, browser requests, bot scans probing for `.env` files, etc. — is mapped
89/// to `"non_grpc"` so the value is safe to use as a Prometheus label without
90/// blowing up cardinality or label-value length.
91pub 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}