rivetkit_core/inspector_bundle/
mod.rs1use std::collections::HashMap;
17
18use ::http::StatusCode;
19use include_dir::{Dir, include_dir};
20use rivet_envoy_client::config::HttpResponse;
21use serde_json::json;
22
23static INSPECTOR_UI_DIR: Dir<'_> = include_dir!("$OUT_DIR/inspector-ui");
27
28static INSPECTOR_TAB_DIR: Dir<'_> = include_dir!("$OUT_DIR/inspector-tab");
32
33const TAB_STYLESHEET_FILE: &str = "styles.css";
36
37const WASM_CUSTOM_TAB_UNAVAILABLE_HTML: &str = include_str!("wasm-custom-tab-unavailable.html");
43
44pub fn is_public_inspector_bundle_path(method: &str, pathname: &str) -> bool {
53 if method != "GET" {
54 return false;
55 }
56 pathname == "/inspector/tab.css"
57 || pathname == "/inspector/ui/"
58 || pathname == "/inspector/ui"
59 || pathname.starts_with("/inspector/ui/")
60}
61
62pub fn serve_inspector_bundle(method: &str, pathname: &str) -> Option<HttpResponse> {
69 if method != "GET" {
70 return None;
71 }
72 if pathname == "/inspector/tab.css" {
73 return Some(serve_tab_stylesheet());
74 }
75 let rel = map_ui_pathname_to_rel(pathname)?;
76 if is_unsafe_rel(&rel) {
77 return Some(not_found_response());
78 }
79 Some(serve_ui_asset(&rel))
80}
81
82pub fn serve_wasm_custom_tab_unavailable() -> HttpResponse {
86 HttpResponse {
87 status: StatusCode::OK.as_u16(),
88 headers: shared_response_headers("text/html; charset=utf-8"),
89 body: Some(WASM_CUSTOM_TAB_UNAVAILABLE_HTML.as_bytes().to_vec()),
90 body_stream: None,
91 }
92}
93
94fn serve_ui_asset(rel: &str) -> HttpResponse {
99 let stripped = rel.trim_start_matches('/');
102 let candidate = if stripped.is_empty() || stripped.ends_with('/') {
103 format!("{stripped}index.html")
104 } else {
105 stripped.to_owned()
106 };
107 match INSPECTOR_UI_DIR.get_file(&candidate) {
108 Some(file) => ok_response(mime_of(&candidate), file.contents().to_vec()),
109 None => not_found_response(),
110 }
111}
112
113fn serve_tab_stylesheet() -> HttpResponse {
114 match INSPECTOR_TAB_DIR.get_file(TAB_STYLESHEET_FILE) {
115 Some(file) => ok_response("text/css; charset=utf-8", file.contents().to_vec()),
116 None => not_found_response(),
117 }
118}
119
120fn map_ui_pathname_to_rel(pathname: &str) -> Option<String> {
125 if pathname == "/inspector/ui/" || pathname == "/inspector/ui" {
126 return Some("index.html".to_owned());
127 }
128 pathname
129 .strip_prefix("/inspector/ui/")
130 .map(|rest| rest.to_owned())
131}
132
133fn is_unsafe_rel(rel: &str) -> bool {
134 if rel.is_empty() {
135 return true;
136 }
137 if rel.starts_with('/') {
138 return true;
139 }
140 for seg in rel.split('/') {
141 if seg == ".." || seg == "." {
142 return true;
143 }
144 }
145 false
146}
147
148fn mime_of(rel: &str) -> &'static str {
149 let Some(dot) = rel.rfind('.') else {
150 return "application/octet-stream";
151 };
152 let ext = rel[dot + 1..].to_ascii_lowercase();
153 match ext.as_str() {
154 "html" | "htm" => "text/html; charset=utf-8",
155 "js" | "mjs" | "cjs" => "application/javascript; charset=utf-8",
156 "css" => "text/css; charset=utf-8",
157 "json" | "map" => "application/json; charset=utf-8",
158 "svg" => "image/svg+xml",
159 "png" => "image/png",
160 "jpg" | "jpeg" => "image/jpeg",
161 "gif" => "image/gif",
162 "webp" => "image/webp",
163 "ico" => "image/x-icon",
164 "woff" => "font-woff",
165 "woff2" => "font-woff2",
166 "ttf" => "font-ttf",
167 "otf" => "font-otf",
168 "txt" => "text/plain; charset=utf-8",
169 "wasm" => "application/wasm",
170 _ => "application/octet-stream",
171 }
172}
173
174fn shared_response_headers(content_type: &str) -> HashMap<String, String> {
175 let mut headers = HashMap::new();
176 headers.insert("Content-Type".to_owned(), content_type.to_owned());
177 headers.insert("Cache-Control".to_owned(), "no-cache".to_owned());
178 headers.insert("Referrer-Policy".to_owned(), "no-referrer".to_owned());
179 headers
180}
181
182fn ok_response(content_type: &str, body: Vec<u8>) -> HttpResponse {
183 HttpResponse {
184 status: StatusCode::OK.as_u16(),
185 headers: shared_response_headers(content_type),
186 body: Some(body),
187 body_stream: None,
188 }
189}
190
191fn not_found_response() -> HttpResponse {
192 let body = json!({
193 "group": "inspector",
194 "code": "ui_asset_not_found",
195 "message": "Inspector UI asset was not found in the embedded bundle",
196 "metadata": serde_json::Value::Null,
197 });
198 let bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
199 HttpResponse {
200 status: StatusCode::NOT_FOUND.as_u16(),
201 headers: shared_response_headers("application/json"),
202 body: Some(bytes),
203 body_stream: None,
204 }
205}
206
207#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn map_ui_pathname_to_rel_maps_known_paths() {
217 assert_eq!(
218 map_ui_pathname_to_rel("/inspector/ui").as_deref(),
219 Some("index.html"),
220 );
221 assert_eq!(
222 map_ui_pathname_to_rel("/inspector/ui/").as_deref(),
223 Some("index.html"),
224 );
225 assert_eq!(
226 map_ui_pathname_to_rel("/inspector/ui/assets/app.js").as_deref(),
227 Some("assets/app.js"),
228 );
229 assert!(map_ui_pathname_to_rel("/inspector/state").is_none());
230 }
231
232 #[test]
233 fn is_unsafe_rel_rejects_traversal_and_absolute() {
234 assert!(is_unsafe_rel(""));
235 assert!(is_unsafe_rel("/etc/passwd"));
236 assert!(is_unsafe_rel("../etc/passwd"));
237 assert!(is_unsafe_rel("assets/../../etc"));
238 assert!(is_unsafe_rel("./assets"));
239 assert!(!is_unsafe_rel("assets/app.js"));
240 assert!(!is_unsafe_rel("index.html"));
241 }
242
243 #[test]
244 fn is_public_inspector_bundle_path_matches() {
245 assert!(is_public_inspector_bundle_path("GET", "/inspector/ui/"));
246 assert!(is_public_inspector_bundle_path("GET", "/inspector/ui"));
247 assert!(is_public_inspector_bundle_path(
248 "GET",
249 "/inspector/ui/assets/app.js"
250 ));
251 assert!(is_public_inspector_bundle_path("GET", "/inspector/tab.css"));
252 assert!(!is_public_inspector_bundle_path("POST", "/inspector/ui/"));
253 assert!(!is_public_inspector_bundle_path("GET", "/inspector/state"));
254 }
255
256 #[test]
257 fn non_get_and_unrelated_paths_are_passthrough() {
258 assert!(serve_inspector_bundle("POST", "/inspector/ui/").is_none());
259 assert!(serve_inspector_bundle("GET", "/inspector/state").is_none());
260 }
261
262 #[test]
263 fn wasm_custom_tab_unavailable_is_html() {
264 let resp = serve_wasm_custom_tab_unavailable();
265 assert_eq!(resp.status, StatusCode::OK.as_u16());
266 assert_eq!(
267 resp.headers.get("Content-Type").map(String::as_str),
268 Some("text/html; charset=utf-8"),
269 );
270 let body = resp.body.expect("body present");
271 assert!(body.starts_with(b"<!doctype html>"));
272 }
273}