1use crate::json::{
14 edge_history_result_json, node_edges_json, node_history_json, node_info_json, params_from_json,
15 parse_ingest_edges, result_set_json, rule_def_from_json,
16};
17use crate::{AppState, AuthIdentity};
18use arrow_bridge::to_ipc_bytes;
19use axum::extract::{Extension, Path, Query, Request, State};
20use axum::http::{header, HeaderValue, Method, StatusCode};
21use axum::middleware::{self, Next};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::{Json, Router};
25use core_api::{
26 is_write_query, json_to_rows, json_to_value, AutoFk, BackupReport, BatchOp, DegreeConfig, Dir,
27 GraphError, IngestOptions, MaskMode, NodeMask, PageRankConfig, ResultSet, SharedDb,
28 SuggestConfig, Value, WccConfig, SUGGEST_DEFAULT_SEED,
29};
30use serde_json::{json, Value as Js};
31use std::collections::{BTreeMap, HashMap};
32use std::net::SocketAddr;
33use std::path::PathBuf;
34use tower_http::services::ServeDir;
35
36pub fn router(db: SharedDb) -> Router {
56 router_with_auth(db, None)
57}
58
59pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
65 build_app(
66 db,
67 token,
68 HashMap::new(),
69 UiFallback::None,
70 default_advertise_addr(),
71 false,
72 )
73}
74
75pub fn router_with_role_tokens(
81 db: SharedDb,
82 token: Option<String>,
83 role_tokens: HashMap<String, String>,
84) -> Router {
85 build_app(
86 db,
87 token,
88 role_tokens,
89 UiFallback::None,
90 default_advertise_addr(),
91 false,
92 )
93}
94
95pub fn router_with_ui(
100 db: SharedDb,
101 ui_dir: impl AsRef<std::path::Path>,
102 token: Option<String>,
103) -> Router {
104 build_app(
105 db,
106 token,
107 HashMap::new(),
108 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
109 default_advertise_addr(),
110 false,
111 )
112}
113
114pub fn router_with_ui_tls(
118 db: SharedDb,
119 ui_dir: impl AsRef<std::path::Path>,
120 token: Option<String>,
121) -> Router {
122 build_app(
123 db,
124 token,
125 HashMap::new(),
126 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
127 default_advertise_addr(),
128 true,
129 )
130}
131
132#[cfg(feature = "embed-ui")]
133static EMBEDDED_UI: include_dir::Dir<'_> =
134 include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
135
136#[cfg(feature = "embed-ui")]
138pub fn router_with_embedded_ui(db: SharedDb) -> Router {
139 build_app(
140 db,
141 None,
142 HashMap::new(),
143 UiFallback::Embedded,
144 default_advertise_addr(),
145 false,
146 )
147}
148
149#[cfg(feature = "embed-ui")]
150async fn embedded_fallback(uri: axum::http::Uri) -> Response {
151 let rel = if uri.path() == "/" || uri.path().is_empty() {
152 "index.html"
153 } else {
154 uri.path().trim_start_matches('/')
155 };
156 if rel.split('/').any(|seg| seg == "..") {
157 return StatusCode::NOT_FOUND.into_response();
158 }
159 match EMBEDDED_UI.get_file(rel) {
160 Some(file) => (
161 StatusCode::OK,
162 [(header::CONTENT_TYPE, embedded_ctype(rel))],
163 file.contents(),
164 )
165 .into_response(),
166 None => StatusCode::NOT_FOUND.into_response(),
167 }
168}
169
170#[cfg(feature = "embed-ui")]
171fn embedded_ctype(path: &str) -> &'static str {
172 if path.ends_with(".html") {
173 "text/html; charset=utf-8"
174 } else if path.ends_with(".js") {
175 "application/javascript; charset=utf-8"
176 } else if path.ends_with(".css") {
177 "text/css; charset=utf-8"
178 } else if path.ends_with(".woff2") {
179 "font/woff2"
180 } else if path.ends_with(".svg") {
181 "image/svg+xml"
182 } else if path.ends_with(".ico") {
183 "image/x-icon"
184 } else if path.ends_with(".txt") {
185 "text/plain; charset=utf-8"
186 } else {
187 "application/octet-stream"
188 }
189}
190
191#[deprecated(
199 since = "0.2.0",
200 note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
201)]
202#[doc(hidden)]
203pub async fn serve(
204 db: SharedDb,
205 addr: SocketAddr,
206 ready: tokio::sync::oneshot::Sender<SocketAddr>,
207 token: Option<String>,
208) -> std::io::Result<()> {
209 serve_inner(db, addr, ready, UiFallback::None, token, HashMap::new()).await
210}
211
212pub async fn serve_with_role_tokens(
217 db: SharedDb,
218 addr: SocketAddr,
219 ready: tokio::sync::oneshot::Sender<SocketAddr>,
220 token: Option<String>,
221 role_tokens: HashMap<String, String>,
222) -> std::io::Result<()> {
223 serve_inner(db, addr, ready, UiFallback::None, token, role_tokens).await
224}
225
226#[deprecated(
231 since = "0.2.0",
232 note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
233)]
234#[doc(hidden)]
235pub async fn serve_with_ui(
236 db: SharedDb,
237 addr: SocketAddr,
238 ready: tokio::sync::oneshot::Sender<SocketAddr>,
239 ui_dir: PathBuf,
240 token: Option<String>,
241) -> std::io::Result<()> {
242 serve_inner(
243 db,
244 addr,
245 ready,
246 UiFallback::Dir(ui_dir),
247 token,
248 HashMap::new(),
249 )
250 .await
251}
252
253pub async fn serve_with_ui_and_role_tokens(
255 db: SharedDb,
256 addr: SocketAddr,
257 ready: tokio::sync::oneshot::Sender<SocketAddr>,
258 ui_dir: PathBuf,
259 token: Option<String>,
260 role_tokens: HashMap<String, String>,
261) -> std::io::Result<()> {
262 serve_inner(db, addr, ready, UiFallback::Dir(ui_dir), token, role_tokens).await
263}
264
265#[cfg(feature = "embed-ui")]
270pub async fn serve_with_embedded_ui(
271 db: SharedDb,
272 addr: SocketAddr,
273 ready: tokio::sync::oneshot::Sender<SocketAddr>,
274 token: Option<String>,
275 role_tokens: HashMap<String, String>,
276) -> std::io::Result<()> {
277 serve_inner(db, addr, ready, UiFallback::Embedded, token, role_tokens).await
278}
279
280enum UiFallback {
281 None,
282 Dir(PathBuf),
283 #[cfg(feature = "embed-ui")]
284 Embedded,
285}
286
287async fn serve_inner(
288 db: SharedDb,
289 addr: SocketAddr,
290 ready: tokio::sync::oneshot::Sender<SocketAddr>,
291 ui: UiFallback,
292 token: Option<String>,
293 role_tokens: HashMap<String, String>,
294) -> std::io::Result<()> {
295 let listener = tokio::net::TcpListener::bind(addr).await?;
296 let local = listener.local_addr()?;
297 if ready.send(local).is_err() {
298 eprintln!("serve: readiness receiver dropped before bind notify");
300 }
301 let app = build_app(db, token, role_tokens, ui, local, false);
302 axum::serve(listener, app).await
303}
304
305#[cfg(feature = "tls")]
313pub async fn serve_tls(
314 db: SharedDb,
315 addr: SocketAddr,
316 ready: tokio::sync::oneshot::Sender<SocketAddr>,
317 cert_path: std::path::PathBuf,
318 key_path: std::path::PathBuf,
319 token: Option<String>,
320 role_tokens: HashMap<String, String>,
321) -> std::io::Result<()> {
322 let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
323 .await
324 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
325 let listener = std::net::TcpListener::bind(addr)?;
326 listener.set_nonblocking(true)?;
327 let local = listener.local_addr()?;
328 if ready.send(local).is_err() {
329 eprintln!("serve_tls: readiness receiver dropped before bind notify");
330 }
331 let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
332 axum_server::from_tcp_rustls(listener, config)?
333 .serve(app.into_make_service())
334 .await
335}
336
337fn default_advertise_addr() -> SocketAddr {
338 SocketAddr::from(([127, 0, 0, 1], 8080))
339}
340
341fn build_app(
342 db: SharedDb,
343 token: Option<String>,
344 role_tokens: HashMap<String, String>,
345 ui: UiFallback,
346 addr: SocketAddr,
347 tls_active: bool,
348) -> Router {
349 debug_assert!(
350 !db.read().has_event_sink(),
351 "router() must be called at most once per SharedDb; a second call \
352 replaces the sink and terminates all existing /watch subscribers \
353 with RecvError::Closed"
354 );
355 let (tx, _) = tokio::sync::broadcast::channel(1024);
356 {
357 let tx = tx.clone();
358 db.write().set_event_sink(Box::new(move |ev| {
359 let _ = tx.send(ev);
360 }));
361 }
362 let state = AppState {
363 db,
364 watch: tx,
365 token,
366 role_tokens,
367 addr,
368 tls_active,
369 started_at: std::time::Instant::now(),
370 };
371 let app = Router::new()
372 .route("/health", get(health))
373 .route("/query", post(query))
374 .route("/stats", get(stats))
375 .route("/metrics", get(metrics))
376 .route("/ingest", post(ingest))
377 .route("/rules", post(create_rule))
378 .route("/suggest", get(suggest))
379 .route("/explain", get(explain))
380 .route("/node/{key}", get(node_info))
381 .route("/node/{key}", axum::routing::delete(delete_node))
382 .route("/node/{key}/edges", get(node_edges))
383 .route("/node/{key}/neighborhood", get(neighborhood))
384 .route("/node/{key}/history", get(node_history_handler))
385 .route("/history/edge", get(edge_history_handler))
386 .route("/history/was_linked", get(was_linked_handler))
387 .route(
388 "/node/{key}/prop/{field}",
389 axum::routing::put(set_node_prop),
390 )
391 .route(
392 "/node/{key}/prop/{field}",
393 axum::routing::delete(remove_node_prop),
394 )
395 .route("/nodes", post(create_node))
399 .route("/nodes/{key}/rename", post(rename_node))
400 .route("/edges", post(create_edge))
401 .route("/edges/upsert", post(upsert_edge))
402 .route(
403 "/edges/{etype}/{src}/{dst}",
404 axum::routing::delete(delete_edge),
405 )
406 .route("/algo/pagerank", post(algo_pagerank))
407 .route("/algo/wcc", post(algo_wcc))
408 .route("/algo/degree", post(algo_degree))
409 .route("/backup", post(backup))
410 .route("/watch", get(crate::ws::watch))
411 .route("/subscribe", get(crate::subscribe::subscribe))
412 .with_state(state.clone());
413 let app = match ui {
414 UiFallback::None => app,
415 UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
416 #[cfg(feature = "embed-ui")]
417 UiFallback::Embedded => app.fallback(embedded_fallback),
418 };
419 app.layer(middleware::from_fn_with_state(state, auth_middleware))
420 .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
424}
425
426const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
430
431async fn health(State(state): State<AppState>) -> Response {
432 let (nodes, edges) = {
433 let g = state.db.read();
434 let s = g.stats();
435 (s.nodes_live, s.edges)
436 };
437 json_ok(json!({
438 "ok": true,
439 "nodes": nodes,
440 "edges": edges,
441 "addr": state.addr.to_string(),
442 }))
443}
444
445async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
448where
449 T: Send + 'static,
450 F: FnOnce() -> core_api::Result<T> + Send + 'static,
451{
452 match tokio::task::spawn_blocking(f).await {
453 Ok(Ok(v)) => Ok(v),
454 Ok(Err(e)) => Err(graph_err(e)),
455 Err(_) => Err(err_response("write task panicked")),
456 }
457}
458
459const TOKEN_COOKIE: &str = "mushroomdb_token";
460
461async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
462 if state.token.is_none() && state.role_tokens.is_empty() {
464 req.extensions_mut().insert(AuthIdentity::Full);
465 return next.run(req).await;
466 }
467
468 if req.method() == Method::GET && req.uri().path() == "/health" {
470 req.extensions_mut().insert(AuthIdentity::Full);
471 return next.run(req).await;
472 }
473
474 let presented = request_token(&req);
475
476 if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
478 if presented
479 .as_deref()
480 .is_some_and(|p| constant_time_eq(p.as_bytes(), full_tok.as_bytes()))
481 {
482 let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
483 req.extensions_mut().insert(AuthIdentity::Full);
484 let mut res = next.run(req).await;
485 if set_cookie && is_html_response(&res) {
486 attach_token_cookie(&mut res, full_tok, state.tls_active);
487 }
488 return res;
489 }
490 }
491
492 if let Some(tok) = presented.as_deref() {
494 if let Some(role_name) = state.role_tokens.get(tok) {
495 let path = req.uri().path();
501 if path == "/subscribe" || path == "/watch" {
502 return forbidden("role-bound token: this endpoint is not permitted");
503 }
504 req.extensions_mut()
505 .insert(AuthIdentity::Role(role_name.clone()));
506 return next.run(req).await;
507 }
508 }
509
510 unauthorized()
512}
513
514fn request_token(req: &Request) -> Option<String> {
515 presented_bearer_or_query(req).or_else(|| presented_cookie(req))
516}
517
518fn presented_bearer_or_query(req: &Request) -> Option<String> {
519 if let Some(header) = req
520 .headers()
521 .get(header::AUTHORIZATION)
522 .and_then(|v| v.to_str().ok())
523 {
524 if let Some(value) = bearer_token(header) {
525 return Some(value.to_string());
526 }
527 }
528 query_param(req.uri().query().unwrap_or(""), "token")
529}
530
531fn presented_cookie(req: &Request) -> Option<String> {
532 let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
533 cookie_named(header, TOKEN_COOKIE).map(str::to_string)
534}
535
536fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
537 for part in header.split(';') {
538 let part = part.trim();
539 let Some((k, v)) = part.split_once('=') else {
540 continue;
541 };
542 if k.trim() == name {
543 return Some(v.trim());
544 }
545 }
546 None
547}
548
549fn is_html_response(res: &Response) -> bool {
550 res.headers()
551 .get(header::CONTENT_TYPE)
552 .and_then(|v| v.to_str().ok())
553 .is_some_and(|ct| {
554 ct.split(';')
555 .next()
556 .unwrap_or("")
557 .trim()
558 .eq_ignore_ascii_case("text/html")
559 })
560}
561
562fn attach_token_cookie(res: &mut Response, token: &str, secure: bool) {
563 let secure_attr = if secure { "; Secure" } else { "" };
564 let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly{secure_attr}");
565 if let Ok(hv) = HeaderValue::from_str(&value) {
566 res.headers_mut().insert(header::SET_COOKIE, hv);
567 }
568}
569
570fn bearer_token(header: &str) -> Option<&str> {
571 let (scheme, value) = header.split_once(' ')?;
572 if scheme.eq_ignore_ascii_case("Bearer") {
573 Some(value.trim())
574 } else {
575 None
576 }
577}
578
579fn query_param(query: &str, key: &str) -> Option<String> {
580 for pair in query.split('&') {
581 if pair.is_empty() {
582 continue;
583 }
584 match pair.split_once('=') {
585 Some((k, v)) if k == key => return percent_decode_plus(v),
586 None if pair == key => return Some(String::new()),
587 _ => {}
588 }
589 }
590 None
591}
592
593fn percent_decode_plus(s: &str) -> Option<String> {
595 let bytes = s.as_bytes();
596 let mut out = Vec::with_capacity(bytes.len());
597 let mut i = 0;
598 while i < bytes.len() {
599 match bytes[i] {
600 b'+' => {
601 out.push(b' ');
602 i += 1;
603 }
604 b'%' => {
605 if i + 2 >= bytes.len() {
606 return None;
607 }
608 let hi = from_hex(bytes[i + 1])?;
609 let lo = from_hex(bytes[i + 2])?;
610 out.push((hi << 4) | lo);
611 i += 3;
612 }
613 c => {
614 out.push(c);
615 i += 1;
616 }
617 }
618 }
619 String::from_utf8(out).ok()
620}
621
622fn from_hex(b: u8) -> Option<u8> {
623 match b {
624 b'0'..=b'9' => Some(b - b'0'),
625 b'a'..=b'f' => Some(b - b'a' + 10),
626 b'A'..=b'F' => Some(b - b'A' + 10),
627 _ => None,
628 }
629}
630
631fn unauthorized() -> Response {
632 (
633 StatusCode::UNAUTHORIZED,
634 Json(json!({"error": "unauthorized"})),
635 )
636 .into_response()
637}
638
639fn forbidden(detail: &str) -> Response {
641 (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
642}
643
644fn role_mask_err(e: GraphError) -> Response {
652 match e {
653 GraphError::Corrupt { detail } => (
654 StatusCode::INTERNAL_SERVER_ERROR,
655 Json(json!({"error": format!("roles misconfigured: {detail}")})),
656 )
657 .into_response(),
658 GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
659 other => graph_err(other),
660 }
661}
662
663fn err_response(detail: impl Into<String>) -> Response {
664 (
665 StatusCode::BAD_REQUEST,
666 Json(json!({"error": detail.into()})),
667 )
668 .into_response()
669}
670
671fn graph_err(e: GraphError) -> Response {
672 match e {
673 GraphError::RoleWriteDenied { reason } => forbidden(&reason),
676 GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
677 err_response(detail)
678 }
679 other => err_response(other.to_string()),
680 }
681}
682
683fn key_not_found(key: String) -> Response {
684 (
685 StatusCode::NOT_FOUND,
686 Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
687 )
688 .into_response()
689}
690
691fn conflict_response(key: String) -> Response {
692 (
693 StatusCode::CONFLICT,
694 Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
695 )
696 .into_response()
697}
698
699fn json_ok(value: Js) -> Response {
700 (StatusCode::OK, Json(value)).into_response()
701}
702
703fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
704 let Some(v) = v else {
705 return Ok(IngestOptions::default());
706 };
707 if v.is_null() {
708 return Ok(IngestOptions::default());
709 }
710 let obj = v
711 .as_object()
712 .ok_or_else(|| "options must be an object".to_string())?;
713 let mut opts = IngestOptions::default();
714 if let Some(kf) = obj.get("key_field") {
715 opts.key_field = kf
716 .as_str()
717 .ok_or_else(|| "options.key_field must be a string".to_string())?
718 .to_string();
719 }
720 if let Some(fk) = obj.get("auto_fk") {
721 if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
722 opts.auto_fk = AutoFk::Off;
723 } else if let Some(m) = fk.as_object() {
724 let suf = m
725 .get("suffix")
726 .and_then(Js::as_str)
727 .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
728 opts.auto_fk = AutoFk::Auto {
729 suffix: suf.to_string(),
730 };
731 } else {
732 return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
733 }
734 }
735 Ok(opts)
736}
737
738fn format_query_result(rs: ResultSet, format: &str) -> Response {
740 match format {
741 "" => match to_ipc_bytes(&rs) {
742 Ok(bytes) => (
743 StatusCode::OK,
744 [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
745 bytes,
746 )
747 .into_response(),
748 Err(e) => err_response(e),
749 },
750 "json" => json_ok(result_set_json(&rs)),
751 other => err_response(format!("unknown format: {other}")),
752 }
753}
754
755async fn query(
756 State(state): State<AppState>,
757 Extension(identity): Extension<AuthIdentity>,
758 Query(qs): Query<BTreeMap<String, String>>,
759 Json(body): Json<Js>,
760) -> Response {
761 let cypher = match body.get("cypher").and_then(Js::as_str) {
762 Some(s) => s.to_string(),
763 None => return err_response("missing cypher"),
764 };
765 let params = match params_from_json(body.get("params")) {
766 Ok(p) => p,
767 Err(e) => return err_response(e),
768 };
769 let format = qs.get("format").map(String::as_str).unwrap_or("");
770
771 let as_of = match body.get("as_of") {
774 None | Some(Js::Null) => None,
775 Some(v) => match v.as_u64() {
776 Some(n) => Some(n),
777 None => return err_response("as_of must be a non-negative integer commit index"),
778 },
779 };
780
781 let mask_keys: Option<Vec<String>> = match body.get("mask") {
783 None | Some(Js::Null) => None,
784 Some(Js::Array(arr)) => {
785 let mut keys = Vec::with_capacity(arr.len());
786 for v in arr {
787 match v.as_str() {
788 Some(s) => keys.push(s.to_string()),
789 None => return err_response("mask must be an array of strings"),
790 }
791 }
792 Some(keys)
793 }
794 Some(_) => return err_response("mask must be an array of strings"),
795 };
796
797 if as_of.is_some() && (matches!(identity, AuthIdentity::Role(_)) || mask_keys.is_some()) {
800 return err_response(
801 "as_of (time-travel) is not yet supported with role tokens or a client mask",
802 );
803 }
804
805 if let AuthIdentity::Role(ref role_name) = identity {
809 let is_write = match is_write_query(&cypher) {
810 Ok(b) => b,
811 Err(e) => return err_response(e),
812 };
813 if is_write {
814 let role = role_name.clone();
815 let cypher_c = cypher.clone();
816 let params_c = params.clone();
817 let db = state.db.clone();
818 return match blocking_write(move || {
819 db.write().query_write_authz(&role, &cypher_c, ¶ms_c)
820 })
821 .await
822 {
823 Ok(rs) => format_query_result(rs, format),
824 Err(resp) => resp,
825 };
826 }
827 let snap = state.db.reader();
828 let role_mask = match snap.mask_for_role(role_name) {
829 Ok(m) => m,
830 Err(e) => return role_mask_err(e),
831 };
832 let effective_mask = if let Some(ref keys) = mask_keys {
833 let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
835 role_mask.intersect(&client_mask)
836 } else {
837 role_mask
838 };
839 return match snap.query_masked(&cypher, ¶ms, &effective_mask) {
840 Ok(rs) => format_query_result(rs, format),
841 Err(GraphError::MaskedReadOnly) => (
842 StatusCode::BAD_REQUEST,
843 Json(json!({"error": "masked queries are read-only"})),
844 )
845 .into_response(),
846 Err(e) => graph_err(e),
847 };
848 }
849
850 if let Some(ref keys) = mask_keys {
860 let stub_hidden = body
861 .get("stub_hidden")
862 .and_then(|v| v.as_bool())
863 .unwrap_or(false);
864 let db = state.db.read();
865 let mask = {
866 let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
867 if stub_hidden {
868 m.with_mode(MaskMode::Stub)
869 } else {
870 m
871 }
872 };
873 return match db.query_masked(&cypher, ¶ms, &mask) {
874 Ok(rs) => format_query_result(rs, format),
875 Err(GraphError::MaskedReadOnly) => (
876 StatusCode::BAD_REQUEST,
877 Json(json!({"error": "masked queries are read-only"})),
878 )
879 .into_response(),
880 Err(e) => graph_err(e),
881 };
882 }
883
884 let is_write = match is_write_query(&cypher) {
889 Ok(b) => b,
890 Err(e) => return err_response(e),
891 };
892
893 if as_of.is_some() && is_write {
894 return err_response("as_of (time-travel) queries are read-only");
895 }
896
897 let rs = if is_write {
898 let db = state.db.clone();
899 match blocking_write(move || db.write().query_write(&cypher, ¶ms)).await {
900 Ok(rs) => rs,
901 Err(resp) => return resp,
902 }
903 } else if let Some(commit) = as_of {
904 match state.db.read().query_at(commit, &cypher, ¶ms) {
905 Ok(rs) => rs,
906 Err(e) => return graph_err(e),
907 }
908 } else {
909 match state.db.read().query(&cypher, ¶ms) {
910 Ok(rs) => rs,
911 Err(e) => return graph_err(e),
912 }
913 };
914
915 format_query_result(rs, format)
916}
917
918async fn stats(
919 State(state): State<AppState>,
920 Extension(identity): Extension<AuthIdentity>,
921) -> Response {
922 if let AuthIdentity::Role(_) = identity {
924 return forbidden("role-bound token: /stats requires a full-access token");
925 }
926 let snap = {
927 let g = state.db.read();
928 g.stats()
929 };
930 match serde_json::to_value(&snap) {
931 Ok(v) => json_ok(v),
932 Err(e) => err_response(e.to_string()),
933 }
934}
935
936async fn metrics(
937 State(state): State<AppState>,
938 Extension(identity): Extension<AuthIdentity>,
939) -> Response {
940 if let AuthIdentity::Role(_) = identity {
942 return forbidden("role-bound token: /metrics requires a full-access token");
943 }
944 let (s, commit_seq, wal_size_bytes, slow_snap) = {
945 let g = state.db.read();
946 let s = g.stats();
947 let commit_seq = g.commit_seq();
948 let wal_size_bytes = g.wal_size_bytes().ok();
949 let slow_snap = g.slow_query_snapshot();
950 (s, commit_seq, wal_size_bytes, slow_snap)
951 };
952 let uptime_s = state.started_at.elapsed().as_secs();
953 let slow_entries: Vec<Js> = slow_snap
954 .last
955 .iter()
956 .map(|e| {
957 json!({
958 "ms": e.ms,
959 "query": e.query,
960 "at_commit": e.at_commit,
961 })
962 })
963 .collect();
964 json_ok(json!({
965 "nodes_live": s.nodes_live,
966 "nodes_tombstoned": s.nodes_tombstoned,
967 "edges": s.edges,
968 "commit_seq": commit_seq,
969 "wal_size_bytes": wal_size_bytes,
970 "rss_bytes": rss_bytes(),
971 "uptime_s": uptime_s,
972 "slow_queries": {
973 "threshold_ms": slow_snap.threshold_ms,
974 "count": slow_snap.count,
975 "last": slow_entries,
976 },
977 }))
978}
979
980fn rss_bytes() -> Option<u64> {
988 #[cfg(target_os = "macos")]
989 {
990 const MACH_TASK_BASIC_INFO: u32 = 20;
995 const MACH_TASK_BASIC_INFO_COUNT: u32 = 12;
996
997 #[repr(C)]
998 struct MachTaskBasicInfo {
999 virtual_size: u64,
1000 resident_size: u64,
1001 resident_size_max: u64,
1002 user_time: [u32; 2],
1003 system_time: [u32; 2],
1004 policy: i32,
1005 suspend_count: i32,
1006 }
1007
1008 extern "C" {
1009 fn mach_task_self() -> u32;
1010 fn task_info(
1011 target_task: u32,
1012 flavor: u32,
1013 task_info_out: *mut std::ffi::c_void,
1014 task_info_cnt: *mut u32,
1015 ) -> i32;
1016 }
1017
1018 let mut info: MachTaskBasicInfo = unsafe { std::mem::zeroed() };
1019 let mut count = MACH_TASK_BASIC_INFO_COUNT;
1020 let ret = unsafe {
1021 task_info(
1022 mach_task_self(),
1023 MACH_TASK_BASIC_INFO,
1024 &mut info as *mut _ as *mut _,
1025 &mut count,
1026 )
1027 };
1028 if ret != 0 {
1029 return None; }
1031 return Some(info.resident_size);
1032 }
1033
1034 #[cfg(target_os = "linux")]
1035 {
1036 let content = std::fs::read_to_string("/proc/self/statm").ok()?;
1038 let mut parts = content.split_whitespace();
1039 let _vsize = parts.next()?;
1040 let rss_pages: u64 = parts.next()?.parse().ok()?;
1041 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1042 if page_size <= 0 {
1043 return None;
1044 }
1045 return Some(rss_pages * page_size as u64);
1046 }
1047
1048 #[allow(unreachable_code)]
1049 None
1050}
1051
1052async fn ingest(
1053 State(state): State<AppState>,
1054 Extension(identity): Extension<AuthIdentity>,
1055 Json(body): Json<Js>,
1056) -> Response {
1057 let label = match body.get("label").and_then(Js::as_str) {
1058 Some(s) => s.to_string(),
1059 None => return err_response("missing label"),
1060 };
1061 let rows = match body.get("rows") {
1062 Some(r) => r,
1063 None => return err_response("missing rows"),
1064 };
1065 let mut converted = match json_to_rows(rows) {
1066 Ok(c) => c,
1067 Err(e) => return graph_err(e),
1068 };
1069 let opts = match ingest_options(body.get("options")) {
1070 Ok(o) => o,
1071 Err(e) => return err_response(e),
1072 };
1073 let taken = std::mem::take(&mut converted.rows);
1074 let edges = match body.get("edges") {
1075 None | Some(Js::Null) => Vec::new(),
1076 Some(raw) => match parse_ingest_edges(raw) {
1077 Ok(e) => e,
1078 Err(e) => return err_response(e),
1079 },
1080 };
1081 let db = state.db.clone();
1082
1083 if let AuthIdentity::Role(role_name) = &identity {
1087 let role = role_name.clone();
1088 return match blocking_write(move || {
1089 db.write()
1090 .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
1091 })
1092 .await
1093 {
1094 Ok(r) => {
1095 let report = converted.into_report(r);
1096 match serde_json::to_value(&report) {
1097 Ok(v) => json_ok(v),
1098 Err(e) => err_response(e.to_string()),
1099 }
1100 }
1101 Err(resp) => resp,
1102 };
1103 }
1104
1105 let report =
1106 match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
1107 .await
1108 {
1109 Ok(r) => converted.into_report(r),
1110 Err(resp) => return resp,
1111 };
1112 match serde_json::to_value(&report) {
1113 Ok(v) => json_ok(v),
1114 Err(e) => err_response(e.to_string()),
1115 }
1116}
1117
1118async fn suggest(
1129 State(state): State<AppState>,
1130 Extension(identity): Extension<AuthIdentity>,
1131) -> Response {
1132 if let AuthIdentity::Role(_) = identity {
1135 return forbidden("role-bound token: /suggest requires a full-access token");
1136 }
1137 let db = state.db.clone();
1138 match tokio::task::spawn_blocking(move || {
1139 let config = SuggestConfig::default();
1140 db.read()
1141 .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1142 })
1143 .await
1144 {
1145 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1146 Err(_) => err_response("suggest task panicked"),
1147 }
1148}
1149
1150async fn create_rule(
1151 State(state): State<AppState>,
1152 Extension(identity): Extension<AuthIdentity>,
1153 Json(body): Json<Js>,
1154) -> Response {
1155 if let AuthIdentity::Role(_) = identity {
1156 return forbidden("role-bound token: writes are not permitted");
1157 }
1158 let def = match rule_def_from_json(body) {
1159 Ok(d) => d,
1160 Err(e) => return err_response(e),
1161 };
1162 let name = def.name.clone();
1163 let db = state.db.clone();
1164 match blocking_write(move || db.write().create_rule(def)).await {
1165 Ok(()) => json_ok(json!({"ok": true, "name": name})),
1166 Err(resp) => resp,
1167 }
1168}
1169
1170async fn explain(
1171 State(state): State<AppState>,
1172 Extension(identity): Extension<AuthIdentity>,
1173 Query(qs): Query<BTreeMap<String, String>>,
1174) -> Response {
1175 if let AuthIdentity::Role(_) = identity {
1177 return forbidden(
1178 "role-bound token: /explain requires a full-access token \
1179 (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1180 );
1181 }
1182 let a = match qs.get("a") {
1183 Some(s) if !s.is_empty() => s.clone(),
1184 _ => return err_response("missing query param a"),
1185 };
1186 let b = match qs.get("b") {
1187 Some(s) if !s.is_empty() => s.clone(),
1188 _ => return err_response("missing query param b"),
1189 };
1190 let out = {
1191 let g = state.db.read();
1192 g.explain(&a, &b)
1193 };
1194 match out {
1195 Ok(v) => match serde_json::to_value(&v) {
1196 Ok(j) => json_ok(j),
1197 Err(e) => err_response(e.to_string()),
1198 },
1199 Err(e) => graph_err(e),
1200 }
1201}
1202
1203async fn node_info(
1204 State(state): State<AppState>,
1205 Extension(identity): Extension<AuthIdentity>,
1206 Path(key): Path<String>,
1207 Query(qs): Query<BTreeMap<String, String>>,
1208) -> Response {
1209 if let AuthIdentity::Role(ref role_name) = identity {
1210 let snap = state.db.reader();
1214 let role_mask = match snap.mask_for_role(role_name) {
1215 Ok(m) => m,
1216 Err(e) => return role_mask_err(e),
1217 };
1218 if !snap
1219 .resolve_key(&key)
1220 .is_some_and(|id| role_mask.contains_id(id))
1221 {
1222 return key_not_found(key);
1223 }
1224 return match snap.node_info(&key) {
1225 Some(info) => json_ok(node_info_json(&info)),
1226 None => key_not_found(key),
1227 };
1228 }
1229
1230 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1234 if !mask_param.is_empty() {
1235 let stub_hidden = qs
1236 .get("stub_hidden")
1237 .map(|v| v == "true" || v == "1")
1238 .unwrap_or(false);
1239 let g = state.db.read();
1240 let mask = {
1241 let keys = mask_param
1242 .split(',')
1243 .map(str::trim)
1244 .filter(|s| !s.is_empty());
1245 let m = NodeMask::from_keys(&*g, keys);
1246 if stub_hidden {
1247 m.with_mode(MaskMode::Stub)
1248 } else {
1249 m
1250 }
1251 };
1252 return match g.node_info_masked(&key, &mask) {
1253 Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1254 Some(core_api::MaskedNodeResult::Restricted) => {
1255 json_ok(crate::json::stub_node_json(&key))
1256 }
1257 None => key_not_found(key),
1258 };
1259 }
1260
1261 let info = {
1262 let g = state.db.read();
1263 g.node_info(&key)
1264 };
1265 match info {
1266 Some(info) => json_ok(node_info_json(&info)),
1267 None => key_not_found(key),
1268 }
1269}
1270
1271async fn node_edges(
1272 State(state): State<AppState>,
1273 Extension(identity): Extension<AuthIdentity>,
1274 Path(key): Path<String>,
1275 Query(qs): Query<BTreeMap<String, String>>,
1276) -> Response {
1277 if let AuthIdentity::Role(ref role_name) = identity {
1278 let snap = state.db.reader();
1281 let role_mask = match snap.mask_for_role(role_name) {
1282 Ok(m) => m,
1283 Err(e) => return role_mask_err(e),
1284 };
1285 if !snap
1286 .resolve_key(&key)
1287 .is_some_and(|id| role_mask.contains_id(id))
1288 {
1289 return key_not_found(key);
1290 }
1291 return match snap.node_edges(&key) {
1292 Ok(edges) => {
1293 let visible: Vec<_> = edges
1297 .into_iter()
1298 .filter(|e| {
1299 let other = if e.src_key == key {
1300 &e.dst_key
1301 } else {
1302 &e.src_key
1303 };
1304 snap.resolve_key(other)
1305 .is_some_and(|id| role_mask.contains_id(id))
1306 })
1307 .collect();
1308 json_ok(node_edges_json(&visible))
1309 }
1310 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1311 Err(e) => graph_err(e),
1312 };
1313 }
1314
1315 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1317 if !mask_param.is_empty() {
1318 let stub_hidden = qs
1319 .get("stub_hidden")
1320 .map(|v| v == "true" || v == "1")
1321 .unwrap_or(false);
1322 let g = state.db.read();
1323 let mask = {
1324 let keys = mask_param
1325 .split(',')
1326 .map(str::trim)
1327 .filter(|s| !s.is_empty());
1328 let m = NodeMask::from_keys(&*g, keys);
1329 if stub_hidden {
1330 m.with_mode(MaskMode::Stub)
1331 } else {
1332 m
1333 }
1334 };
1335 return match g.node_edges_masked(&key, &mask) {
1336 Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1337 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1338 Err(e) => graph_err(e),
1339 };
1340 }
1341
1342 let out = {
1343 let g = state.db.read();
1344 g.node_edges(&key)
1345 };
1346 match out {
1347 Ok(edges) => json_ok(node_edges_json(&edges)),
1348 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1349 Err(e) => graph_err(e),
1350 }
1351}
1352
1353async fn neighborhood(
1354 State(state): State<AppState>,
1355 Extension(identity): Extension<AuthIdentity>,
1356 Path(key): Path<String>,
1357 Query(qs): Query<BTreeMap<String, String>>,
1358) -> Response {
1359 let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1360 Ok(d) => d,
1361 Err(e) => return err_response(e),
1362 };
1363 let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1364 s if s.eq_ignore_ascii_case("out") => Dir::Out,
1365 s if s.eq_ignore_ascii_case("in") => Dir::In,
1366 s if s.eq_ignore_ascii_case("both") => Dir::Both,
1367 other => return err_response(format!("unknown dir: {other}")),
1368 };
1369 let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1370 s.split(',')
1371 .map(str::trim)
1372 .filter(|t| !t.is_empty())
1373 .map(str::to_string)
1374 .collect()
1375 });
1376 let etype_refs: Option<Vec<&str>> = edge_type_names
1377 .as_ref()
1378 .map(|v| v.iter().map(String::as_str).collect());
1379 if let AuthIdentity::Role(ref role_name) = identity {
1380 let snap = state.db.reader();
1383 let role_mask = match snap.mask_for_role(role_name) {
1384 Ok(m) => m,
1385 Err(e) => return role_mask_err(e),
1386 };
1387 if !snap
1388 .resolve_key(&key)
1389 .is_some_and(|id| role_mask.contains_id(id))
1390 {
1391 return key_not_found(key);
1392 }
1393 let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1396 {
1397 Some(rs) => rs,
1398 None => return key_not_found(key),
1399 };
1400 return json_ok(result_set_json(&rs));
1401 }
1402 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1408 if !mask_param.is_empty() {
1409 let stub_hidden = qs
1410 .get("stub_hidden")
1411 .map(|v| v == "true" || v == "1")
1412 .unwrap_or(false);
1413 let g = state.db.read();
1414 let mask = {
1415 let keys = mask_param
1416 .split(',')
1417 .map(str::trim)
1418 .filter(|s| !s.is_empty());
1419 let m = NodeMask::from_keys(&*g, keys);
1420 if stub_hidden {
1421 m.with_mode(MaskMode::Stub)
1422 } else {
1423 m
1424 }
1425 };
1426 return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1427 Some(rs) => json_ok(result_set_json(&rs)),
1428 None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1429 };
1430 }
1431
1432 let rs = {
1434 let g = state.db.read();
1435 match g.node_ref(&key) {
1436 Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1437 None => Err(GraphError::KeyNotFound { key: key.clone() }),
1438 }
1439 };
1440 match rs {
1441 Ok(rs) => json_ok(result_set_json(&rs)),
1442 Err(e) => graph_err(e),
1443 }
1444}
1445
1446async fn algo_pagerank(
1455 State(state): State<AppState>,
1456 Extension(identity): Extension<AuthIdentity>,
1457 Json(body): Json<serde_json::Value>,
1458) -> Response {
1459 if let AuthIdentity::Role(_) = identity {
1461 return forbidden("role-bound token: /algo/* requires a full-access token");
1462 }
1463 let config: PageRankConfig = match serde_json::from_value(body) {
1464 Ok(c) => c,
1465 Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1466 };
1467 let db = state.db.clone();
1468 match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1469 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1470 Err(_) => err_response("pagerank task panicked"),
1471 }
1472}
1473
1474async fn algo_wcc(
1479 State(state): State<AppState>,
1480 Extension(identity): Extension<AuthIdentity>,
1481 Json(body): Json<serde_json::Value>,
1482) -> Response {
1483 if let AuthIdentity::Role(_) = identity {
1484 return forbidden("role-bound token: /algo/* requires a full-access token");
1485 }
1486 let config: WccConfig = match serde_json::from_value(body) {
1487 Ok(c) => c,
1488 Err(e) => return err_response(format!("invalid wcc config: {e}")),
1489 };
1490 let db = state.db.clone();
1491 match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1492 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1493 Err(_) => err_response("wcc task panicked"),
1494 }
1495}
1496
1497async fn algo_degree(
1501 State(state): State<AppState>,
1502 Extension(identity): Extension<AuthIdentity>,
1503 Json(body): Json<serde_json::Value>,
1504) -> Response {
1505 if let AuthIdentity::Role(_) = identity {
1506 return forbidden("role-bound token: /algo/* requires a full-access token");
1507 }
1508 let config: DegreeConfig = match serde_json::from_value(body) {
1509 Ok(c) => c,
1510 Err(e) => return err_response(format!("invalid degree config: {e}")),
1511 };
1512 let db = state.db.clone();
1513 match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1514 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1515 Err(_) => err_response("degree task panicked"),
1516 }
1517}
1518
1519fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1532 let obj = match v.as_object() {
1533 Some(o) => o,
1534 None => return Err("props must be a JSON object".into()),
1535 };
1536 let mut out = Vec::with_capacity(obj.len());
1537 for (k, val) in obj {
1538 if let Some(v) = json_to_value(val.clone()) {
1539 out.push((k.clone(), v));
1540 }
1541 }
1542 Ok(out)
1543}
1544
1545async fn create_node(
1549 State(state): State<AppState>,
1550 Extension(identity): Extension<AuthIdentity>,
1551 Json(body): Json<Js>,
1552) -> Response {
1553 let label = match body.get("label").and_then(Js::as_str) {
1554 Some(s) => s.to_string(),
1555 None => return err_response("missing label"),
1556 };
1557 let key = match body.get("key").and_then(Js::as_str) {
1558 Some(s) => s.to_string(),
1559 None => return err_response("missing key"),
1560 };
1561 let props = match body.get("props") {
1562 None | Some(Js::Null) => vec![],
1563 Some(v) => match props_from_json_obj(v) {
1564 Ok(p) => p,
1565 Err(e) => return err_response(e),
1566 },
1567 };
1568 let db = state.db.clone();
1569 if let AuthIdentity::Role(role_name) = &identity {
1570 let role = role_name.clone();
1571 return match blocking_write(move || {
1572 db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1573 })
1574 .await
1575 {
1576 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1577 Err(resp) => resp,
1578 };
1579 }
1580 match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1581 .await
1582 {
1583 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1584 Err(resp) => resp,
1585 }
1586}
1587
1588async fn delete_node(
1590 State(state): State<AppState>,
1591 Extension(identity): Extension<AuthIdentity>,
1592 Path(key): Path<String>,
1593) -> Response {
1594 let db = state.db.clone();
1595 if let AuthIdentity::Role(role_name) = &identity {
1596 let role = role_name.clone();
1597 return match blocking_write(move || {
1598 db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1599 })
1600 .await
1601 {
1602 Ok(_) => json_ok(json!({"ok": true})),
1603 Err(resp) => resp,
1604 };
1605 }
1606 match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1607 Ok(_) => json_ok(json!({"ok": true})),
1608 Err(resp) => resp,
1609 }
1610}
1611
1612async fn create_edge(
1616 State(state): State<AppState>,
1617 Extension(identity): Extension<AuthIdentity>,
1618 Json(body): Json<Js>,
1619) -> Response {
1620 let edge_type = match body.get("type").and_then(Js::as_str) {
1621 Some(s) => s.to_string(),
1622 None => return err_response("missing type"),
1623 };
1624 let src = match body.get("src").and_then(Js::as_str) {
1625 Some(s) => s.to_string(),
1626 None => return err_response("missing src"),
1627 };
1628 let dst = match body.get("dst").and_then(Js::as_str) {
1629 Some(s) => s.to_string(),
1630 None => return err_response("missing dst"),
1631 };
1632 let db = state.db.clone();
1633 if let AuthIdentity::Role(role_name) = &identity {
1634 let role = role_name.clone();
1635 return match blocking_write(move || {
1636 db.submit_batch_authz(
1637 role,
1638 vec![BatchOp::InsertEdge {
1639 edge_type,
1640 src_key: src,
1641 dst_key: dst,
1642 }],
1643 )
1644 })
1645 .await
1646 {
1647 Ok(_) => json_ok(json!({"ok": true})),
1648 Err(resp) => resp,
1649 };
1650 }
1651 match blocking_write(move || {
1652 db.submit_batch(vec![BatchOp::InsertEdge {
1653 edge_type,
1654 src_key: src,
1655 dst_key: dst,
1656 }])
1657 })
1658 .await
1659 {
1660 Ok(_) => json_ok(json!({"ok": true})),
1661 Err(resp) => resp,
1662 }
1663}
1664
1665async fn delete_edge(
1667 State(state): State<AppState>,
1668 Extension(identity): Extension<AuthIdentity>,
1669 Path((etype, src, dst)): Path<(String, String, String)>,
1670) -> Response {
1671 let db = state.db.clone();
1672 if let AuthIdentity::Role(role_name) = &identity {
1673 let role = role_name.clone();
1674 return match blocking_write(move || {
1675 db.submit_batch_authz(
1676 role,
1677 vec![BatchOp::DeleteEdge {
1678 edge_type: etype,
1679 src_key: src,
1680 dst_key: dst,
1681 }],
1682 )
1683 })
1684 .await
1685 {
1686 Ok(_) => json_ok(json!({"ok": true})),
1687 Err(resp) => resp,
1688 };
1689 }
1690 match blocking_write(move || {
1691 db.submit_batch(vec![BatchOp::DeleteEdge {
1692 edge_type: etype,
1693 src_key: src,
1694 dst_key: dst,
1695 }])
1696 })
1697 .await
1698 {
1699 Ok(_) => json_ok(json!({"ok": true})),
1700 Err(resp) => resp,
1701 }
1702}
1703
1704async fn rename_node(
1709 State(state): State<AppState>,
1710 Extension(identity): Extension<AuthIdentity>,
1711 Path(key): Path<String>,
1712 Json(body): Json<Js>,
1713) -> Response {
1714 if let AuthIdentity::Role(_) = identity {
1715 return forbidden("role-bound token: writes are not permitted");
1716 }
1717 let new_key = match body.get("new_key").and_then(Js::as_str) {
1718 Some(s) => s.to_string(),
1719 None => return err_response("missing new_key"),
1720 };
1721 let db = state.db.clone();
1722 match tokio::task::spawn_blocking(move || {
1723 db.submit_batch(vec![BatchOp::RenameNode {
1724 old_key: key,
1725 new_key,
1726 }])
1727 })
1728 .await
1729 {
1730 Ok(Ok(_)) => json_ok(json!({"ok": true})),
1731 Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1732 Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1733 Ok(Err(e)) => graph_err(e),
1734 Err(_) => err_response("write task panicked"),
1735 }
1736}
1737
1738async fn upsert_edge(
1743 State(state): State<AppState>,
1744 Extension(identity): Extension<AuthIdentity>,
1745 Json(body): Json<Js>,
1746) -> Response {
1747 let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1748 Some(s) => s.to_string(),
1749 None => return err_response("missing edge_type"),
1750 };
1751 let src_key = match body.get("src_key").and_then(Js::as_str) {
1752 Some(s) => s.to_string(),
1753 None => return err_response("missing src_key"),
1754 };
1755 let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1756 Some(s) => s.to_string(),
1757 None => return err_response("missing dst_key"),
1758 };
1759 let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1760 Some(s) => s.to_string(),
1761 None => return err_response("missing placeholder_label"),
1762 };
1763 let db = state.db.clone();
1764 if let AuthIdentity::Role(role_name) = &identity {
1765 let role = role_name.clone();
1766 return match blocking_write(move || {
1767 db.submit_batch_authz(
1768 role,
1769 vec![BatchOp::InsertEdgeUpsert {
1770 edge_type,
1771 src_key,
1772 dst_key,
1773 placeholder_label,
1774 }],
1775 )
1776 })
1777 .await
1778 {
1779 Ok((nodes, edges)) => json_ok(json!({
1780 "nodes_created": nodes,
1781 "edge_inserted": edges > 0,
1782 })),
1783 Err(resp) => resp,
1784 };
1785 }
1786 match blocking_write(move || {
1787 db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1788 edge_type,
1789 src_key,
1790 dst_key,
1791 placeholder_label,
1792 }])
1793 })
1794 .await
1795 {
1796 Ok((nodes, edges)) => json_ok(json!({
1797 "nodes_created": nodes,
1798 "edge_inserted": edges > 0,
1799 })),
1800 Err(resp) => resp,
1801 }
1802}
1803
1804async fn set_node_prop(
1808 State(state): State<AppState>,
1809 Extension(identity): Extension<AuthIdentity>,
1810 Path((key, field)): Path<(String, String)>,
1811 Json(body): Json<Js>,
1812) -> Response {
1813 let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1814 Some(v) => v,
1815 None => {
1816 return err_response(
1817 "request body must be a JSON object with a \"value\" field, \
1818 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
1819 )
1820 }
1821 };
1822 let db = state.db.clone();
1823 if let AuthIdentity::Role(role_name) = &identity {
1824 let role = role_name.clone();
1825 return match blocking_write(move || {
1826 db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1827 })
1828 .await
1829 {
1830 Ok(_) => json_ok(json!({"ok": true})),
1831 Err(resp) => resp,
1832 };
1833 }
1834 match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1835 .await
1836 {
1837 Ok(_) => json_ok(json!({"ok": true})),
1838 Err(resp) => resp,
1839 }
1840}
1841
1842async fn node_history_handler(
1858 State(state): State<AppState>,
1859 Extension(identity): Extension<AuthIdentity>,
1860 Path(key): Path<String>,
1861) -> Response {
1862 if let AuthIdentity::Role(ref role_name) = identity {
1863 let g = state.db.read();
1864 let role_mask = match g.mask_for_role(role_name) {
1865 Ok(m) => m,
1866 Err(e) => return role_mask_err(e),
1867 };
1868 if !role_mask.contains_node(&*g, &key) {
1870 return key_not_found(key);
1871 }
1872 let entries = match g.node_history(&key) {
1873 Ok(e) => e,
1874 Err(e) => return graph_err(e),
1875 };
1876 let total_commits = match g.wal_total_commits() {
1877 Ok(n) => n,
1878 Err(e) => return graph_err(e),
1879 };
1880 use core_api::HistoryChange;
1884 let visible: Vec<_> = entries
1885 .into_iter()
1886 .filter(|entry| match &entry.change {
1887 HistoryChange::EdgeAdded { other, .. }
1888 | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1889 _ => true,
1890 })
1891 .collect();
1892 return json_ok(node_history_json(&key, &visible, total_commits));
1893 }
1894 let g = state.db.read();
1897 if !g.has_node(&key) {
1898 return key_not_found(key);
1899 }
1900 let entries = match g.node_history(&key) {
1901 Ok(e) => e,
1902 Err(e) => return graph_err(e),
1903 };
1904 let total_commits = match g.wal_total_commits() {
1905 Ok(n) => n,
1906 Err(e) => return graph_err(e),
1907 };
1908 json_ok(node_history_json(&key, &entries, total_commits))
1909}
1910
1911async fn edge_history_handler(
1917 State(state): State<AppState>,
1918 Extension(identity): Extension<AuthIdentity>,
1919 Query(qs): Query<BTreeMap<String, String>>,
1920) -> Response {
1921 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1922 Some(s) => s.clone(),
1923 None => return err_response("missing query param a"),
1924 };
1925 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1926 Some(s) => s.clone(),
1927 None => return err_response("missing query param b"),
1928 };
1929 if let AuthIdentity::Role(ref role_name) = identity {
1930 let g = state.db.read();
1931 let role_mask = match g.mask_for_role(role_name) {
1932 Ok(m) => m,
1933 Err(e) => return role_mask_err(e),
1934 };
1935 if !role_mask.contains_node(&*g, &a) {
1937 return key_not_found(a);
1938 }
1939 if !role_mask.contains_node(&*g, &b) {
1940 return key_not_found(b);
1941 }
1942 let result = match g.edge_history(&a, &b) {
1943 Ok(r) => r,
1944 Err(e) => return graph_err(e),
1945 };
1946 return json_ok(edge_history_result_json(&a, &b, &result));
1947 }
1948 let g = state.db.read();
1950 let result = match g.edge_history(&a, &b) {
1951 Ok(r) => r,
1952 Err(e) => return graph_err(e),
1953 };
1954 json_ok(edge_history_result_json(&a, &b, &result))
1955}
1956
1957async fn was_linked_handler(
1963 State(state): State<AppState>,
1964 Extension(identity): Extension<AuthIdentity>,
1965 Query(qs): Query<BTreeMap<String, String>>,
1966) -> Response {
1967 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1968 Some(s) => s.clone(),
1969 None => return err_response("missing query param a"),
1970 };
1971 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1972 Some(s) => s.clone(),
1973 None => return err_response("missing query param b"),
1974 };
1975 let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1976 Some(s) => s.clone(),
1977 None => return err_response("missing query param edge_type"),
1978 };
1979 let at_commit: u64 = match qs.get("at_commit") {
1980 Some(s) => match s.parse() {
1981 Ok(n) => n,
1982 Err(_) => return err_response("at_commit must be a non-negative integer"),
1983 },
1984 None => return err_response("missing query param at_commit"),
1985 };
1986
1987 if let AuthIdentity::Role(ref role_name) = identity {
1988 let g = state.db.read();
1989 let role_mask = match g.mask_for_role(role_name) {
1990 Ok(m) => m,
1991 Err(e) => return role_mask_err(e),
1992 };
1993 if !role_mask.contains_node(&*g, &a) {
1994 return key_not_found(a);
1995 }
1996 if !role_mask.contains_node(&*g, &b) {
1997 return key_not_found(b);
1998 }
1999 return match g.was_linked(&a, &b, &edge_type, at_commit) {
2000 Ok(linked) => json_ok(json!({
2001 "a": a, "b": b, "edge_type": edge_type,
2002 "at_commit": at_commit, "linked": linked,
2003 })),
2004 Err(GraphError::CommitOutOfRange { .. }) => (
2005 StatusCode::BAD_REQUEST,
2006 Json(json!({"error": format!("commit {at_commit} is out of range")})),
2007 )
2008 .into_response(),
2009 Err(e) => graph_err(e),
2010 };
2011 }
2012
2013 let g = state.db.read();
2015 match g.was_linked(&a, &b, &edge_type, at_commit) {
2016 Ok(linked) => json_ok(json!({
2017 "a": a, "b": b, "edge_type": edge_type,
2018 "at_commit": at_commit, "linked": linked,
2019 })),
2020 Err(GraphError::CommitOutOfRange { .. }) => (
2021 StatusCode::BAD_REQUEST,
2022 Json(json!({"error": format!("commit {at_commit} is out of range")})),
2023 )
2024 .into_response(),
2025 Err(e) => graph_err(e),
2026 }
2027}
2028
2029async fn remove_node_prop(
2031 State(state): State<AppState>,
2032 Extension(identity): Extension<AuthIdentity>,
2033 Path((key, field)): Path<(String, String)>,
2034) -> Response {
2035 let db = state.db.clone();
2036 if let AuthIdentity::Role(role_name) = &identity {
2037 let role = role_name.clone();
2038 return match blocking_write(move || {
2039 db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
2040 })
2041 .await
2042 {
2043 Ok(_) => json_ok(json!({"ok": true})),
2044 Err(resp) => resp,
2045 };
2046 }
2047 match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
2048 Ok(_) => json_ok(json!({"ok": true})),
2049 Err(resp) => resp,
2050 }
2051}
2052
2053fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2058 if a.len() != b.len() {
2059 return false;
2060 }
2061 let mut diff = 0u8;
2062 for (x, y) in a.iter().zip(b.iter()) {
2063 diff |= x ^ y;
2064 }
2065 diff == 0
2066}
2067
2068const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
2073
2074fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
2076 match raw {
2077 None => Ok(1),
2078 Some(s) => {
2079 let d: u32 = s
2080 .parse()
2081 .map_err(|_| "depth must be an integer".to_string())?;
2082 if d > MAX_NEIGHBORHOOD_DEPTH {
2083 return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
2084 }
2085 Ok(d)
2086 }
2087 }
2088}
2089
2090fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
2098 if dest.is_empty() {
2099 return Err("missing or empty \"dest\" field".into());
2100 }
2101 let dest_path = std::path::Path::new(dest);
2102 if dest_path
2103 .components()
2104 .any(|c| matches!(c, std::path::Component::ParentDir))
2105 {
2106 return Err("backup \"dest\" may not contain \"..\" path segments".into());
2107 }
2108 let joined = if dest_path.is_absolute() {
2109 dest_path.to_path_buf()
2110 } else {
2111 root.join(dest_path)
2112 };
2113 if !joined.starts_with(root) {
2114 return Err(format!(
2115 "backup \"dest\" must be within the backup root ({}); \
2116 set MUSHROOMDB_BACKUP_DIR to change it",
2117 root.display()
2118 ));
2119 }
2120 Ok(joined)
2121}
2122
2123fn backup_root() -> PathBuf {
2126 std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2127 .map(PathBuf::from)
2128 .or_else(|| std::env::current_dir().ok())
2129 .unwrap_or_else(|| PathBuf::from("."))
2130}
2131
2132async fn backup(
2153 State(state): State<AppState>,
2154 Extension(identity): Extension<AuthIdentity>,
2155 Json(body): Json<Js>,
2156) -> Response {
2157 if let AuthIdentity::Role(_) = identity {
2158 return forbidden("role-bound token: /backup requires a full-access token");
2159 }
2160 let root = backup_root();
2161 let dest = match body.get("dest").and_then(Js::as_str) {
2162 Some(s) => match confine_backup_dest(s, &root) {
2163 Ok(p) => p,
2164 Err(e) => return err_response(e),
2165 },
2166 None => return err_response("missing or empty \"dest\" field"),
2167 };
2168 let db = state.db.clone();
2169 let report: BackupReport = match tokio::task::spawn_blocking(move || {
2170 let g = db.read();
2173 g.backup_to(&dest)
2174 })
2175 .await
2176 {
2177 Ok(Ok(r)) => r,
2178 Ok(Err(e)) => return graph_err(e),
2179 Err(_) => return err_response("backup task panicked"),
2180 };
2181
2182 let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2183 Ok(v) => v,
2184 Err(e) => return err_response(e.to_string()),
2185 };
2186
2187 if report.verified {
2188 json_ok(body)
2189 } else {
2190 (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2191 }
2192}
2193
2194#[derive(serde::Serialize)]
2196struct BackupReportJson<'a> {
2197 files: &'a [String],
2198 bytes: u64,
2199 verified: bool,
2200}
2201
2202impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2203 fn from(r: &'a BackupReport) -> Self {
2204 Self {
2205 files: &r.files,
2206 bytes: r.bytes,
2207 verified: r.verified,
2208 }
2209 }
2210}
2211
2212#[cfg(test)]
2213mod tests {
2214 use super::*;
2215 use crate::json::result_set_json;
2216 use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2217
2218 #[test]
2219 fn nan_float_cell_serializes_as_null() {
2220 let mut rs = ResultSet::new(vec!["n".into()]);
2221 rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2222 let j = result_set_json(&rs);
2223 assert_eq!(j["rows"][0][0], Js::Null);
2224 }
2225
2226 #[test]
2229 fn pagerank_config_empty_body_uses_defaults() {
2230 let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2231 let default = PageRankConfig::default();
2232 assert_eq!(config.damping, default.damping);
2233 assert_eq!(config.max_iters, default.max_iters);
2234 assert_eq!(config.tol, default.tol);
2235 assert_eq!(config.budget_ms, default.budget_ms);
2236 assert_eq!(config.edge_type, default.edge_type);
2237 }
2238
2239 #[test]
2241 fn wcc_config_empty_body_uses_defaults() {
2242 let config: WccConfig = serde_json::from_str("{}").unwrap();
2243 let default = WccConfig::default();
2244 assert_eq!(config.budget_ms, default.budget_ms);
2245 assert_eq!(config.edge_type, default.edge_type);
2246 }
2247
2248 #[test]
2250 fn degree_config_empty_body_uses_defaults() {
2251 let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2252 let default = DegreeConfig::default();
2253 assert_eq!(config.budget_ms, default.budget_ms);
2254 assert_eq!(config.edge_type, default.edge_type);
2255 }
2256
2257 #[test]
2258 fn backup_dest_rejects_empty() {
2259 assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2260 }
2261
2262 #[test]
2263 fn constant_time_eq_matches_equality() {
2264 assert!(constant_time_eq(b"secret-token", b"secret-token"));
2265 assert!(constant_time_eq(b"", b""));
2266 assert!(!constant_time_eq(b"secret-token", b"secret-toke")); assert!(!constant_time_eq(b"secret-token", b"secret-tokex")); assert!(!constant_time_eq(b"secret-token", b"Xecret-token")); assert!(!constant_time_eq(b"", b"x"));
2270 }
2271
2272 #[test]
2273 fn depth_defaults_to_one_when_absent() {
2274 assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2275 }
2276
2277 #[test]
2278 fn depth_within_cap_is_accepted() {
2279 assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2280 assert_eq!(
2281 resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2282 MAX_NEIGHBORHOOD_DEPTH
2283 );
2284 }
2285
2286 #[test]
2287 fn depth_over_cap_is_rejected() {
2288 assert!(resolve_neighborhood_depth(Some("65")).is_err());
2289 assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2290 }
2291
2292 #[test]
2293 fn depth_non_integer_is_rejected() {
2294 assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2295 }
2296
2297 #[test]
2298 fn backup_dest_rejects_parent_traversal() {
2299 let root = std::path::Path::new("/srv/backups");
2300 assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2301 assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2302 }
2303
2304 #[test]
2305 fn backup_dest_rejects_absolute_outside_root() {
2306 let root = std::path::Path::new("/srv/backups");
2307 assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2308 assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2309 }
2310
2311 #[test]
2312 fn backup_dest_allows_relative_within_root() {
2313 let root = std::path::Path::new("/srv/backups");
2314 assert_eq!(
2315 confine_backup_dest("nightly", root).unwrap(),
2316 PathBuf::from("/srv/backups/nightly")
2317 );
2318 assert_eq!(
2319 confine_backup_dest("2026/aug", root).unwrap(),
2320 PathBuf::from("/srv/backups/2026/aug")
2321 );
2322 }
2323
2324 #[test]
2325 fn backup_dest_allows_absolute_within_root() {
2326 let root = std::path::Path::new("/srv/backups");
2327 assert_eq!(
2328 confine_backup_dest("/srv/backups/x", root).unwrap(),
2329 PathBuf::from("/srv/backups/x")
2330 );
2331 }
2332}