1use std::sync::Arc;
2use subtle::ConstantTimeEq;
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::net::TcpListener;
5
6const DEFAULT_PORT: u16 = 3333;
7const DEFAULT_HOST: &str = "127.0.0.1";
8const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
9const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
10const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
11const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
12const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
13const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
14const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
15const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
16const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
17const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
18const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
19const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
20const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
21const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
22const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
23const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
24 include_str!("static/components/cockpit-compression.js");
25const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
26const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
27const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
28 include_str!("static/components/cockpit-architecture.js");
29const COCKPIT_COMPONENT_EXPLORER_JS: &str = include_str!("static/components/cockpit-explorer.js");
30const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
31const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
32const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
33const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
34const COCKPIT_COMPONENT_ROI_JS: &str = include_str!("static/components/cockpit-roi.js");
35const COCKPIT_COMPONENT_AREA_TABS_JS: &str = include_str!("static/components/cockpit-area-tabs.js");
36const COCKPIT_COMPONENT_PROTECTION_JS: &str =
37 include_str!("static/components/cockpit-protection.js");
38
39const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
42const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
43const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
44const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
45
46const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
50const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
51const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
52
53fn match_font_asset(path: &str) -> Option<&'static [u8]> {
55 match path {
56 "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
57 "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
58 "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
59 _ => None,
60 }
61}
62
63pub mod base_path;
64pub mod routes;
65
66pub async fn start(
67 port: Option<u16>,
68 host: Option<String>,
69 base_path: Option<String>,
70 auth_token: Option<String>,
71) {
72 let port = port.unwrap_or_else(|| {
73 std::env::var("LEAN_CTX_PORT")
74 .ok()
75 .and_then(|p| p.parse().ok())
76 .unwrap_or(DEFAULT_PORT)
77 });
78
79 let host = host.unwrap_or_else(|| {
80 std::env::var("LEAN_CTX_HOST")
81 .ok()
82 .unwrap_or_else(|| DEFAULT_HOST.to_string())
83 });
84
85 let base_path = Arc::new(
88 base_path
89 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
90 .map(|b| base_path::normalize(&b))
91 .unwrap_or_default(),
92 );
93
94 let addr = format!("{host}:{port}");
95 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
96
97 let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
101
102 if is_local && dashboard_responding(&host, port) {
105 println!("\n lean-ctx dashboard already running → http://{host}:{port}{base_path}");
106 if let Some(req) = requested_token.as_deref() {
107 if load_saved_token().as_deref() != Some(req) {
108 eprintln!(
109 " \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
110 will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
111 );
112 }
113 }
114 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
115 if let Some(t) = load_saved_token() {
116 open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
117 } else {
118 open_browser(&format!("http://localhost:{port}{base_path}/"));
119 }
120 return;
121 }
122
123 let t = requested_token.unwrap_or_else(generate_token);
126 let token = Some(Arc::new(t));
127
128 let listener = match TcpListener::bind(&addr).await {
135 Ok(l) => l,
136 Err(e) => {
137 eprintln!("Failed to bind to {addr}: {e}");
138 std::process::exit(1);
139 }
140 };
141
142 if let Some(t) = token.as_ref() {
143 save_token(t);
144 let masked = if t.len() > 12 {
145 format!(
146 "{}…{}",
147 &t[..t.floor_char_boundary(8)],
148 &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
149 )
150 } else {
151 t.to_string()
152 };
153 let src = if token_src.is_empty() {
154 String::new()
155 } else {
156 format!(" (from {token_src})")
157 };
158 if is_local {
159 println!(" Auth: enabled (local){src}");
160 println!(" Browser URL: http://localhost:{port}{base_path}/?token={t}");
161 } else {
162 eprintln!(
163 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
164 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n \
165 Browser URL: http://<your-ip>:{port}{base_path}/?token={t}"
166 );
167 }
168 }
169
170 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
171 |_| "~/.lean-ctx/stats.json".to_string(),
172 |d| d.join("stats.json").display().to_string(),
173 );
174
175 if host == "0.0.0.0" {
176 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
177 println!(" Local access: http://localhost:{port}");
178 } else {
179 println!("\n lean-ctx dashboard → http://{host}:{port}");
180 }
181 println!(" Stats file: {stats_path}");
182 println!(" Press Ctrl+C to stop");
183 println!(
184 " \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
185 );
186
187 if is_local {
188 if let Some(t) = token.as_ref() {
189 open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
190 } else {
191 open_browser(&format!("http://localhost:{port}{base_path}/"));
192 }
193 }
194 if crate::shell::is_container() && is_local {
195 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
196 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
197 println!(" docker run ... -p {port}:{port} ...");
198 println!();
199 }
200
201 if crate::core::datadog_push::spawn_if_enabled() {
202 println!(
203 " Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
204 );
205 }
206
207 loop {
208 if let Ok((stream, _)) = listener.accept().await {
209 let token_ref = token.clone();
210 let base_ref = base_path.clone();
211 tokio::spawn(handle_request(stream, token_ref, base_ref));
212 }
213 }
214}
215
216const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
218const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
221
222fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
235 if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
236 return (Some(t.to_string()), "--auth-token");
237 }
238 if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
239 let trimmed = raw.trim();
240 if !trimmed.is_empty() {
241 return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
242 }
243 }
244 (None, "")
245}
246
247fn generate_token() -> String {
248 let mut bytes = [0u8; 32];
249 if getrandom::fill(&mut bytes).is_err() {
250 tracing::warn!("CSPRNG unavailable — falling back to time-based token");
251 let ts = std::time::SystemTime::now()
252 .duration_since(std::time::UNIX_EPOCH)
253 .unwrap_or_default()
254 .as_nanos();
255 for (i, b) in bytes.iter_mut().enumerate() {
256 *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
257 }
258 }
259 format!("lctx_{}", hex_lower(&bytes))
260}
261
262fn save_token(token: &str) {
263 if let Ok(dir) = crate::core::paths::state_dir() {
264 let _ = std::fs::create_dir_all(&dir);
265 let path = dir.join("dashboard.token");
266 #[cfg(unix)]
267 {
268 use std::io::Write;
269 use std::os::unix::fs::OpenOptionsExt;
270 let Ok(mut f) = std::fs::OpenOptions::new()
271 .write(true)
272 .create(true)
273 .truncate(true)
274 .mode(0o600)
275 .open(&path)
276 else {
277 return;
278 };
279 let _ = f.write_all(token.as_bytes());
280 }
281 #[cfg(not(unix))]
282 {
283 let _ = std::fs::write(&path, token);
284 }
285 }
286}
287
288fn load_saved_token() -> Option<String> {
289 let dir = crate::core::paths::state_dir().ok()?;
290 let path = dir.join("dashboard.token");
291 std::fs::read_to_string(path)
292 .ok()
293 .map(|s| s.trim().to_string())
294}
295
296pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
299 let mut result = String::with_capacity(html.len() + 128);
300 let mut remaining = html;
301 while let Some(pos) = remaining.find("<script") {
302 result.push_str(&remaining[..pos]);
303 let tag_start = &remaining[pos..];
304 let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
305 let tag = &tag_start[..=tag_end];
306 if tag.contains("src=") || tag.contains("nonce=") {
307 result.push_str(tag);
308 } else {
309 result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
310 }
311 remaining = &tag_start[tag_end + 1..];
312 }
313 result.push_str(remaining);
314 result
315}
316
317fn hex_lower(bytes: &[u8]) -> String {
318 const HEX: &[u8; 16] = b"0123456789abcdef";
319 let mut out = String::with_capacity(bytes.len() * 2);
320 for &b in bytes {
321 out.push(HEX[(b >> 4) as usize] as char);
322 out.push(HEX[(b & 0x0f) as usize] as char);
323 }
324 out
325}
326
327fn open_browser(url: &str) {
328 #[cfg(target_os = "macos")]
329 {
330 let _ = std::process::Command::new("open").arg(url).spawn();
331 }
332
333 #[cfg(target_os = "linux")]
334 {
335 let _ = std::process::Command::new("xdg-open")
336 .arg(url)
337 .stderr(std::process::Stdio::null())
338 .spawn();
339 }
340
341 #[cfg(target_os = "windows")]
342 {
343 let _ = std::process::Command::new("cmd")
344 .args(["/C", "start", url])
345 .spawn();
346 }
347}
348
349fn dashboard_responding(host: &str, port: u16) -> bool {
350 use std::io::{Read, Write};
351 use std::net::TcpStream;
352 use std::time::Duration;
353
354 let addr = format!("{host}:{port}");
355 let Ok(mut s) = TcpStream::connect_timeout(
356 &addr
357 .parse()
358 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
359 Duration::from_millis(150),
360 ) else {
361 return false;
362 };
363 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
364 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
365
366 let auth_header = load_saved_token()
367 .map(|t| format!("Authorization: Bearer {t}\r\n"))
368 .unwrap_or_default();
369
370 let req = format!(
371 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
372 );
373 if s.write_all(req.as_bytes()).is_err() {
374 return false;
375 }
376 let mut buf = [0u8; 256];
377 let Ok(n) = s.read(&mut buf) else {
378 return false;
379 };
380 let head = String::from_utf8_lossy(&buf[..n]);
381 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
382}
383
384const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
385
386fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
387 for line in header_section.lines() {
388 let Some((k, v)) = line.split_once(':') else {
389 continue;
390 };
391 if k.trim().eq_ignore_ascii_case(name) {
392 return Some(v.trim());
393 }
394 }
395 None
396}
397
398fn host_loopback_aliases(host: &str) -> Vec<String> {
400 let mut v = vec![host.to_string()];
401 if let Some(port) = host.strip_prefix("127.0.0.1:") {
402 v.push(format!("localhost:{port}"));
403 }
404 if let Some(port) = host.strip_prefix("localhost:") {
405 v.push(format!("127.0.0.1:{port}"));
406 }
407 if let Some(port) = host.strip_prefix("[::1]:") {
408 v.push(format!("127.0.0.1:{port}"));
409 v.push(format!("localhost:{port}"));
410 }
411 v
412}
413
414fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
415 let origin = origin.trim_end_matches('/');
416 for h in host_loopback_aliases(host) {
417 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
418 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
419 {
420 return true;
421 }
422 }
423 false
424}
425
426fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
429 let uc = method.to_ascii_uppercase();
430 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
431 return true;
432 }
433 if !path.starts_with("/api/") {
434 return true;
435 }
436 let Some(origin) = header_line_value(header_section, "Origin") else {
437 return true;
438 };
439 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
440 return true;
441 }
442 let Some(host) = header_line_value(header_section, "Host") else {
443 return false;
444 };
445 origin_matches_dashboard_host(origin, host)
446}
447
448fn find_headers_end(buf: &[u8]) -> Option<usize> {
449 buf.windows(4).position(|w| w == b"\r\n\r\n")
450}
451
452fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
453 let text = String::from_utf8_lossy(header_section);
454 for line in text.lines() {
455 let Some((k, v)) = line.split_once(':') else {
456 continue;
457 };
458 if k.trim().eq_ignore_ascii_case("content-length") {
459 return v.trim().parse::<usize>().ok();
460 }
461 }
462 Some(0)
463}
464
465async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
466 let mut buf = Vec::new();
467 let mut tmp = [0u8; 8192];
468 loop {
469 if let Some(end) = find_headers_end(&buf) {
470 let cl = parse_content_length_header(&buf[..end])?;
471 let total = end + 4 + cl;
472 if total > MAX_HTTP_MESSAGE {
473 return None;
474 }
475 if buf.len() >= total {
476 buf.truncate(total);
477 return Some(buf);
478 }
479 } else if buf.len() > 65_536 {
480 return None;
481 }
482
483 let n = stream.read(&mut tmp).await.ok()?;
484 if n == 0 {
485 return None;
486 }
487 buf.extend_from_slice(&tmp[..n]);
488 if buf.len() > MAX_HTTP_MESSAGE {
489 return None;
490 }
491 }
492}
493
494async fn handle_request(
495 mut stream: tokio::net::TcpStream,
496 token: Option<Arc<String>>,
497 base_path: Arc<String>,
498) {
499 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
500
501 let Some(buf) = read_http_message(&mut stream).await else {
502 return;
503 };
504 let Some(header_end) = find_headers_end(&buf) else {
505 return;
506 };
507 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
508 let body_start = header_end + 4;
509 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
510 return;
511 };
512 if buf.len() < body_start + content_len {
513 return;
514 }
515 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
516 .unwrap_or("")
517 .to_string();
518
519 let first = header_text.lines().next().unwrap_or("");
520 let mut parts = first.split_whitespace();
521 let method = parts.next().unwrap_or("GET").to_string();
522 let raw_path = parts.next().unwrap_or("/").to_string();
523
524 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
525 let p = &raw_path[..idx];
526 let qs = &raw_path[idx + 1..];
527 let tok = qs
528 .split('&')
529 .find_map(|pair| pair.strip_prefix("token="))
530 .map(std::string::ToString::to_string);
531 (p.to_string(), tok)
532 } else {
533 (raw_path.clone(), None)
534 };
535
536 let query_str = raw_path
537 .find('?')
538 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
539
540 let path = base_path::strip(&path, base_path.as_str()).to_string();
544
545 if let Some(bytes) = match_font_asset(&path) {
548 let header = format!(
549 "HTTP/1.1 200 OK\r\n\
550 Content-Type: font/woff2\r\n\
551 Content-Length: {}\r\n\
552 Cache-Control: public, max-age=31536000, immutable\r\n\
553 X-Content-Type-Options: nosniff\r\n\
554 Connection: close\r\n\
555 \r\n",
556 bytes.len()
557 );
558 let _ = stream.write_all(header.as_bytes()).await;
559 let _ = stream.write_all(bytes).await;
560 return;
561 }
562
563 let is_api = path.starts_with("/api/");
564 let requires_auth = is_api || path == "/metrics";
565
566 if let Some(ref expected) = token {
567 let mut has_header_auth = check_auth(&header_text, expected);
568
569 if !has_header_auth && path == "/metrics" {
574 if let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV) {
575 let scrape = scrape.trim();
576 if !scrape.is_empty() && check_auth(&header_text, scrape) {
577 has_header_auth = true;
578 }
579 }
580 }
581
582 if requires_auth && !has_header_auth {
583 let body = r#"{"error":"unauthorized"}"#;
584 let response = format!(
585 "HTTP/1.1 401 Unauthorized\r\n\
586 Content-Type: application/json\r\n\
587 Content-Length: {}\r\n\
588 WWW-Authenticate: Bearer\r\n\
589 Connection: close\r\n\
590 \r\n\
591 {body}",
592 body.len()
593 );
594 let _ = stream.write_all(response.as_bytes()).await;
595 return;
596 }
597
598 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
599 let body = r#"{"error":"forbidden"}"#;
600 let response = format!(
601 "HTTP/1.1 403 Forbidden\r\n\
602 Content-Type: application/json\r\n\
603 Content-Length: {}\r\n\
604 Connection: close\r\n\
605 \r\n\
606 {body}",
607 body.len()
608 );
609 let _ = stream.write_all(response.as_bytes()).await;
610 return;
611 }
612 }
613
614 let path = path.as_str();
615 let query_str = query_str.as_str();
616 let method = method.as_str();
617
618 let compute = std::panic::catch_unwind(|| {
619 routes::route_response(
620 path,
621 query_str,
622 query_token.as_ref(),
623 token.as_ref(),
624 is_loopback,
625 method,
626 &body_str,
627 )
628 });
629 let (status, content_type, mut body) = match compute {
630 Ok(v) => v,
631 Err(_) => (
632 "500 Internal Server Error",
633 "application/json",
634 r#"{"error":"dashboard route panicked"}"#.to_string(),
635 ),
636 };
637
638 if !base_path.is_empty()
641 && (content_type.contains("text/html")
642 || content_type.contains("text/css")
643 || content_type.contains("javascript"))
644 {
645 body = base_path::rewrite_asset_urls(&body, base_path.as_str());
646 }
647
648 let cache_header = if content_type.starts_with("application/json") {
649 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
650 } else if content_type.starts_with("application/javascript")
651 || content_type.starts_with("text/css")
652 {
653 "Cache-Control: no-cache, must-revalidate\r\n"
654 } else {
655 ""
656 };
657
658 let nonce = {
659 let mut nb = [0u8; 16];
660 if getrandom::fill(&mut nb).is_err() {
661 nb.iter_mut().enumerate().for_each(|(i, b)| {
662 *b = (std::time::SystemTime::now()
663 .duration_since(std::time::UNIX_EPOCH)
664 .unwrap_or_default()
665 .subsec_nanos()
666 .wrapping_add(i as u32)) as u8;
667 });
668 }
669 hex_lower(&nb)
670 };
671 if content_type.contains("text/html") {
672 body = add_nonce_to_inline_scripts(&body, &nonce);
673 }
674 let security_headers = format!(
675 "X-Content-Type-Options: nosniff\r\n\
676 X-Frame-Options: DENY\r\n\
677 Referrer-Policy: no-referrer\r\n\
678 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'\r\n"
679 );
680
681 let response = format!(
682 "HTTP/1.1 {status}\r\n\
683 Content-Type: {content_type}\r\n\
684 Content-Length: {}\r\n\
685 {cache_header}\
686 {security_headers}\
687 Connection: close\r\n\
688 \r\n\
689 {body}",
690 body.len()
691 );
692
693 let _ = stream.write_all(response.as_bytes()).await;
694}
695
696fn check_auth(request: &str, expected_token: &str) -> bool {
697 for line in request.lines() {
698 let lower = line.to_lowercase();
699 if lower.starts_with("authorization:") {
700 let value = line["authorization:".len()..].trim();
701 if let Some(token) = value
702 .strip_prefix("Bearer ")
703 .or_else(|| value.strip_prefix("bearer "))
704 {
705 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
706 }
707 }
708 }
709 false
710}
711
712fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
713 if a.len() != b.len() {
714 return false;
715 }
716 bool::from(a.ct_eq(b))
717}
718
719#[cfg(test)]
720mod tests {
721 use super::routes::helpers::normalize_dashboard_demo_path;
722 use super::*;
723 use tempfile::tempdir;
724
725 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
726
727 #[test]
728 fn check_auth_with_valid_bearer() {
729 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
730 assert!(check_auth(req, "lctx_abc123"));
731 }
732
733 #[test]
734 fn check_auth_with_invalid_bearer() {
735 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
736 assert!(!check_auth(req, "lctx_abc123"));
737 }
738
739 #[test]
740 fn check_auth_missing_header() {
741 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
742 assert!(!check_auth(req, "lctx_abc123"));
743 }
744
745 #[test]
746 fn check_auth_lowercase_bearer() {
747 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
748 assert!(check_auth(req, "lctx_abc123"));
749 }
750
751 #[test]
752 fn query_token_parsing() {
753 let raw_path = "/index.html?token=lctx_abc123&other=val";
754 let idx = raw_path.find('?').unwrap();
755 let qs = &raw_path[idx + 1..];
756 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
757 assert_eq!(tok, Some("lctx_abc123"));
758 }
759
760 #[test]
761 fn api_path_detection() {
762 assert!("/api/stats".starts_with("/api/"));
763 assert!("/api/version".starts_with("/api/"));
764 assert!(!"/".starts_with("/api/"));
765 assert!(!"/index.html".starts_with("/api/"));
766 assert!(!"/favicon.ico".starts_with("/api/"));
767 }
768
769 #[test]
770 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
771 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
772 assert_eq!(
773 normalized,
774 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
775 );
776 }
777
778 #[test]
779 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
780 let input = r"C:\repo\backend\list_tables.js";
781 assert_eq!(normalize_dashboard_demo_path(input), input);
782 }
783
784 #[test]
785 fn normalize_dashboard_demo_path_preserves_unc_path() {
786 let input = r"\\server\share\backend\list_tables.js";
787 assert_eq!(normalize_dashboard_demo_path(input), input);
788 }
789
790 #[test]
791 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
792 assert_eq!(
793 normalize_dashboard_demo_path("./src/main.rs"),
794 "src/main.rs"
795 );
796 assert_eq!(
797 normalize_dashboard_demo_path(r".\src\main.rs"),
798 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
799 );
800 }
801
802 #[test]
803 fn api_profile_returns_json() {
804 let (_status, _ct, body) =
805 routes::route_response("/api/profile", "", None, None, false, "GET", "");
806 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
807 assert!(v.get("active_name").is_some(), "missing active_name");
808 assert!(
809 v.pointer("/profile/profile/name")
810 .and_then(|n| n.as_str())
811 .is_some(),
812 "missing profile.profile.name"
813 );
814 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
815 }
816
817 #[test]
818 fn api_billing_badge_returns_cosmetic_shape() {
819 let (status, ct, body) =
820 routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
821 assert_eq!(status, "200 OK");
822 assert_eq!(ct, "application/json");
823 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
824 assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
825 assert!(v
826 .get("supporter")
827 .and_then(serde_json::Value::as_bool)
828 .is_some());
829 assert!(
830 matches!(
831 v.get("source").and_then(|s| s.as_str()),
832 Some("live" | "cached" | "expired" | "none")
833 ),
834 "unexpected source: {body}"
835 );
836 }
837
838 #[test]
839 fn api_episodes_returns_json() {
840 let (_status, _ct, body) =
841 routes::route_response("/api/episodes", "", None, None, false, "GET", "");
842 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
843 assert!(v.get("project_hash").is_some());
844 assert!(v.get("stats").is_some());
845 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
846 }
847
848 #[test]
849 fn api_procedures_returns_json() {
850 let (_status, _ct, body) =
851 routes::route_response("/api/procedures", "", None, None, false, "GET", "");
852 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
853 assert!(v.get("project_hash").is_some());
854 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
855 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
856 }
857
858 #[test]
859 fn api_compression_demo_heals_moved_file_paths() {
860 let _g = ENV_LOCK.lock().expect("env lock");
861 let td = tempdir().expect("tempdir");
862 let root = td.path();
863 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
864 std::fs::write(
865 root.join("src").join("moved").join("foo.rs"),
866 "pub fn foo() { println!(\"hi\"); }\n",
867 )
868 .expect("write foo.rs");
869
870 let root_s = root.to_string_lossy().to_string();
871 std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
872
873 let (_status, _ct, body) = routes::route_response(
874 "/api/compression-demo",
875 "path=src/foo.rs",
876 None,
877 None,
878 false,
879 "GET",
880 "",
881 );
882 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
883 assert!(v.get("error").is_none(), "unexpected error: {body}");
884 assert_eq!(
885 v.get("resolved_from").and_then(|x| x.as_str()),
886 Some("src/moved/foo.rs")
887 );
888
889 std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
890 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
891 let _ = std::fs::remove_dir_all(dir);
892 }
893 }
894
895 #[test]
896 fn resolve_token_uses_env_var_verbatim() {
897 let _g = ENV_LOCK.lock().expect("env lock");
898 std::env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
899 let (token, src) = resolve_requested_token(None);
900 std::env::remove_var(HTTP_TOKEN_ENV);
901 assert_eq!(
902 src, HTTP_TOKEN_ENV,
903 "token should be reported as env-sourced"
904 );
905 assert_eq!(token.as_deref(), Some("lctx_mystatic"));
906 }
907
908 #[test]
909 fn resolve_token_trims_env_var() {
910 let _g = ENV_LOCK.lock().expect("env lock");
911 std::env::set_var(HTTP_TOKEN_ENV, " lctx_padded ");
912 let (token, src) = resolve_requested_token(None);
913 std::env::remove_var(HTTP_TOKEN_ENV);
914 assert_eq!(src, HTTP_TOKEN_ENV);
915 assert_eq!(token.as_deref(), Some("lctx_padded"));
916 }
917
918 #[test]
919 fn resolve_token_falls_back_to_random_when_unset() {
920 let _g = ENV_LOCK.lock().expect("env lock");
921 std::env::remove_var(HTTP_TOKEN_ENV);
922 let (token, src) = resolve_requested_token(None);
923 assert!(token.is_none(), "unset env requests no fixed token");
924 assert!(src.is_empty());
925 let generated = token.unwrap_or_else(generate_token);
927 assert!(
928 generated.starts_with("lctx_"),
929 "generated token prefix, got {generated}"
930 );
931 assert!(
932 generated.len() > 12,
933 "generated token should be 32-byte hex"
934 );
935 }
936
937 #[test]
938 fn resolve_token_ignores_empty_env() {
939 let _g = ENV_LOCK.lock().expect("env lock");
940 std::env::set_var(HTTP_TOKEN_ENV, " ");
941 let (token, src) = resolve_requested_token(None);
942 std::env::remove_var(HTTP_TOKEN_ENV);
943 assert!(
944 token.is_none(),
945 "whitespace-only env requests no fixed token"
946 );
947 assert!(src.is_empty());
948 }
949
950 #[test]
951 fn resolve_token_flag_overrides_env() {
952 let _g = ENV_LOCK.lock().expect("env lock");
955 std::env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
956 let (token, src) = resolve_requested_token(Some("lctx_fromflag"));
957 std::env::remove_var(HTTP_TOKEN_ENV);
958 assert_eq!(src, "--auth-token");
959 assert_eq!(token.as_deref(), Some("lctx_fromflag"));
960 }
961
962 #[test]
963 fn resolve_token_uses_flag_when_env_unset() {
964 let _g = ENV_LOCK.lock().expect("env lock");
965 std::env::remove_var(HTTP_TOKEN_ENV);
966 let (token, src) = resolve_requested_token(Some(" lctx_flag_padded "));
967 assert_eq!(src, "--auth-token");
968 assert_eq!(token.as_deref(), Some("lctx_flag_padded"));
969 }
970
971 #[test]
972 fn resolve_token_empty_flag_falls_back_to_env() {
973 let _g = ENV_LOCK.lock().expect("env lock");
974 std::env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
975 let (token, src) = resolve_requested_token(Some(" "));
976 std::env::remove_var(HTTP_TOKEN_ENV);
977 assert_eq!(src, HTTP_TOKEN_ENV);
978 assert_eq!(token.as_deref(), Some("lctx_fromenv"));
979 }
980}