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 busy_response(detail: String) -> Response {
677 (
678 StatusCode::SERVICE_UNAVAILABLE,
679 [(header::RETRY_AFTER, HeaderValue::from_static("1"))],
680 Json(json!({ "error": detail })),
681 )
682 .into_response()
683}
684
685fn graph_err(e: GraphError) -> Response {
686 match e {
687 GraphError::RoleWriteDenied { reason } => forbidden(&reason),
690 GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
691 err_response(detail)
692 }
693 busy @ GraphError::Busy { .. } => busy_response(busy.to_string()),
695 other => err_response(other.to_string()),
696 }
697}
698
699fn key_not_found(key: String) -> Response {
700 (
701 StatusCode::NOT_FOUND,
702 Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
703 )
704 .into_response()
705}
706
707fn conflict_response(key: String) -> Response {
708 (
709 StatusCode::CONFLICT,
710 Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
711 )
712 .into_response()
713}
714
715fn json_ok(value: Js) -> Response {
716 (StatusCode::OK, Json(value)).into_response()
717}
718
719fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
720 let Some(v) = v else {
721 return Ok(IngestOptions::default());
722 };
723 if v.is_null() {
724 return Ok(IngestOptions::default());
725 }
726 let obj = v
727 .as_object()
728 .ok_or_else(|| "options must be an object".to_string())?;
729 let mut opts = IngestOptions::default();
730 if let Some(kf) = obj.get("key_field") {
731 opts.key_field = kf
732 .as_str()
733 .ok_or_else(|| "options.key_field must be a string".to_string())?
734 .to_string();
735 }
736 if let Some(fk) = obj.get("auto_fk") {
737 if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
738 opts.auto_fk = AutoFk::Off;
739 } else if let Some(m) = fk.as_object() {
740 let suf = m
741 .get("suffix")
742 .and_then(Js::as_str)
743 .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
744 opts.auto_fk = AutoFk::Auto {
745 suffix: suf.to_string(),
746 };
747 } else {
748 return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
749 }
750 }
751 Ok(opts)
752}
753
754fn format_query_result(rs: ResultSet, format: &str) -> Response {
756 match format {
757 "" => match to_ipc_bytes(&rs) {
758 Ok(bytes) => (
759 StatusCode::OK,
760 [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
761 bytes,
762 )
763 .into_response(),
764 Err(e) => err_response(e),
765 },
766 "json" => json_ok(result_set_json(&rs)),
767 other => err_response(format!("unknown format: {other}")),
768 }
769}
770
771async fn query(
772 State(state): State<AppState>,
773 Extension(identity): Extension<AuthIdentity>,
774 Query(qs): Query<BTreeMap<String, String>>,
775 Json(body): Json<Js>,
776) -> Response {
777 let cypher = match body.get("cypher").and_then(Js::as_str) {
778 Some(s) => s.to_string(),
779 None => return err_response("missing cypher"),
780 };
781 let params = match params_from_json(body.get("params")) {
782 Ok(p) => p,
783 Err(e) => return err_response(e),
784 };
785 let format = qs.get("format").map(String::as_str).unwrap_or("");
786
787 let as_of = match body.get("as_of") {
790 None | Some(Js::Null) => None,
791 Some(v) => match v.as_u64() {
792 Some(n) => Some(n),
793 None => return err_response("as_of must be a non-negative integer commit index"),
794 },
795 };
796
797 let mask_keys: Option<Vec<String>> = match body.get("mask") {
799 None | Some(Js::Null) => None,
800 Some(Js::Array(arr)) => {
801 let mut keys = Vec::with_capacity(arr.len());
802 for v in arr {
803 match v.as_str() {
804 Some(s) => keys.push(s.to_string()),
805 None => return err_response("mask must be an array of strings"),
806 }
807 }
808 Some(keys)
809 }
810 Some(_) => return err_response("mask must be an array of strings"),
811 };
812
813 if as_of.is_some() && (matches!(identity, AuthIdentity::Role(_)) || mask_keys.is_some()) {
816 return err_response(
817 "as_of (time-travel) is not yet supported with role tokens or a client mask",
818 );
819 }
820
821 if let AuthIdentity::Role(ref role_name) = identity {
825 let is_write = match is_write_query(&cypher) {
826 Ok(b) => b,
827 Err(e) => return err_response(e),
828 };
829 if is_write {
830 let role = role_name.clone();
831 let cypher_c = cypher.clone();
832 let params_c = params.clone();
833 let db = state.db.clone();
834 return match blocking_write(move || {
835 db.write().query_write_authz(&role, &cypher_c, ¶ms_c)
836 })
837 .await
838 {
839 Ok(rs) => format_query_result(rs, format),
840 Err(resp) => resp,
841 };
842 }
843 let snap = state.db.reader();
844 let role_mask = match snap.mask_for_role(role_name) {
845 Ok(m) => m,
846 Err(e) => return role_mask_err(e),
847 };
848 let effective_mask = if let Some(ref keys) = mask_keys {
849 let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
851 role_mask.intersect(&client_mask)
852 } else {
853 role_mask
854 };
855 return match snap.query_masked(&cypher, ¶ms, &effective_mask) {
856 Ok(rs) => format_query_result(rs, format),
857 Err(GraphError::MaskedReadOnly) => (
858 StatusCode::BAD_REQUEST,
859 Json(json!({"error": "masked queries are read-only"})),
860 )
861 .into_response(),
862 Err(e) => graph_err(e),
863 };
864 }
865
866 if let Some(ref keys) = mask_keys {
876 let stub_hidden = body
877 .get("stub_hidden")
878 .and_then(|v| v.as_bool())
879 .unwrap_or(false);
880 let db = state.db.read();
881 let mask = {
882 let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
883 if stub_hidden {
884 m.with_mode(MaskMode::Stub)
885 } else {
886 m
887 }
888 };
889 return match db.query_masked(&cypher, ¶ms, &mask) {
890 Ok(rs) => format_query_result(rs, format),
891 Err(GraphError::MaskedReadOnly) => (
892 StatusCode::BAD_REQUEST,
893 Json(json!({"error": "masked queries are read-only"})),
894 )
895 .into_response(),
896 Err(e) => graph_err(e),
897 };
898 }
899
900 let is_write = match is_write_query(&cypher) {
905 Ok(b) => b,
906 Err(e) => return err_response(e),
907 };
908
909 if as_of.is_some() && is_write {
910 return err_response("as_of (time-travel) queries are read-only");
911 }
912
913 let rs = if is_write {
914 let db = state.db.clone();
915 match blocking_write(move || db.write().query_write(&cypher, ¶ms)).await {
916 Ok(rs) => rs,
917 Err(resp) => return resp,
918 }
919 } else if let Some(commit) = as_of {
920 match state.db.read().query_at(commit, &cypher, ¶ms) {
921 Ok(rs) => rs,
922 Err(e) => return graph_err(e),
923 }
924 } else {
925 match state.db.read().query(&cypher, ¶ms) {
926 Ok(rs) => rs,
927 Err(e) => return graph_err(e),
928 }
929 };
930
931 format_query_result(rs, format)
932}
933
934async fn stats(
935 State(state): State<AppState>,
936 Extension(identity): Extension<AuthIdentity>,
937) -> Response {
938 if let AuthIdentity::Role(_) = identity {
940 return forbidden("role-bound token: /stats requires a full-access token");
941 }
942 let snap = {
943 let g = state.db.read();
944 g.stats()
945 };
946 match serde_json::to_value(&snap) {
947 Ok(v) => json_ok(v),
948 Err(e) => err_response(e.to_string()),
949 }
950}
951
952async fn metrics(
953 State(state): State<AppState>,
954 Extension(identity): Extension<AuthIdentity>,
955) -> Response {
956 if let AuthIdentity::Role(_) = identity {
958 return forbidden("role-bound token: /metrics requires a full-access token");
959 }
960 let (s, commit_seq, wal_size_bytes, slow_snap) = {
961 let g = state.db.read();
962 let s = g.stats();
963 let commit_seq = g.commit_seq();
964 let wal_size_bytes = g.wal_size_bytes().ok();
965 let slow_snap = g.slow_query_snapshot();
966 (s, commit_seq, wal_size_bytes, slow_snap)
967 };
968 let uptime_s = state.started_at.elapsed().as_secs();
969 let slow_entries: Vec<Js> = slow_snap
970 .last
971 .iter()
972 .map(|e| {
973 json!({
974 "ms": e.ms,
975 "query": e.query,
976 "at_commit": e.at_commit,
977 })
978 })
979 .collect();
980 json_ok(json!({
981 "nodes_live": s.nodes_live,
982 "nodes_tombstoned": s.nodes_tombstoned,
983 "edges": s.edges,
984 "commit_seq": commit_seq,
985 "wal_size_bytes": wal_size_bytes,
986 "rss_bytes": rss_bytes(),
987 "uptime_s": uptime_s,
988 "slow_queries": {
989 "threshold_ms": slow_snap.threshold_ms,
990 "count": slow_snap.count,
991 "last": slow_entries,
992 },
993 }))
994}
995
996fn rss_bytes() -> Option<u64> {
1004 #[cfg(target_os = "macos")]
1005 {
1006 const MACH_TASK_BASIC_INFO: u32 = 20;
1011 const MACH_TASK_BASIC_INFO_COUNT: u32 = 12;
1012
1013 #[repr(C)]
1014 struct MachTaskBasicInfo {
1015 virtual_size: u64,
1016 resident_size: u64,
1017 resident_size_max: u64,
1018 user_time: [u32; 2],
1019 system_time: [u32; 2],
1020 policy: i32,
1021 suspend_count: i32,
1022 }
1023
1024 extern "C" {
1025 fn mach_task_self() -> u32;
1026 fn task_info(
1027 target_task: u32,
1028 flavor: u32,
1029 task_info_out: *mut std::ffi::c_void,
1030 task_info_cnt: *mut u32,
1031 ) -> i32;
1032 }
1033
1034 let mut info: MachTaskBasicInfo = unsafe { std::mem::zeroed() };
1035 let mut count = MACH_TASK_BASIC_INFO_COUNT;
1036 let ret = unsafe {
1037 task_info(
1038 mach_task_self(),
1039 MACH_TASK_BASIC_INFO,
1040 &mut info as *mut _ as *mut _,
1041 &mut count,
1042 )
1043 };
1044 if ret != 0 {
1045 return None; }
1047 return Some(info.resident_size);
1048 }
1049
1050 #[cfg(target_os = "linux")]
1051 {
1052 let content = std::fs::read_to_string("/proc/self/statm").ok()?;
1054 let mut parts = content.split_whitespace();
1055 let _vsize = parts.next()?;
1056 let rss_pages: u64 = parts.next()?.parse().ok()?;
1057 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1058 if page_size <= 0 {
1059 return None;
1060 }
1061 return Some(rss_pages * page_size as u64);
1062 }
1063
1064 #[allow(unreachable_code)]
1065 None
1066}
1067
1068async fn ingest(
1069 State(state): State<AppState>,
1070 Extension(identity): Extension<AuthIdentity>,
1071 Json(body): Json<Js>,
1072) -> Response {
1073 let label = match body.get("label").and_then(Js::as_str) {
1074 Some(s) => s.to_string(),
1075 None => return err_response("missing label"),
1076 };
1077 let rows = match body.get("rows") {
1078 Some(r) => r,
1079 None => return err_response("missing rows"),
1080 };
1081 let mut converted = match json_to_rows(rows) {
1082 Ok(c) => c,
1083 Err(e) => return graph_err(e),
1084 };
1085 let opts = match ingest_options(body.get("options")) {
1086 Ok(o) => o,
1087 Err(e) => return err_response(e),
1088 };
1089 let taken = std::mem::take(&mut converted.rows);
1090 let edges = match body.get("edges") {
1091 None | Some(Js::Null) => Vec::new(),
1092 Some(raw) => match parse_ingest_edges(raw) {
1093 Ok(e) => e,
1094 Err(e) => return err_response(e),
1095 },
1096 };
1097 let db = state.db.clone();
1098
1099 if let AuthIdentity::Role(role_name) = &identity {
1103 let role = role_name.clone();
1104 return match blocking_write(move || {
1105 db.write()
1106 .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
1107 })
1108 .await
1109 {
1110 Ok(r) => {
1111 let report = converted.into_report(r);
1112 match serde_json::to_value(&report) {
1113 Ok(v) => json_ok(v),
1114 Err(e) => err_response(e.to_string()),
1115 }
1116 }
1117 Err(resp) => resp,
1118 };
1119 }
1120
1121 let report =
1122 match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
1123 .await
1124 {
1125 Ok(r) => converted.into_report(r),
1126 Err(resp) => return resp,
1127 };
1128 match serde_json::to_value(&report) {
1129 Ok(v) => json_ok(v),
1130 Err(e) => err_response(e.to_string()),
1131 }
1132}
1133
1134async fn suggest(
1145 State(state): State<AppState>,
1146 Extension(identity): Extension<AuthIdentity>,
1147) -> Response {
1148 if let AuthIdentity::Role(_) = identity {
1151 return forbidden("role-bound token: /suggest requires a full-access token");
1152 }
1153 let db = state.db.clone();
1154 match tokio::task::spawn_blocking(move || {
1155 let config = SuggestConfig::default();
1156 db.read()
1157 .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1158 })
1159 .await
1160 {
1161 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1162 Err(_) => err_response("suggest task panicked"),
1163 }
1164}
1165
1166async fn create_rule(
1167 State(state): State<AppState>,
1168 Extension(identity): Extension<AuthIdentity>,
1169 Json(body): Json<Js>,
1170) -> Response {
1171 if let AuthIdentity::Role(_) = identity {
1172 return forbidden("role-bound token: writes are not permitted");
1173 }
1174 let def = match rule_def_from_json(body) {
1175 Ok(d) => d,
1176 Err(e) => return err_response(e),
1177 };
1178 let name = def.name.clone();
1179 let db = state.db.clone();
1180 match blocking_write(move || db.write().create_rule(def)).await {
1181 Ok(()) => json_ok(json!({"ok": true, "name": name})),
1182 Err(resp) => resp,
1183 }
1184}
1185
1186async fn explain(
1187 State(state): State<AppState>,
1188 Extension(identity): Extension<AuthIdentity>,
1189 Query(qs): Query<BTreeMap<String, String>>,
1190) -> Response {
1191 if let AuthIdentity::Role(_) = identity {
1193 return forbidden(
1194 "role-bound token: /explain requires a full-access token \
1195 (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1196 );
1197 }
1198 let a = match qs.get("a") {
1199 Some(s) if !s.is_empty() => s.clone(),
1200 _ => return err_response("missing query param a"),
1201 };
1202 let b = match qs.get("b") {
1203 Some(s) if !s.is_empty() => s.clone(),
1204 _ => return err_response("missing query param b"),
1205 };
1206 let out = {
1207 let g = state.db.read();
1208 g.explain(&a, &b)
1209 };
1210 match out {
1211 Ok(v) => match serde_json::to_value(&v) {
1212 Ok(j) => json_ok(j),
1213 Err(e) => err_response(e.to_string()),
1214 },
1215 Err(e) => graph_err(e),
1216 }
1217}
1218
1219async fn node_info(
1220 State(state): State<AppState>,
1221 Extension(identity): Extension<AuthIdentity>,
1222 Path(key): Path<String>,
1223 Query(qs): Query<BTreeMap<String, String>>,
1224) -> Response {
1225 if let AuthIdentity::Role(ref role_name) = identity {
1226 let snap = state.db.reader();
1230 let role_mask = match snap.mask_for_role(role_name) {
1231 Ok(m) => m,
1232 Err(e) => return role_mask_err(e),
1233 };
1234 if !snap
1235 .resolve_key(&key)
1236 .is_some_and(|id| role_mask.contains_id(id))
1237 {
1238 return key_not_found(key);
1239 }
1240 return match snap.node_info(&key) {
1241 Some(info) => json_ok(node_info_json(&info)),
1242 None => key_not_found(key),
1243 };
1244 }
1245
1246 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1250 if !mask_param.is_empty() {
1251 let stub_hidden = qs
1252 .get("stub_hidden")
1253 .map(|v| v == "true" || v == "1")
1254 .unwrap_or(false);
1255 let g = state.db.read();
1256 let mask = {
1257 let keys = mask_param
1258 .split(',')
1259 .map(str::trim)
1260 .filter(|s| !s.is_empty());
1261 let m = NodeMask::from_keys(&*g, keys);
1262 if stub_hidden {
1263 m.with_mode(MaskMode::Stub)
1264 } else {
1265 m
1266 }
1267 };
1268 return match g.node_info_masked(&key, &mask) {
1269 Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1270 Some(core_api::MaskedNodeResult::Restricted) => {
1271 json_ok(crate::json::stub_node_json(&key))
1272 }
1273 None => key_not_found(key),
1274 };
1275 }
1276
1277 let info = {
1278 let g = state.db.read();
1279 g.node_info(&key)
1280 };
1281 match info {
1282 Some(info) => json_ok(node_info_json(&info)),
1283 None => key_not_found(key),
1284 }
1285}
1286
1287async fn node_edges(
1288 State(state): State<AppState>,
1289 Extension(identity): Extension<AuthIdentity>,
1290 Path(key): Path<String>,
1291 Query(qs): Query<BTreeMap<String, String>>,
1292) -> Response {
1293 if let AuthIdentity::Role(ref role_name) = identity {
1294 let snap = state.db.reader();
1297 let role_mask = match snap.mask_for_role(role_name) {
1298 Ok(m) => m,
1299 Err(e) => return role_mask_err(e),
1300 };
1301 if !snap
1302 .resolve_key(&key)
1303 .is_some_and(|id| role_mask.contains_id(id))
1304 {
1305 return key_not_found(key);
1306 }
1307 return match snap.node_edges(&key) {
1308 Ok(edges) => {
1309 let visible: Vec<_> = edges
1313 .into_iter()
1314 .filter(|e| {
1315 let other = if e.src_key == key {
1316 &e.dst_key
1317 } else {
1318 &e.src_key
1319 };
1320 snap.resolve_key(other)
1321 .is_some_and(|id| role_mask.contains_id(id))
1322 })
1323 .collect();
1324 json_ok(node_edges_json(&visible))
1325 }
1326 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1327 Err(e) => graph_err(e),
1328 };
1329 }
1330
1331 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1333 if !mask_param.is_empty() {
1334 let stub_hidden = qs
1335 .get("stub_hidden")
1336 .map(|v| v == "true" || v == "1")
1337 .unwrap_or(false);
1338 let g = state.db.read();
1339 let mask = {
1340 let keys = mask_param
1341 .split(',')
1342 .map(str::trim)
1343 .filter(|s| !s.is_empty());
1344 let m = NodeMask::from_keys(&*g, keys);
1345 if stub_hidden {
1346 m.with_mode(MaskMode::Stub)
1347 } else {
1348 m
1349 }
1350 };
1351 return match g.node_edges_masked(&key, &mask) {
1352 Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1353 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1354 Err(e) => graph_err(e),
1355 };
1356 }
1357
1358 let out = {
1359 let g = state.db.read();
1360 g.node_edges(&key)
1361 };
1362 match out {
1363 Ok(edges) => json_ok(node_edges_json(&edges)),
1364 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1365 Err(e) => graph_err(e),
1366 }
1367}
1368
1369async fn neighborhood(
1370 State(state): State<AppState>,
1371 Extension(identity): Extension<AuthIdentity>,
1372 Path(key): Path<String>,
1373 Query(qs): Query<BTreeMap<String, String>>,
1374) -> Response {
1375 let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1376 Ok(d) => d,
1377 Err(e) => return err_response(e),
1378 };
1379 let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1380 s if s.eq_ignore_ascii_case("out") => Dir::Out,
1381 s if s.eq_ignore_ascii_case("in") => Dir::In,
1382 s if s.eq_ignore_ascii_case("both") => Dir::Both,
1383 other => return err_response(format!("unknown dir: {other}")),
1384 };
1385 let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1386 s.split(',')
1387 .map(str::trim)
1388 .filter(|t| !t.is_empty())
1389 .map(str::to_string)
1390 .collect()
1391 });
1392 let etype_refs: Option<Vec<&str>> = edge_type_names
1393 .as_ref()
1394 .map(|v| v.iter().map(String::as_str).collect());
1395 if let AuthIdentity::Role(ref role_name) = identity {
1396 let snap = state.db.reader();
1399 let role_mask = match snap.mask_for_role(role_name) {
1400 Ok(m) => m,
1401 Err(e) => return role_mask_err(e),
1402 };
1403 if !snap
1404 .resolve_key(&key)
1405 .is_some_and(|id| role_mask.contains_id(id))
1406 {
1407 return key_not_found(key);
1408 }
1409 let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1412 {
1413 Some(rs) => rs,
1414 None => return key_not_found(key),
1415 };
1416 return json_ok(result_set_json(&rs));
1417 }
1418 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1424 if !mask_param.is_empty() {
1425 let stub_hidden = qs
1426 .get("stub_hidden")
1427 .map(|v| v == "true" || v == "1")
1428 .unwrap_or(false);
1429 let g = state.db.read();
1430 let mask = {
1431 let keys = mask_param
1432 .split(',')
1433 .map(str::trim)
1434 .filter(|s| !s.is_empty());
1435 let m = NodeMask::from_keys(&*g, keys);
1436 if stub_hidden {
1437 m.with_mode(MaskMode::Stub)
1438 } else {
1439 m
1440 }
1441 };
1442 return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1443 Some(rs) => json_ok(result_set_json(&rs)),
1444 None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1445 };
1446 }
1447
1448 let rs = {
1450 let g = state.db.read();
1451 match g.node_ref(&key) {
1452 Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1453 None => Err(GraphError::KeyNotFound { key: key.clone() }),
1454 }
1455 };
1456 match rs {
1457 Ok(rs) => json_ok(result_set_json(&rs)),
1458 Err(e) => graph_err(e),
1459 }
1460}
1461
1462async fn algo_pagerank(
1471 State(state): State<AppState>,
1472 Extension(identity): Extension<AuthIdentity>,
1473 Json(body): Json<serde_json::Value>,
1474) -> Response {
1475 if let AuthIdentity::Role(_) = identity {
1477 return forbidden("role-bound token: /algo/* requires a full-access token");
1478 }
1479 let config: PageRankConfig = match serde_json::from_value(body) {
1480 Ok(c) => c,
1481 Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1482 };
1483 let db = state.db.clone();
1484 match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1485 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1486 Err(_) => err_response("pagerank task panicked"),
1487 }
1488}
1489
1490async fn algo_wcc(
1495 State(state): State<AppState>,
1496 Extension(identity): Extension<AuthIdentity>,
1497 Json(body): Json<serde_json::Value>,
1498) -> Response {
1499 if let AuthIdentity::Role(_) = identity {
1500 return forbidden("role-bound token: /algo/* requires a full-access token");
1501 }
1502 let config: WccConfig = match serde_json::from_value(body) {
1503 Ok(c) => c,
1504 Err(e) => return err_response(format!("invalid wcc config: {e}")),
1505 };
1506 let db = state.db.clone();
1507 match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1508 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1509 Err(_) => err_response("wcc task panicked"),
1510 }
1511}
1512
1513async fn algo_degree(
1517 State(state): State<AppState>,
1518 Extension(identity): Extension<AuthIdentity>,
1519 Json(body): Json<serde_json::Value>,
1520) -> Response {
1521 if let AuthIdentity::Role(_) = identity {
1522 return forbidden("role-bound token: /algo/* requires a full-access token");
1523 }
1524 let config: DegreeConfig = match serde_json::from_value(body) {
1525 Ok(c) => c,
1526 Err(e) => return err_response(format!("invalid degree config: {e}")),
1527 };
1528 let db = state.db.clone();
1529 match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1530 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1531 Err(_) => err_response("degree task panicked"),
1532 }
1533}
1534
1535fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1548 let obj = match v.as_object() {
1549 Some(o) => o,
1550 None => return Err("props must be a JSON object".into()),
1551 };
1552 let mut out = Vec::with_capacity(obj.len());
1553 for (k, val) in obj {
1554 if let Some(v) = json_to_value(val.clone()) {
1555 out.push((k.clone(), v));
1556 }
1557 }
1558 Ok(out)
1559}
1560
1561async fn create_node(
1565 State(state): State<AppState>,
1566 Extension(identity): Extension<AuthIdentity>,
1567 Json(body): Json<Js>,
1568) -> Response {
1569 let label = match body.get("label").and_then(Js::as_str) {
1570 Some(s) => s.to_string(),
1571 None => return err_response("missing label"),
1572 };
1573 let key = match body.get("key").and_then(Js::as_str) {
1574 Some(s) => s.to_string(),
1575 None => return err_response("missing key"),
1576 };
1577 let props = match body.get("props") {
1578 None | Some(Js::Null) => vec![],
1579 Some(v) => match props_from_json_obj(v) {
1580 Ok(p) => p,
1581 Err(e) => return err_response(e),
1582 },
1583 };
1584 let db = state.db.clone();
1585 if let AuthIdentity::Role(role_name) = &identity {
1586 let role = role_name.clone();
1587 return match blocking_write(move || {
1588 db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1589 })
1590 .await
1591 {
1592 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1593 Err(resp) => resp,
1594 };
1595 }
1596 match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1597 .await
1598 {
1599 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1600 Err(resp) => resp,
1601 }
1602}
1603
1604async fn delete_node(
1606 State(state): State<AppState>,
1607 Extension(identity): Extension<AuthIdentity>,
1608 Path(key): Path<String>,
1609) -> Response {
1610 let db = state.db.clone();
1611 if let AuthIdentity::Role(role_name) = &identity {
1612 let role = role_name.clone();
1613 return match blocking_write(move || {
1614 db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1615 })
1616 .await
1617 {
1618 Ok(_) => json_ok(json!({"ok": true})),
1619 Err(resp) => resp,
1620 };
1621 }
1622 match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1623 Ok(_) => json_ok(json!({"ok": true})),
1624 Err(resp) => resp,
1625 }
1626}
1627
1628async fn create_edge(
1632 State(state): State<AppState>,
1633 Extension(identity): Extension<AuthIdentity>,
1634 Json(body): Json<Js>,
1635) -> Response {
1636 let edge_type = match body.get("type").and_then(Js::as_str) {
1637 Some(s) => s.to_string(),
1638 None => return err_response("missing type"),
1639 };
1640 let src = match body.get("src").and_then(Js::as_str) {
1641 Some(s) => s.to_string(),
1642 None => return err_response("missing src"),
1643 };
1644 let dst = match body.get("dst").and_then(Js::as_str) {
1645 Some(s) => s.to_string(),
1646 None => return err_response("missing dst"),
1647 };
1648 let db = state.db.clone();
1649 if let AuthIdentity::Role(role_name) = &identity {
1650 let role = role_name.clone();
1651 return match blocking_write(move || {
1652 db.submit_batch_authz(
1653 role,
1654 vec![BatchOp::InsertEdge {
1655 edge_type,
1656 src_key: src,
1657 dst_key: dst,
1658 }],
1659 )
1660 })
1661 .await
1662 {
1663 Ok(_) => json_ok(json!({"ok": true})),
1664 Err(resp) => resp,
1665 };
1666 }
1667 match blocking_write(move || {
1668 db.submit_batch(vec![BatchOp::InsertEdge {
1669 edge_type,
1670 src_key: src,
1671 dst_key: dst,
1672 }])
1673 })
1674 .await
1675 {
1676 Ok(_) => json_ok(json!({"ok": true})),
1677 Err(resp) => resp,
1678 }
1679}
1680
1681async fn delete_edge(
1683 State(state): State<AppState>,
1684 Extension(identity): Extension<AuthIdentity>,
1685 Path((etype, src, dst)): Path<(String, String, String)>,
1686) -> Response {
1687 let db = state.db.clone();
1688 if let AuthIdentity::Role(role_name) = &identity {
1689 let role = role_name.clone();
1690 return match blocking_write(move || {
1691 db.submit_batch_authz(
1692 role,
1693 vec![BatchOp::DeleteEdge {
1694 edge_type: etype,
1695 src_key: src,
1696 dst_key: dst,
1697 }],
1698 )
1699 })
1700 .await
1701 {
1702 Ok(_) => json_ok(json!({"ok": true})),
1703 Err(resp) => resp,
1704 };
1705 }
1706 match blocking_write(move || {
1707 db.submit_batch(vec![BatchOp::DeleteEdge {
1708 edge_type: etype,
1709 src_key: src,
1710 dst_key: dst,
1711 }])
1712 })
1713 .await
1714 {
1715 Ok(_) => json_ok(json!({"ok": true})),
1716 Err(resp) => resp,
1717 }
1718}
1719
1720async fn rename_node(
1725 State(state): State<AppState>,
1726 Extension(identity): Extension<AuthIdentity>,
1727 Path(key): Path<String>,
1728 Json(body): Json<Js>,
1729) -> Response {
1730 if let AuthIdentity::Role(_) = identity {
1731 return forbidden("role-bound token: writes are not permitted");
1732 }
1733 let new_key = match body.get("new_key").and_then(Js::as_str) {
1734 Some(s) => s.to_string(),
1735 None => return err_response("missing new_key"),
1736 };
1737 let db = state.db.clone();
1738 match tokio::task::spawn_blocking(move || {
1739 db.submit_batch(vec![BatchOp::RenameNode {
1740 old_key: key,
1741 new_key,
1742 }])
1743 })
1744 .await
1745 {
1746 Ok(Ok(_)) => json_ok(json!({"ok": true})),
1747 Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1748 Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1749 Ok(Err(e)) => graph_err(e),
1750 Err(_) => err_response("write task panicked"),
1751 }
1752}
1753
1754async fn upsert_edge(
1759 State(state): State<AppState>,
1760 Extension(identity): Extension<AuthIdentity>,
1761 Json(body): Json<Js>,
1762) -> Response {
1763 let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1764 Some(s) => s.to_string(),
1765 None => return err_response("missing edge_type"),
1766 };
1767 let src_key = match body.get("src_key").and_then(Js::as_str) {
1768 Some(s) => s.to_string(),
1769 None => return err_response("missing src_key"),
1770 };
1771 let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1772 Some(s) => s.to_string(),
1773 None => return err_response("missing dst_key"),
1774 };
1775 let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1776 Some(s) => s.to_string(),
1777 None => return err_response("missing placeholder_label"),
1778 };
1779 let db = state.db.clone();
1780 if let AuthIdentity::Role(role_name) = &identity {
1781 let role = role_name.clone();
1782 return match blocking_write(move || {
1783 db.submit_batch_authz(
1784 role,
1785 vec![BatchOp::InsertEdgeUpsert {
1786 edge_type,
1787 src_key,
1788 dst_key,
1789 placeholder_label,
1790 }],
1791 )
1792 })
1793 .await
1794 {
1795 Ok((nodes, edges)) => json_ok(json!({
1796 "nodes_created": nodes,
1797 "edge_inserted": edges > 0,
1798 })),
1799 Err(resp) => resp,
1800 };
1801 }
1802 match blocking_write(move || {
1803 db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1804 edge_type,
1805 src_key,
1806 dst_key,
1807 placeholder_label,
1808 }])
1809 })
1810 .await
1811 {
1812 Ok((nodes, edges)) => json_ok(json!({
1813 "nodes_created": nodes,
1814 "edge_inserted": edges > 0,
1815 })),
1816 Err(resp) => resp,
1817 }
1818}
1819
1820async fn set_node_prop(
1824 State(state): State<AppState>,
1825 Extension(identity): Extension<AuthIdentity>,
1826 Path((key, field)): Path<(String, String)>,
1827 Json(body): Json<Js>,
1828) -> Response {
1829 let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1830 Some(v) => v,
1831 None => {
1832 return err_response(
1833 "request body must be a JSON object with a \"value\" field, \
1834 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
1835 )
1836 }
1837 };
1838 let db = state.db.clone();
1839 if let AuthIdentity::Role(role_name) = &identity {
1840 let role = role_name.clone();
1841 return match blocking_write(move || {
1842 db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1843 })
1844 .await
1845 {
1846 Ok(_) => json_ok(json!({"ok": true})),
1847 Err(resp) => resp,
1848 };
1849 }
1850 match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1851 .await
1852 {
1853 Ok(_) => json_ok(json!({"ok": true})),
1854 Err(resp) => resp,
1855 }
1856}
1857
1858async fn node_history_handler(
1874 State(state): State<AppState>,
1875 Extension(identity): Extension<AuthIdentity>,
1876 Path(key): Path<String>,
1877) -> Response {
1878 if let AuthIdentity::Role(ref role_name) = identity {
1879 let g = state.db.read();
1880 let role_mask = match g.mask_for_role(role_name) {
1881 Ok(m) => m,
1882 Err(e) => return role_mask_err(e),
1883 };
1884 if !role_mask.contains_node(&*g, &key) {
1886 return key_not_found(key);
1887 }
1888 let entries = match g.node_history(&key) {
1889 Ok(e) => e,
1890 Err(e) => return graph_err(e),
1891 };
1892 let total_commits = match g.wal_total_commits() {
1893 Ok(n) => n,
1894 Err(e) => return graph_err(e),
1895 };
1896 use core_api::HistoryChange;
1900 let visible: Vec<_> = entries
1901 .into_iter()
1902 .filter(|entry| match &entry.change {
1903 HistoryChange::EdgeAdded { other, .. }
1904 | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1905 _ => true,
1906 })
1907 .collect();
1908 return json_ok(node_history_json(&key, &visible, total_commits));
1909 }
1910 let g = state.db.read();
1913 if !g.has_node(&key) {
1914 return key_not_found(key);
1915 }
1916 let entries = match g.node_history(&key) {
1917 Ok(e) => e,
1918 Err(e) => return graph_err(e),
1919 };
1920 let total_commits = match g.wal_total_commits() {
1921 Ok(n) => n,
1922 Err(e) => return graph_err(e),
1923 };
1924 json_ok(node_history_json(&key, &entries, total_commits))
1925}
1926
1927async fn edge_history_handler(
1933 State(state): State<AppState>,
1934 Extension(identity): Extension<AuthIdentity>,
1935 Query(qs): Query<BTreeMap<String, String>>,
1936) -> Response {
1937 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1938 Some(s) => s.clone(),
1939 None => return err_response("missing query param a"),
1940 };
1941 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1942 Some(s) => s.clone(),
1943 None => return err_response("missing query param b"),
1944 };
1945 if let AuthIdentity::Role(ref role_name) = identity {
1946 let g = state.db.read();
1947 let role_mask = match g.mask_for_role(role_name) {
1948 Ok(m) => m,
1949 Err(e) => return role_mask_err(e),
1950 };
1951 if !role_mask.contains_node(&*g, &a) {
1953 return key_not_found(a);
1954 }
1955 if !role_mask.contains_node(&*g, &b) {
1956 return key_not_found(b);
1957 }
1958 let result = match g.edge_history(&a, &b) {
1959 Ok(r) => r,
1960 Err(e) => return graph_err(e),
1961 };
1962 return json_ok(edge_history_result_json(&a, &b, &result));
1963 }
1964 let g = state.db.read();
1966 let result = match g.edge_history(&a, &b) {
1967 Ok(r) => r,
1968 Err(e) => return graph_err(e),
1969 };
1970 json_ok(edge_history_result_json(&a, &b, &result))
1971}
1972
1973async fn was_linked_handler(
1979 State(state): State<AppState>,
1980 Extension(identity): Extension<AuthIdentity>,
1981 Query(qs): Query<BTreeMap<String, String>>,
1982) -> Response {
1983 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1984 Some(s) => s.clone(),
1985 None => return err_response("missing query param a"),
1986 };
1987 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1988 Some(s) => s.clone(),
1989 None => return err_response("missing query param b"),
1990 };
1991 let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1992 Some(s) => s.clone(),
1993 None => return err_response("missing query param edge_type"),
1994 };
1995 let at_commit: u64 = match qs.get("at_commit") {
1996 Some(s) => match s.parse() {
1997 Ok(n) => n,
1998 Err(_) => return err_response("at_commit must be a non-negative integer"),
1999 },
2000 None => return err_response("missing query param at_commit"),
2001 };
2002
2003 if let AuthIdentity::Role(ref role_name) = identity {
2004 let g = state.db.read();
2005 let role_mask = match g.mask_for_role(role_name) {
2006 Ok(m) => m,
2007 Err(e) => return role_mask_err(e),
2008 };
2009 if !role_mask.contains_node(&*g, &a) {
2010 return key_not_found(a);
2011 }
2012 if !role_mask.contains_node(&*g, &b) {
2013 return key_not_found(b);
2014 }
2015 return 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
2029 let g = state.db.read();
2031 match g.was_linked(&a, &b, &edge_type, at_commit) {
2032 Ok(linked) => json_ok(json!({
2033 "a": a, "b": b, "edge_type": edge_type,
2034 "at_commit": at_commit, "linked": linked,
2035 })),
2036 Err(GraphError::CommitOutOfRange { .. }) => (
2037 StatusCode::BAD_REQUEST,
2038 Json(json!({"error": format!("commit {at_commit} is out of range")})),
2039 )
2040 .into_response(),
2041 Err(e) => graph_err(e),
2042 }
2043}
2044
2045async fn remove_node_prop(
2047 State(state): State<AppState>,
2048 Extension(identity): Extension<AuthIdentity>,
2049 Path((key, field)): Path<(String, String)>,
2050) -> Response {
2051 let db = state.db.clone();
2052 if let AuthIdentity::Role(role_name) = &identity {
2053 let role = role_name.clone();
2054 return match blocking_write(move || {
2055 db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
2056 })
2057 .await
2058 {
2059 Ok(_) => json_ok(json!({"ok": true})),
2060 Err(resp) => resp,
2061 };
2062 }
2063 match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
2064 Ok(_) => json_ok(json!({"ok": true})),
2065 Err(resp) => resp,
2066 }
2067}
2068
2069fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2074 if a.len() != b.len() {
2075 return false;
2076 }
2077 let mut diff = 0u8;
2078 for (x, y) in a.iter().zip(b.iter()) {
2079 diff |= x ^ y;
2080 }
2081 diff == 0
2082}
2083
2084const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
2089
2090fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
2092 match raw {
2093 None => Ok(1),
2094 Some(s) => {
2095 let d: u32 = s
2096 .parse()
2097 .map_err(|_| "depth must be an integer".to_string())?;
2098 if d > MAX_NEIGHBORHOOD_DEPTH {
2099 return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
2100 }
2101 Ok(d)
2102 }
2103 }
2104}
2105
2106fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
2114 if dest.is_empty() {
2115 return Err("missing or empty \"dest\" field".into());
2116 }
2117 let dest_path = std::path::Path::new(dest);
2118 if dest_path
2119 .components()
2120 .any(|c| matches!(c, std::path::Component::ParentDir))
2121 {
2122 return Err("backup \"dest\" may not contain \"..\" path segments".into());
2123 }
2124 let joined = if dest_path.is_absolute() {
2125 dest_path.to_path_buf()
2126 } else {
2127 root.join(dest_path)
2128 };
2129 if !joined.starts_with(root) {
2130 return Err(format!(
2131 "backup \"dest\" must be within the backup root ({}); \
2132 set MUSHROOMDB_BACKUP_DIR to change it",
2133 root.display()
2134 ));
2135 }
2136 Ok(joined)
2137}
2138
2139fn backup_root() -> PathBuf {
2142 std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2143 .map(PathBuf::from)
2144 .or_else(|| std::env::current_dir().ok())
2145 .unwrap_or_else(|| PathBuf::from("."))
2146}
2147
2148async fn backup(
2169 State(state): State<AppState>,
2170 Extension(identity): Extension<AuthIdentity>,
2171 Json(body): Json<Js>,
2172) -> Response {
2173 if let AuthIdentity::Role(_) = identity {
2174 return forbidden("role-bound token: /backup requires a full-access token");
2175 }
2176 let root = backup_root();
2177 let dest = match body.get("dest").and_then(Js::as_str) {
2178 Some(s) => match confine_backup_dest(s, &root) {
2179 Ok(p) => p,
2180 Err(e) => return err_response(e),
2181 },
2182 None => return err_response("missing or empty \"dest\" field"),
2183 };
2184 let db = state.db.clone();
2185 let report: BackupReport = match tokio::task::spawn_blocking(move || {
2186 let g = db.read();
2189 g.backup_to(&dest)
2190 })
2191 .await
2192 {
2193 Ok(Ok(r)) => r,
2194 Ok(Err(e)) => return graph_err(e),
2195 Err(_) => return err_response("backup task panicked"),
2196 };
2197
2198 let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2199 Ok(v) => v,
2200 Err(e) => return err_response(e.to_string()),
2201 };
2202
2203 if report.verified {
2204 json_ok(body)
2205 } else {
2206 (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2207 }
2208}
2209
2210#[derive(serde::Serialize)]
2212struct BackupReportJson<'a> {
2213 files: &'a [String],
2214 bytes: u64,
2215 verified: bool,
2216}
2217
2218impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2219 fn from(r: &'a BackupReport) -> Self {
2220 Self {
2221 files: &r.files,
2222 bytes: r.bytes,
2223 verified: r.verified,
2224 }
2225 }
2226}
2227
2228#[cfg(test)]
2229mod tests {
2230 use super::*;
2231 use crate::json::result_set_json;
2232 use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2233
2234 #[test]
2235 fn nan_float_cell_serializes_as_null() {
2236 let mut rs = ResultSet::new(vec!["n".into()]);
2237 rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2238 let j = result_set_json(&rs);
2239 assert_eq!(j["rows"][0][0], Js::Null);
2240 }
2241
2242 #[test]
2245 fn pagerank_config_empty_body_uses_defaults() {
2246 let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2247 let default = PageRankConfig::default();
2248 assert_eq!(config.damping, default.damping);
2249 assert_eq!(config.max_iters, default.max_iters);
2250 assert_eq!(config.tol, default.tol);
2251 assert_eq!(config.budget_ms, default.budget_ms);
2252 assert_eq!(config.edge_type, default.edge_type);
2253 }
2254
2255 #[test]
2257 fn wcc_config_empty_body_uses_defaults() {
2258 let config: WccConfig = serde_json::from_str("{}").unwrap();
2259 let default = WccConfig::default();
2260 assert_eq!(config.budget_ms, default.budget_ms);
2261 assert_eq!(config.edge_type, default.edge_type);
2262 }
2263
2264 #[test]
2266 fn degree_config_empty_body_uses_defaults() {
2267 let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2268 let default = DegreeConfig::default();
2269 assert_eq!(config.budget_ms, default.budget_ms);
2270 assert_eq!(config.edge_type, default.edge_type);
2271 }
2272
2273 #[test]
2274 fn backup_dest_rejects_empty() {
2275 assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2276 }
2277
2278 #[test]
2279 fn constant_time_eq_matches_equality() {
2280 assert!(constant_time_eq(b"secret-token", b"secret-token"));
2281 assert!(constant_time_eq(b"", b""));
2282 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"));
2286 }
2287
2288 #[test]
2289 fn depth_defaults_to_one_when_absent() {
2290 assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2291 }
2292
2293 #[test]
2294 fn depth_within_cap_is_accepted() {
2295 assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2296 assert_eq!(
2297 resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2298 MAX_NEIGHBORHOOD_DEPTH
2299 );
2300 }
2301
2302 #[test]
2303 fn depth_over_cap_is_rejected() {
2304 assert!(resolve_neighborhood_depth(Some("65")).is_err());
2305 assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2306 }
2307
2308 #[test]
2309 fn depth_non_integer_is_rejected() {
2310 assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2311 }
2312
2313 #[test]
2314 fn backup_dest_rejects_parent_traversal() {
2315 let root = std::path::Path::new("/srv/backups");
2316 assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2317 assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2318 }
2319
2320 #[test]
2321 fn backup_dest_rejects_absolute_outside_root() {
2322 let root = std::path::Path::new("/srv/backups");
2323 assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2324 assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2325 }
2326
2327 #[test]
2328 fn backup_dest_allows_relative_within_root() {
2329 let root = std::path::Path::new("/srv/backups");
2330 assert_eq!(
2331 confine_backup_dest("nightly", root).unwrap(),
2332 PathBuf::from("/srv/backups/nightly")
2333 );
2334 assert_eq!(
2335 confine_backup_dest("2026/aug", root).unwrap(),
2336 PathBuf::from("/srv/backups/2026/aug")
2337 );
2338 }
2339
2340 #[test]
2341 fn backup_dest_allows_absolute_within_root() {
2342 let root = std::path::Path::new("/srv/backups");
2343 assert_eq!(
2344 confine_backup_dest("/srv/backups/x", root).unwrap(),
2345 PathBuf::from("/srv/backups/x")
2346 );
2347 }
2348}