1use crate::json::{
14 edge_history_result_json, namespace_arg, node_edges_json, node_history_json, node_info_json,
15 params_from_json, parse_ingest_edges, result_set_json, rule_def_from_json, stamp_namespace,
16 stamp_namespace_row,
17};
18use crate::{AppState, AuthIdentity};
19use arrow_bridge::to_ipc_bytes;
20use axum::extract::{Extension, Path, Query, Request, State};
21use axum::http::{header, HeaderValue, Method, StatusCode};
22use axum::middleware::{self, Next};
23use axum::response::{IntoResponse, Response};
24use axum::routing::{get, post};
25use axum::{Json, Router};
26use core_api::{
27 is_write_query, json_to_rows, json_to_value, AsOfScope, AutoFk, BackupReport, BatchOp,
28 DegreeConfig, Dir, GraphError, IngestOptions, MaskMode, NodeMask, PageRankConfig,
29 PropPredicate, ResultSet, SharedDb, SuggestConfig, Value, WccConfig, SUGGEST_DEFAULT_SEED,
30};
31use serde_json::{json, Value as Js};
32use std::collections::{BTreeMap, HashMap};
33use std::net::SocketAddr;
34use std::path::PathBuf;
35use tower_http::services::ServeDir;
36
37pub fn router(db: SharedDb) -> Router {
57 router_with_auth(db, None)
58}
59
60pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
66 build_app(
67 db,
68 token,
69 HashMap::new(),
70 UiFallback::None,
71 default_advertise_addr(),
72 false,
73 )
74}
75
76pub fn router_with_role_tokens(
82 db: SharedDb,
83 token: Option<String>,
84 role_tokens: HashMap<String, String>,
85) -> Router {
86 build_app(
87 db,
88 token,
89 role_tokens,
90 UiFallback::None,
91 default_advertise_addr(),
92 false,
93 )
94}
95
96pub fn router_with_ui(
101 db: SharedDb,
102 ui_dir: impl AsRef<std::path::Path>,
103 token: Option<String>,
104) -> Router {
105 build_app(
106 db,
107 token,
108 HashMap::new(),
109 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
110 default_advertise_addr(),
111 false,
112 )
113}
114
115pub fn router_with_ui_tls(
119 db: SharedDb,
120 ui_dir: impl AsRef<std::path::Path>,
121 token: Option<String>,
122) -> Router {
123 build_app(
124 db,
125 token,
126 HashMap::new(),
127 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
128 default_advertise_addr(),
129 true,
130 )
131}
132
133#[cfg(feature = "embed-ui")]
134static EMBEDDED_UI: include_dir::Dir<'_> =
135 include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
136
137#[cfg(feature = "embed-ui")]
139pub fn router_with_embedded_ui(db: SharedDb) -> Router {
140 build_app(
141 db,
142 None,
143 HashMap::new(),
144 UiFallback::Embedded,
145 default_advertise_addr(),
146 false,
147 )
148}
149
150#[cfg(feature = "embed-ui")]
151async fn embedded_fallback(uri: axum::http::Uri) -> Response {
152 let rel = if uri.path() == "/" || uri.path().is_empty() {
153 "index.html"
154 } else {
155 uri.path().trim_start_matches('/')
156 };
157 if rel.split('/').any(|seg| seg == "..") {
158 return StatusCode::NOT_FOUND.into_response();
159 }
160 match EMBEDDED_UI.get_file(rel) {
161 Some(file) => (
162 StatusCode::OK,
163 [(header::CONTENT_TYPE, embedded_ctype(rel))],
164 file.contents(),
165 )
166 .into_response(),
167 None => StatusCode::NOT_FOUND.into_response(),
168 }
169}
170
171#[cfg(feature = "embed-ui")]
172fn embedded_ctype(path: &str) -> &'static str {
173 if path.ends_with(".html") {
174 "text/html; charset=utf-8"
175 } else if path.ends_with(".js") {
176 "application/javascript; charset=utf-8"
177 } else if path.ends_with(".css") {
178 "text/css; charset=utf-8"
179 } else if path.ends_with(".woff2") {
180 "font/woff2"
181 } else if path.ends_with(".svg") {
182 "image/svg+xml"
183 } else if path.ends_with(".ico") {
184 "image/x-icon"
185 } else if path.ends_with(".txt") {
186 "text/plain; charset=utf-8"
187 } else {
188 "application/octet-stream"
189 }
190}
191
192#[deprecated(
200 since = "0.2.0",
201 note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
202)]
203#[doc(hidden)]
204pub async fn serve(
205 db: SharedDb,
206 addr: SocketAddr,
207 ready: tokio::sync::oneshot::Sender<SocketAddr>,
208 token: Option<String>,
209) -> std::io::Result<()> {
210 serve_inner(
211 db,
212 addr,
213 ready,
214 UiFallback::None,
215 token,
216 HashMap::new(),
217 None,
218 )
219 .await
220}
221
222pub async fn serve_with_role_tokens(
227 db: SharedDb,
228 addr: SocketAddr,
229 ready: tokio::sync::oneshot::Sender<SocketAddr>,
230 token: Option<String>,
231 role_tokens: HashMap<String, String>,
232) -> std::io::Result<()> {
233 serve_inner(db, addr, ready, UiFallback::None, token, role_tokens, None).await
234}
235
236#[deprecated(
241 since = "0.2.0",
242 note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
243)]
244#[doc(hidden)]
245pub async fn serve_with_ui(
246 db: SharedDb,
247 addr: SocketAddr,
248 ready: tokio::sync::oneshot::Sender<SocketAddr>,
249 ui_dir: PathBuf,
250 token: Option<String>,
251) -> std::io::Result<()> {
252 serve_inner(
253 db,
254 addr,
255 ready,
256 UiFallback::Dir(ui_dir),
257 token,
258 HashMap::new(),
259 None,
260 )
261 .await
262}
263
264pub async fn serve_with_ui_and_role_tokens(
266 db: SharedDb,
267 addr: SocketAddr,
268 ready: tokio::sync::oneshot::Sender<SocketAddr>,
269 ui_dir: PathBuf,
270 token: Option<String>,
271 role_tokens: HashMap<String, String>,
272) -> std::io::Result<()> {
273 serve_inner(
274 db,
275 addr,
276 ready,
277 UiFallback::Dir(ui_dir),
278 token,
279 role_tokens,
280 None,
281 )
282 .await
283}
284
285#[cfg(feature = "embed-ui")]
290pub async fn serve_with_embedded_ui(
291 db: SharedDb,
292 addr: SocketAddr,
293 ready: tokio::sync::oneshot::Sender<SocketAddr>,
294 token: Option<String>,
295 role_tokens: HashMap<String, String>,
296) -> std::io::Result<()> {
297 serve_inner(
298 db,
299 addr,
300 ready,
301 UiFallback::Embedded,
302 token,
303 role_tokens,
304 None,
305 )
306 .await
307}
308
309pub async fn serve_with_shutdown(
323 db: SharedDb,
324 addr: SocketAddr,
325 ready: tokio::sync::oneshot::Sender<SocketAddr>,
326 token: Option<String>,
327 shutdown: tokio::sync::oneshot::Receiver<()>,
328) -> std::io::Result<()> {
329 serve_inner(
330 db,
331 addr,
332 ready,
333 UiFallback::None,
334 token,
335 HashMap::new(),
336 Some(shutdown),
337 )
338 .await
339}
340
341pub async fn serve_with_role_tokens_and_shutdown(
343 db: SharedDb,
344 addr: SocketAddr,
345 ready: tokio::sync::oneshot::Sender<SocketAddr>,
346 token: Option<String>,
347 role_tokens: HashMap<String, String>,
348 shutdown: tokio::sync::oneshot::Receiver<()>,
349) -> std::io::Result<()> {
350 serve_inner(
351 db,
352 addr,
353 ready,
354 UiFallback::None,
355 token,
356 role_tokens,
357 Some(shutdown),
358 )
359 .await
360}
361
362pub async fn serve_with_ui_and_role_tokens_and_shutdown(
364 db: SharedDb,
365 addr: SocketAddr,
366 ready: tokio::sync::oneshot::Sender<SocketAddr>,
367 ui_dir: PathBuf,
368 token: Option<String>,
369 role_tokens: HashMap<String, String>,
370 shutdown: tokio::sync::oneshot::Receiver<()>,
371) -> std::io::Result<()> {
372 serve_inner(
373 db,
374 addr,
375 ready,
376 UiFallback::Dir(ui_dir),
377 token,
378 role_tokens,
379 Some(shutdown),
380 )
381 .await
382}
383
384#[cfg(feature = "embed-ui")]
386pub async fn serve_with_embedded_ui_and_shutdown(
387 db: SharedDb,
388 addr: SocketAddr,
389 ready: tokio::sync::oneshot::Sender<SocketAddr>,
390 token: Option<String>,
391 role_tokens: HashMap<String, String>,
392 shutdown: tokio::sync::oneshot::Receiver<()>,
393) -> std::io::Result<()> {
394 serve_inner(
395 db,
396 addr,
397 ready,
398 UiFallback::Embedded,
399 token,
400 role_tokens,
401 Some(shutdown),
402 )
403 .await
404}
405
406enum UiFallback {
407 None,
408 Dir(PathBuf),
409 #[cfg(feature = "embed-ui")]
410 Embedded,
411}
412
413async fn serve_inner(
414 db: SharedDb,
415 addr: SocketAddr,
416 ready: tokio::sync::oneshot::Sender<SocketAddr>,
417 ui: UiFallback,
418 token: Option<String>,
419 role_tokens: HashMap<String, String>,
420 shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
421) -> std::io::Result<()> {
422 let listener = tokio::net::TcpListener::bind(addr).await?;
423 let local = listener.local_addr()?;
424 if ready.send(local).is_err() {
425 eprintln!("serve: readiness receiver dropped before bind notify");
427 }
428 let app = build_app(db, token, role_tokens, ui, local, false);
429 match shutdown {
430 None => axum::serve(listener, app).await,
431 Some(rx) => {
435 axum::serve(listener, app)
436 .with_graceful_shutdown(async move {
437 let _ = rx.await;
438 })
439 .await
440 }
441 }
442}
443
444#[cfg(feature = "tls")]
452pub async fn serve_tls(
453 db: SharedDb,
454 addr: SocketAddr,
455 ready: tokio::sync::oneshot::Sender<SocketAddr>,
456 cert_path: std::path::PathBuf,
457 key_path: std::path::PathBuf,
458 token: Option<String>,
459 role_tokens: HashMap<String, String>,
460) -> std::io::Result<()> {
461 let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
462 .await
463 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
464 let listener = std::net::TcpListener::bind(addr)?;
465 listener.set_nonblocking(true)?;
466 let local = listener.local_addr()?;
467 if ready.send(local).is_err() {
468 eprintln!("serve_tls: readiness receiver dropped before bind notify");
469 }
470 let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
471 axum_server::from_tcp_rustls(listener, config)?
472 .serve(app.into_make_service())
473 .await
474}
475
476#[cfg(feature = "tls")]
484pub async fn serve_tls_with_shutdown(
485 db: SharedDb,
486 addr: SocketAddr,
487 ready: tokio::sync::oneshot::Sender<SocketAddr>,
488 cert_path: std::path::PathBuf,
489 key_path: std::path::PathBuf,
490 token: Option<String>,
491 role_tokens: HashMap<String, String>,
492 shutdown: tokio::sync::oneshot::Receiver<()>,
493) -> std::io::Result<()> {
494 let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
495 .await
496 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
497 let listener = std::net::TcpListener::bind(addr)?;
498 listener.set_nonblocking(true)?;
499 let local = listener.local_addr()?;
500 if ready.send(local).is_err() {
501 eprintln!("serve_tls: readiness receiver dropped before bind notify");
502 }
503 let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
504 let handle = axum_server::Handle::new();
505 tokio::spawn({
506 let handle = handle.clone();
507 async move {
508 let _ = shutdown.await;
509 handle.graceful_shutdown(Some(TLS_GRACE));
510 }
511 });
512 axum_server::from_tcp_rustls(listener, config)?
513 .handle(handle)
514 .serve(app.into_make_service())
515 .await
516}
517
518#[cfg(feature = "tls")]
521const TLS_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
522
523fn default_advertise_addr() -> SocketAddr {
524 SocketAddr::from(([127, 0, 0, 1], 8080))
525}
526
527fn build_app(
528 db: SharedDb,
529 token: Option<String>,
530 role_tokens: HashMap<String, String>,
531 ui: UiFallback,
532 addr: SocketAddr,
533 tls_active: bool,
534) -> Router {
535 debug_assert!(
536 !db.read().has_event_sink(),
537 "router() must be called at most once per SharedDb; a second call \
538 replaces the sink and terminates all existing /watch subscribers \
539 with RecvError::Closed"
540 );
541 let (tx, _) = tokio::sync::broadcast::channel(1024);
542 {
543 let tx = tx.clone();
544 db.write().set_event_sink(Box::new(move |ev| {
545 let _ = tx.send(ev);
546 }));
547 }
548 let state = AppState {
549 db,
550 watch: tx,
551 token,
552 role_tokens,
553 addr,
554 tls_active,
555 started_at: std::time::Instant::now(),
556 };
557 let app = Router::new()
558 .route("/health", get(health))
559 .route("/query", post(query))
560 .route("/stats", get(stats))
561 .route("/metrics", get(metrics))
562 .route("/ingest", post(ingest))
563 .route("/rules", post(create_rule))
564 .route("/suggest", get(suggest))
565 .route("/explain", get(explain))
566 .route("/find_similar", post(find_similar))
567 .route("/node/{key}", get(node_info))
568 .route("/node/{key}", axum::routing::delete(delete_node))
569 .route("/node/{key}/edges", get(node_edges))
570 .route("/node/{key}/neighborhood", get(neighborhood))
571 .route("/node/{key}/history", get(node_history_handler))
572 .route("/history/edge", get(edge_history_handler))
573 .route("/history/was_linked", get(was_linked_handler))
574 .route(
575 "/node/{key}/prop/{field}",
576 axum::routing::put(set_node_prop),
577 )
578 .route(
579 "/node/{key}/prop/{field}",
580 axum::routing::delete(remove_node_prop),
581 )
582 .route("/nodes", post(create_node))
586 .route("/nodes/{key}/rename", post(rename_node))
587 .route("/edges", post(create_edge))
588 .route("/edges/upsert", post(upsert_edge))
589 .route(
590 "/edges/{etype}/{src}/{dst}",
591 axum::routing::delete(delete_edge),
592 )
593 .route("/algo/pagerank", post(algo_pagerank))
594 .route("/algo/wcc", post(algo_wcc))
595 .route("/algo/degree", post(algo_degree))
596 .route("/backup", post(backup))
597 .with_state(state.clone());
598
599 let streaming = Router::new()
603 .route("/watch", get(crate::ws::watch))
604 .route("/subscribe", get(crate::subscribe::subscribe))
605 .with_state(state.clone());
606
607 let app = app
619 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
620 StatusCode::REQUEST_TIMEOUT,
621 REQUEST_TIMEOUT,
622 ))
623 .layer(
624 tower::ServiceBuilder::new()
625 .layer(axum::error_handling::HandleErrorLayer::new(
629 |_: tower::BoxError| async {
630 (
631 StatusCode::SERVICE_UNAVAILABLE,
632 Json(json!({
633 "error": "server at its concurrency limit; retry"
634 })),
635 )
636 },
637 ))
638 .load_shed()
639 .concurrency_limit(MAX_CONCURRENT_REQUESTS),
640 );
641 let app = app.merge(streaming);
642 let app = match ui {
643 UiFallback::None => app,
644 UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
645 #[cfg(feature = "embed-ui")]
646 UiFallback::Embedded => app.fallback(embedded_fallback),
647 };
648 app.layer(middleware::from_fn_with_state(state, auth_middleware))
649 .layer(middleware::from_fn(log_middleware))
652 .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
656}
657
658async fn log_middleware(req: Request, next: Next) -> Response {
683 let request_id = req
684 .headers()
685 .get("x-request-id")
686 .and_then(|v| v.to_str().ok())
687 .filter(|v| !v.is_empty() && v.len() <= 128 && v.is_ascii())
688 .map(str::to_owned)
689 .unwrap_or_else(new_request_id);
690
691 let method = req.method().clone();
692 let path = req.uri().path().to_owned();
695 let started = std::time::Instant::now();
696
697 let mut res = next.run(req).await;
698 let status = res.status();
699 let ms = started.elapsed().as_millis();
700
701 if status.is_server_error() {
702 tracing::error!(%request_id, %method, %path, status = status.as_u16(), ms, "request failed");
703 } else if started.elapsed() >= SLOW_REQUEST {
704 tracing::warn!(%request_id, %method, %path, status = status.as_u16(), ms, "slow request");
705 } else if status.is_client_error() {
706 tracing::info!(%request_id, %method, %path, status = status.as_u16(), ms, "request refused");
707 } else {
708 tracing::debug!(%request_id, %method, %path, status = status.as_u16(), ms, "request");
709 }
710
711 if let Ok(v) = axum::http::HeaderValue::from_str(&request_id) {
712 res.headers_mut().insert("x-request-id", v);
713 }
714 res
715}
716
717fn new_request_id() -> String {
721 use std::sync::atomic::{AtomicU64, Ordering};
722 static N: AtomicU64 = AtomicU64::new(0);
723 let n = N.fetch_add(1, Ordering::Relaxed);
724 let nanos = std::time::SystemTime::now()
725 .duration_since(std::time::UNIX_EPOCH)
726 .map(|d| d.subsec_nanos() as u64)
727 .unwrap_or(0);
728 format!("{:x}-{:x}-{:x}", std::process::id(), n, nanos)
729}
730
731const SLOW_REQUEST: std::time::Duration = std::time::Duration::from_secs(5);
735
736const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
737
738const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
749
750const MAX_CONCURRENT_REQUESTS: usize = 256;
757
758async fn health(
759 State(state): State<AppState>,
760 Extension(identity): Extension<AuthIdentity>,
761) -> Response {
762 if !matches!(identity, AuthIdentity::Full) {
767 return json_ok(json!({ "ok": true }));
768 }
769 let (nodes, edges) = {
770 let g = state.db.read();
771 let s = g.stats();
772 (s.nodes_live, s.edges)
773 };
774 json_ok(json!({
775 "ok": true,
776 "nodes": nodes,
777 "edges": edges,
778 "addr": state.addr.to_string(),
779 }))
780}
781
782async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
785where
786 T: Send + 'static,
787 F: FnOnce() -> core_api::Result<T> + Send + 'static,
788{
789 match tokio::task::spawn_blocking(f).await {
790 Ok(Ok(v)) => Ok(v),
791 Ok(Err(e)) => Err(graph_err(e)),
792 Err(_) => Err(err_response("write task panicked")),
793 }
794}
795
796const TOKEN_COOKIE: &str = "mushroomdb_token";
797
798async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
799 if state.token.is_none() && state.role_tokens.is_empty() {
801 req.extensions_mut().insert(AuthIdentity::Full);
802 return next.run(req).await;
803 }
804
805 let presented = request_token(&req);
806
807 if req.method() == Method::GET && req.uri().path() == "/health" {
818 let full = state
819 .token
820 .clone()
821 .filter(|s| !s.is_empty())
822 .zip(presented.as_deref())
823 .is_some_and(|(tok, p)| constant_time_eq(p.as_bytes(), tok.as_bytes()));
824 req.extensions_mut().insert(if full {
825 AuthIdentity::Full
826 } else {
827 AuthIdentity::Anonymous
828 });
829 return next.run(req).await;
830 }
831
832 if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
834 if presented
835 .as_deref()
836 .is_some_and(|p| constant_time_eq(p.as_bytes(), full_tok.as_bytes()))
837 {
838 let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
839 req.extensions_mut().insert(AuthIdentity::Full);
840 let mut res = next.run(req).await;
841 if set_cookie && is_html_response(&res) {
842 attach_token_cookie(&mut res, full_tok, state.tls_active);
843 }
844 return res;
845 }
846 }
847
848 if let Some(tok) = presented.as_deref() {
850 if let Some(role_name) = state.role_tokens.get(tok) {
851 let path = req.uri().path();
857 if path == "/subscribe" || path == "/watch" {
858 return forbidden("role-bound token: this endpoint is not permitted");
859 }
860 req.extensions_mut()
861 .insert(AuthIdentity::Role(role_name.clone()));
862 return next.run(req).await;
863 }
864 }
865
866 unauthorized()
868}
869
870fn request_token(req: &Request) -> Option<String> {
871 presented_bearer_or_query(req).or_else(|| presented_cookie(req))
872}
873
874fn presented_bearer_or_query(req: &Request) -> Option<String> {
875 if let Some(header) = req
876 .headers()
877 .get(header::AUTHORIZATION)
878 .and_then(|v| v.to_str().ok())
879 {
880 if let Some(value) = bearer_token(header) {
881 return Some(value.to_string());
882 }
883 }
884 query_param(req.uri().query().unwrap_or(""), "token")
885}
886
887fn presented_cookie(req: &Request) -> Option<String> {
888 let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
889 cookie_named(header, TOKEN_COOKIE).map(str::to_string)
890}
891
892fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
893 for part in header.split(';') {
894 let part = part.trim();
895 let Some((k, v)) = part.split_once('=') else {
896 continue;
897 };
898 if k.trim() == name {
899 return Some(v.trim());
900 }
901 }
902 None
903}
904
905fn is_html_response(res: &Response) -> bool {
906 res.headers()
907 .get(header::CONTENT_TYPE)
908 .and_then(|v| v.to_str().ok())
909 .is_some_and(|ct| {
910 ct.split(';')
911 .next()
912 .unwrap_or("")
913 .trim()
914 .eq_ignore_ascii_case("text/html")
915 })
916}
917
918fn attach_token_cookie(res: &mut Response, token: &str, secure: bool) {
919 let secure_attr = if secure { "; Secure" } else { "" };
920 let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly{secure_attr}");
921 if let Ok(hv) = HeaderValue::from_str(&value) {
922 res.headers_mut().insert(header::SET_COOKIE, hv);
923 }
924}
925
926fn bearer_token(header: &str) -> Option<&str> {
927 let (scheme, value) = header.split_once(' ')?;
928 if scheme.eq_ignore_ascii_case("Bearer") {
929 Some(value.trim())
930 } else {
931 None
932 }
933}
934
935fn query_param(query: &str, key: &str) -> Option<String> {
936 for pair in query.split('&') {
937 if pair.is_empty() {
938 continue;
939 }
940 match pair.split_once('=') {
941 Some((k, v)) if k == key => return percent_decode_plus(v),
942 None if pair == key => return Some(String::new()),
943 _ => {}
944 }
945 }
946 None
947}
948
949fn percent_decode_plus(s: &str) -> Option<String> {
951 let bytes = s.as_bytes();
952 let mut out = Vec::with_capacity(bytes.len());
953 let mut i = 0;
954 while i < bytes.len() {
955 match bytes[i] {
956 b'+' => {
957 out.push(b' ');
958 i += 1;
959 }
960 b'%' => {
961 if i + 2 >= bytes.len() {
962 return None;
963 }
964 let hi = from_hex(bytes[i + 1])?;
965 let lo = from_hex(bytes[i + 2])?;
966 out.push((hi << 4) | lo);
967 i += 3;
968 }
969 c => {
970 out.push(c);
971 i += 1;
972 }
973 }
974 }
975 String::from_utf8(out).ok()
976}
977
978fn from_hex(b: u8) -> Option<u8> {
979 match b {
980 b'0'..=b'9' => Some(b - b'0'),
981 b'a'..=b'f' => Some(b - b'a' + 10),
982 b'A'..=b'F' => Some(b - b'A' + 10),
983 _ => None,
984 }
985}
986
987fn unauthorized() -> Response {
988 (
989 StatusCode::UNAUTHORIZED,
990 Json(json!({"error": "unauthorized"})),
991 )
992 .into_response()
993}
994
995fn forbidden(detail: &str) -> Response {
997 (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
998}
999
1000fn role_mask_err(e: GraphError) -> Response {
1008 match e {
1009 GraphError::Corrupt { detail } => (
1010 StatusCode::INTERNAL_SERVER_ERROR,
1011 Json(json!({"error": format!("roles misconfigured: {detail}")})),
1012 )
1013 .into_response(),
1014 GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
1015 other => graph_err(other),
1016 }
1017}
1018
1019fn err_response(detail: impl Into<String>) -> Response {
1020 (
1021 StatusCode::BAD_REQUEST,
1022 Json(json!({"error": detail.into()})),
1023 )
1024 .into_response()
1025}
1026
1027fn busy_response(detail: String) -> Response {
1033 (
1034 StatusCode::SERVICE_UNAVAILABLE,
1035 [(header::RETRY_AFTER, HeaderValue::from_static("1"))],
1036 Json(json!({ "error": detail })),
1037 )
1038 .into_response()
1039}
1040
1041fn graph_err(e: GraphError) -> Response {
1042 match e {
1043 GraphError::RoleWriteDenied { reason } => forbidden(&reason),
1046 corrupt @ GraphError::Corrupt { .. } => (
1054 StatusCode::INTERNAL_SERVER_ERROR,
1055 Json(json!({ "error": corrupt.to_string() })),
1056 )
1057 .into_response(),
1058 GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
1059 err_response(detail)
1060 }
1061 busy @ GraphError::Busy { .. } => busy_response(busy.to_string()),
1063 other => err_response(other.to_string()),
1064 }
1065}
1066
1067fn key_not_found(key: String) -> Response {
1068 (
1069 StatusCode::NOT_FOUND,
1070 Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
1071 )
1072 .into_response()
1073}
1074
1075fn conflict_response(key: String) -> Response {
1076 (
1077 StatusCode::CONFLICT,
1078 Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
1079 )
1080 .into_response()
1081}
1082
1083fn json_ok(value: Js) -> Response {
1084 (StatusCode::OK, Json(value)).into_response()
1085}
1086
1087fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
1088 let Some(v) = v else {
1089 return Ok(IngestOptions::default());
1090 };
1091 if v.is_null() {
1092 return Ok(IngestOptions::default());
1093 }
1094 let obj = v
1095 .as_object()
1096 .ok_or_else(|| "options must be an object".to_string())?;
1097 let mut opts = IngestOptions::default();
1098 if let Some(kf) = obj.get("key_field") {
1099 opts.key_field = kf
1100 .as_str()
1101 .ok_or_else(|| "options.key_field must be a string".to_string())?
1102 .to_string();
1103 }
1104 if let Some(fk) = obj.get("auto_fk") {
1105 if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
1106 opts.auto_fk = AutoFk::Off;
1107 } else if let Some(m) = fk.as_object() {
1108 let suf = m
1109 .get("suffix")
1110 .and_then(Js::as_str)
1111 .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
1112 opts.auto_fk = AutoFk::Auto {
1113 suffix: suf.to_string(),
1114 };
1115 } else {
1116 return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
1117 }
1118 }
1119 Ok(opts)
1120}
1121
1122fn format_query_result(rs: ResultSet, format: &str) -> Response {
1124 match format {
1125 "" => match to_ipc_bytes(&rs) {
1126 Ok(bytes) => (
1127 StatusCode::OK,
1128 [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
1129 bytes,
1130 )
1131 .into_response(),
1132 Err(e) => err_response(e),
1133 },
1134 "json" => json_ok(result_set_json(&rs)),
1135 other => err_response(format!("unknown format: {other}")),
1136 }
1137}
1138
1139async fn query(
1140 State(state): State<AppState>,
1141 Extension(identity): Extension<AuthIdentity>,
1142 Query(qs): Query<BTreeMap<String, String>>,
1143 Json(body): Json<Js>,
1144) -> Response {
1145 let cypher = match body.get("cypher").and_then(Js::as_str) {
1146 Some(s) => s.to_string(),
1147 None => return err_response("missing cypher"),
1148 };
1149 let params = match params_from_json(body.get("params")) {
1150 Ok(p) => p,
1151 Err(e) => return err_response(e),
1152 };
1153 let format = qs.get("format").map(String::as_str).unwrap_or("");
1154
1155 let as_of = match body.get("as_of") {
1158 None | Some(Js::Null) => None,
1159 Some(v) => match v.as_u64() {
1160 Some(n) => Some(n),
1161 None => return err_response("as_of must be a non-negative integer commit index"),
1162 },
1163 };
1164
1165 let mask_keys: Option<Vec<String>> = match body.get("mask") {
1167 None | Some(Js::Null) => None,
1168 Some(Js::Array(arr)) => {
1169 let mut keys = Vec::with_capacity(arr.len());
1170 for v in arr {
1171 match v.as_str() {
1172 Some(s) => keys.push(s.to_string()),
1173 None => return err_response("mask must be an array of strings"),
1174 }
1175 }
1176 Some(keys)
1177 }
1178 Some(_) => return err_response("mask must be an array of strings"),
1179 };
1180
1181 let namespace = match namespace_arg(body.get("namespace")) {
1187 Ok(n) => n,
1188 Err(e) => return err_response(e),
1189 };
1190
1191 if as_of.is_some()
1194 && body
1195 .get("stub_hidden")
1196 .and_then(|v| v.as_bool())
1197 .unwrap_or(false)
1198 {
1199 return err_response("as_of (time-travel) does not compose with stub_hidden");
1200 }
1201
1202 if let AuthIdentity::Role(ref role_name) = identity {
1206 let is_write = match is_write_query(&cypher) {
1207 Ok(b) => b,
1208 Err(e) => return err_response(e),
1209 };
1210 if as_of.is_some() && is_write {
1211 return err_response("as_of (time-travel) queries are read-only");
1212 }
1213 if is_write && (mask_keys.is_some() || namespace.is_some()) {
1222 return (
1223 StatusCode::BAD_REQUEST,
1224 Json(json!({"error": "masked queries are read-only"})),
1225 )
1226 .into_response();
1227 }
1228 if is_write {
1229 let role = role_name.clone();
1230 let cypher_c = cypher.clone();
1231 let params_c = params.clone();
1232 let db = state.db.clone();
1233 return match blocking_write(move || {
1234 db.write().query_write_authz(&role, &cypher_c, ¶ms_c)
1235 })
1236 .await
1237 {
1238 Ok(rs) => format_query_result(rs, format),
1239 Err(resp) => resp,
1240 };
1241 }
1242 if let Some(commit) = as_of {
1247 let scope = match mask_keys {
1248 Some(ref keys) => AsOfScope::RoleAndKeys(role_name, keys),
1249 None => AsOfScope::Role(role_name),
1250 };
1251 let g = state.db.read();
1252 let out = match namespace.as_deref() {
1253 Some(ns) => g.query_at_scoped_in_namespace(commit, &cypher, ¶ms, scope, ns),
1254 None => g.query_at_scoped(commit, &cypher, ¶ms, scope),
1255 };
1256 return match out {
1257 Ok(rs) => format_query_result(rs, format),
1258 Err(e) => role_mask_err(e),
1259 };
1260 }
1261 let snap = state.db.reader();
1262 let role_mask = match snap.mask_for_role(role_name) {
1263 Ok(m) => m,
1264 Err(e) => return role_mask_err(e),
1265 };
1266 let effective_mask = if let Some(ref keys) = mask_keys {
1267 let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
1269 role_mask.intersect(&client_mask)
1270 } else {
1271 role_mask
1272 };
1273 let effective_mask = match namespace.as_deref() {
1276 Some(ns) => match snap.mask_for_namespace(ns) {
1277 Ok(ns_mask) => effective_mask.intersect(&ns_mask),
1278 Err(e) => return graph_err(e),
1279 },
1280 None => effective_mask,
1281 };
1282 return match snap.query_masked(&cypher, ¶ms, &effective_mask) {
1283 Ok(rs) => format_query_result(rs, format),
1284 Err(GraphError::MaskedReadOnly) => (
1285 StatusCode::BAD_REQUEST,
1286 Json(json!({"error": "masked queries are read-only"})),
1287 )
1288 .into_response(),
1289 Err(e) => graph_err(e),
1290 };
1291 }
1292
1293 if mask_keys.is_some() || namespace.is_some() {
1303 if let Some(commit) = as_of {
1306 let g = state.db.read();
1307 let out = match (&mask_keys, namespace.as_deref()) {
1308 (Some(keys), Some(ns)) => g.query_at_scoped_in_namespace(
1309 commit,
1310 &cypher,
1311 ¶ms,
1312 AsOfScope::Keys(keys),
1313 ns,
1314 ),
1315 (Some(keys), None) => {
1316 g.query_at_scoped(commit, &cypher, ¶ms, AsOfScope::Keys(keys))
1317 }
1318 (None, Some(ns)) => {
1319 g.query_at_scoped(commit, &cypher, ¶ms, AsOfScope::Namespace(ns))
1320 }
1321 (None, None) => unreachable!("one of the two is Some in this branch"),
1322 };
1323 return match out {
1324 Ok(rs) => format_query_result(rs, format),
1325 Err(e) => graph_err(e),
1326 };
1327 }
1328 let stub_hidden = body
1329 .get("stub_hidden")
1330 .and_then(|v| v.as_bool())
1331 .unwrap_or(false);
1332 let db = state.db.read();
1333 let mask = {
1334 let m = match (&mask_keys, namespace.as_deref()) {
1335 (Some(keys), Some(ns)) => {
1336 NodeMask::from_keys(&*db, keys.iter().map(String::as_str))
1337 .intersect(&db.mask_for_namespace(ns))
1338 }
1339 (Some(keys), None) => NodeMask::from_keys(&*db, keys.iter().map(String::as_str)),
1340 (None, Some(ns)) => db.mask_for_namespace(ns),
1341 (None, None) => unreachable!("one of the two is Some in this branch"),
1342 };
1343 if stub_hidden {
1344 m.with_mode(MaskMode::Stub)
1345 } else {
1346 m
1347 }
1348 };
1349 return match db.query_masked(&cypher, ¶ms, &mask) {
1350 Ok(rs) => format_query_result(rs, format),
1351 Err(GraphError::MaskedReadOnly) => (
1352 StatusCode::BAD_REQUEST,
1353 Json(json!({"error": "masked queries are read-only"})),
1354 )
1355 .into_response(),
1356 Err(e) => graph_err(e),
1357 };
1358 }
1359
1360 let is_write = match is_write_query(&cypher) {
1365 Ok(b) => b,
1366 Err(e) => return err_response(e),
1367 };
1368
1369 if as_of.is_some() && is_write {
1370 return err_response("as_of (time-travel) queries are read-only");
1371 }
1372
1373 let rs = if is_write {
1374 let db = state.db.clone();
1375 match blocking_write(move || db.write().query_write(&cypher, ¶ms)).await {
1376 Ok(rs) => rs,
1377 Err(resp) => return resp,
1378 }
1379 } else if let Some(commit) = as_of {
1380 match state.db.read().query_at(commit, &cypher, ¶ms) {
1381 Ok(rs) => rs,
1382 Err(e) => return graph_err(e),
1383 }
1384 } else {
1385 match state.db.read().query(&cypher, ¶ms) {
1386 Ok(rs) => rs,
1387 Err(e) => return graph_err(e),
1388 }
1389 };
1390
1391 format_query_result(rs, format)
1392}
1393
1394async fn stats(
1403 State(state): State<AppState>,
1404 Extension(identity): Extension<AuthIdentity>,
1405) -> Response {
1406 if let AuthIdentity::Role(_) = identity {
1409 return forbidden("role-bound token: /stats requires a full-access token");
1410 }
1411 let snap = {
1412 let g = state.db.read();
1413 g.stats()
1414 };
1415 match serde_json::to_value(&snap) {
1416 Ok(v) => json_ok(v),
1417 Err(e) => err_response(e.to_string()),
1418 }
1419}
1420
1421async fn metrics(
1422 State(state): State<AppState>,
1423 Extension(identity): Extension<AuthIdentity>,
1424) -> Response {
1425 if let AuthIdentity::Role(_) = identity {
1427 return forbidden("role-bound token: /metrics requires a full-access token");
1428 }
1429 let (s, commit_seq, wal_size_bytes, slow_snap) = {
1430 let g = state.db.read();
1431 let s = g.stats();
1432 let commit_seq = g.commit_seq();
1433 let wal_size_bytes = g.wal_size_bytes().ok();
1434 let slow_snap = g.slow_query_snapshot();
1435 (s, commit_seq, wal_size_bytes, slow_snap)
1436 };
1437 let uptime_s = state.started_at.elapsed().as_secs();
1438 let slow_entries: Vec<Js> = slow_snap
1439 .last
1440 .iter()
1441 .map(|e| {
1442 json!({
1443 "ms": e.ms,
1444 "query": e.query,
1445 "at_commit": e.at_commit,
1446 })
1447 })
1448 .collect();
1449 json_ok(json!({
1450 "nodes_live": s.nodes_live,
1451 "nodes_tombstoned": s.nodes_tombstoned,
1452 "edges": s.edges,
1453 "commit_seq": commit_seq,
1454 "wal_size_bytes": wal_size_bytes,
1455 "rss_bytes": rss_bytes(),
1456 "uptime_s": uptime_s,
1457 "slow_queries": {
1458 "threshold_ms": slow_snap.threshold_ms,
1459 "count": slow_snap.count,
1460 "last": slow_entries,
1461 },
1462 }))
1463}
1464
1465fn rss_bytes() -> Option<u64> {
1473 #[cfg(target_os = "macos")]
1474 {
1475 const MACH_TASK_BASIC_INFO: u32 = 20;
1480 const MACH_TASK_BASIC_INFO_COUNT: u32 = 12;
1481
1482 #[repr(C)]
1483 struct MachTaskBasicInfo {
1484 virtual_size: u64,
1485 resident_size: u64,
1486 resident_size_max: u64,
1487 user_time: [u32; 2],
1488 system_time: [u32; 2],
1489 policy: i32,
1490 suspend_count: i32,
1491 }
1492
1493 extern "C" {
1494 fn mach_task_self() -> u32;
1495 fn task_info(
1496 target_task: u32,
1497 flavor: u32,
1498 task_info_out: *mut std::ffi::c_void,
1499 task_info_cnt: *mut u32,
1500 ) -> i32;
1501 }
1502
1503 let mut info: MachTaskBasicInfo = unsafe { std::mem::zeroed() };
1504 let mut count = MACH_TASK_BASIC_INFO_COUNT;
1505 let ret = unsafe {
1506 task_info(
1507 mach_task_self(),
1508 MACH_TASK_BASIC_INFO,
1509 &mut info as *mut _ as *mut _,
1510 &mut count,
1511 )
1512 };
1513 if ret != 0 {
1514 return None; }
1516 return Some(info.resident_size);
1517 }
1518
1519 #[cfg(target_os = "linux")]
1520 {
1521 let content = std::fs::read_to_string("/proc/self/statm").ok()?;
1523 let mut parts = content.split_whitespace();
1524 let _vsize = parts.next()?;
1525 let rss_pages: u64 = parts.next()?.parse().ok()?;
1526 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1527 if page_size <= 0 {
1528 return None;
1529 }
1530 return Some(rss_pages * page_size as u64);
1531 }
1532
1533 #[allow(unreachable_code)]
1534 None
1535}
1536
1537async fn ingest(
1538 State(state): State<AppState>,
1539 Extension(identity): Extension<AuthIdentity>,
1540 Json(body): Json<Js>,
1541) -> Response {
1542 let label = match body.get("label").and_then(Js::as_str) {
1543 Some(s) => s.to_string(),
1544 None => return err_response("missing label"),
1545 };
1546 let rows = match body.get("rows") {
1547 Some(r) => r,
1548 None => return err_response("missing rows"),
1549 };
1550 let mut converted = match json_to_rows(rows) {
1551 Ok(c) => c,
1552 Err(e) => return graph_err(e),
1553 };
1554 let opts = match ingest_options(body.get("options")) {
1555 Ok(o) => o,
1556 Err(e) => return err_response(e),
1557 };
1558 let namespace = match namespace_arg(body.get("namespace")) {
1560 Ok(n) => n,
1561 Err(e) => return err_response(e),
1562 };
1563 if let Err(e) = stamp_namespace(&mut converted.rows, namespace.as_deref()) {
1564 return err_response(e);
1565 }
1566 let taken = std::mem::take(&mut converted.rows);
1567 let edges = match body.get("edges") {
1568 None | Some(Js::Null) => Vec::new(),
1569 Some(raw) => match parse_ingest_edges(raw) {
1570 Ok(e) => e,
1571 Err(e) => return err_response(e),
1572 },
1573 };
1574 let db = state.db.clone();
1575
1576 if let AuthIdentity::Role(role_name) = &identity {
1580 let role = role_name.clone();
1581 return match blocking_write(move || {
1582 db.write()
1583 .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
1584 })
1585 .await
1586 {
1587 Ok(r) => {
1588 let report = converted.into_report(r);
1589 match serde_json::to_value(&report) {
1590 Ok(v) => json_ok(v),
1591 Err(e) => err_response(e.to_string()),
1592 }
1593 }
1594 Err(resp) => resp,
1595 };
1596 }
1597
1598 let report =
1599 match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
1600 .await
1601 {
1602 Ok(r) => converted.into_report(r),
1603 Err(resp) => return resp,
1604 };
1605 match serde_json::to_value(&report) {
1606 Ok(v) => json_ok(v),
1607 Err(e) => err_response(e.to_string()),
1608 }
1609}
1610
1611async fn suggest(
1622 State(state): State<AppState>,
1623 Extension(identity): Extension<AuthIdentity>,
1624) -> Response {
1625 if let AuthIdentity::Role(_) = identity {
1628 return forbidden("role-bound token: /suggest requires a full-access token");
1629 }
1630 let db = state.db.clone();
1631 match tokio::task::spawn_blocking(move || {
1632 let config = SuggestConfig::default();
1633 db.read()
1634 .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1635 })
1636 .await
1637 {
1638 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1639 Err(_) => err_response("suggest task panicked"),
1640 }
1641}
1642
1643async fn create_rule(
1644 State(state): State<AppState>,
1645 Extension(identity): Extension<AuthIdentity>,
1646 Json(body): Json<Js>,
1647) -> Response {
1648 if let AuthIdentity::Role(_) = identity {
1649 return forbidden("role-bound token: writes are not permitted");
1650 }
1651 let def = match rule_def_from_json(body) {
1652 Ok(d) => d,
1653 Err(e) => return err_response(e),
1654 };
1655 let name = def.name.clone();
1656 let db = state.db.clone();
1657 if let Err(resp) = blocking_write(move || db.write().create_rule(def)).await {
1658 return resp;
1659 }
1660 let building = state
1670 .db
1671 .read()
1672 .builds_in_progress()
1673 .into_iter()
1674 .find(|b| b.rule == name);
1675 match building {
1676 Some(b) => (
1677 StatusCode::ACCEPTED,
1678 Json(json!({"rule": name, "building": {"indexed": b.indexed, "total": b.total}})),
1679 )
1680 .into_response(),
1681 None => json_ok(json!({"ok": true, "name": name})),
1682 }
1683}
1684
1685async fn explain(
1686 State(state): State<AppState>,
1687 Extension(identity): Extension<AuthIdentity>,
1688 Query(qs): Query<BTreeMap<String, String>>,
1689) -> Response {
1690 if let AuthIdentity::Role(_) = identity {
1692 return forbidden(
1693 "role-bound token: /explain requires a full-access token \
1694 (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1695 );
1696 }
1697 let a = match qs.get("a") {
1698 Some(s) if !s.is_empty() => s.clone(),
1699 _ => return err_response("missing query param a"),
1700 };
1701 let b = match qs.get("b") {
1702 Some(s) if !s.is_empty() => s.clone(),
1703 _ => return err_response("missing query param b"),
1704 };
1705 let out = {
1706 let g = state.db.read();
1707 g.explain(&a, &b)
1708 };
1709 match out {
1710 Ok(v) => match serde_json::to_value(&v) {
1711 Ok(j) => json_ok(j),
1712 Err(e) => err_response(e.to_string()),
1713 },
1714 Err(e) => graph_err(e),
1715 }
1716}
1717
1718async fn node_info(
1719 State(state): State<AppState>,
1720 Extension(identity): Extension<AuthIdentity>,
1721 Path(key): Path<String>,
1722 Query(qs): Query<BTreeMap<String, String>>,
1723) -> Response {
1724 if let AuthIdentity::Role(ref role_name) = identity {
1725 let snap = state.db.reader();
1729 let role_mask = match snap.mask_for_role(role_name) {
1730 Ok(m) => m,
1731 Err(e) => return role_mask_err(e),
1732 };
1733 if !snap
1734 .resolve_key(&key)
1735 .is_some_and(|id| role_mask.contains_id(id))
1736 {
1737 return key_not_found(key);
1738 }
1739 return match snap.node_info(&key) {
1740 Some(info) => json_ok(node_info_json(&info)),
1741 None => key_not_found(key),
1742 };
1743 }
1744
1745 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1749 if !mask_param.is_empty() {
1750 let stub_hidden = qs
1751 .get("stub_hidden")
1752 .map(|v| v == "true" || v == "1")
1753 .unwrap_or(false);
1754 let g = state.db.read();
1755 let mask = {
1756 let keys = mask_param
1757 .split(',')
1758 .map(str::trim)
1759 .filter(|s| !s.is_empty());
1760 let m = NodeMask::from_keys(&*g, keys);
1761 if stub_hidden {
1762 m.with_mode(MaskMode::Stub)
1763 } else {
1764 m
1765 }
1766 };
1767 return match g.node_info_masked(&key, &mask) {
1768 Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1769 Some(core_api::MaskedNodeResult::Restricted) => {
1770 json_ok(crate::json::stub_node_json(&key))
1771 }
1772 None => key_not_found(key),
1773 };
1774 }
1775
1776 let info = {
1777 let g = state.db.read();
1778 g.node_info(&key)
1779 };
1780 match info {
1781 Some(info) => json_ok(node_info_json(&info)),
1782 None => key_not_found(key),
1783 }
1784}
1785
1786async fn node_edges(
1787 State(state): State<AppState>,
1788 Extension(identity): Extension<AuthIdentity>,
1789 Path(key): Path<String>,
1790 Query(qs): Query<BTreeMap<String, String>>,
1791) -> Response {
1792 if let AuthIdentity::Role(ref role_name) = identity {
1793 let snap = state.db.reader();
1800 let role_mask = match snap.mask_for_role(role_name) {
1801 Ok(m) => m,
1802 Err(e) => return role_mask_err(e),
1803 };
1804 return match snap.node_edges_scoped(&key, &role_mask) {
1805 Ok(edges) => json_ok(node_edges_json(&edges)),
1806 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1807 Err(e) => graph_err(e),
1808 };
1809 }
1810
1811 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1813 if !mask_param.is_empty() {
1814 let stub_hidden = qs
1815 .get("stub_hidden")
1816 .map(|v| v == "true" || v == "1")
1817 .unwrap_or(false);
1818 let g = state.db.read();
1819 let mask = {
1820 let keys = mask_param
1821 .split(',')
1822 .map(str::trim)
1823 .filter(|s| !s.is_empty());
1824 let m = NodeMask::from_keys(&*g, keys);
1825 if stub_hidden {
1826 m.with_mode(MaskMode::Stub)
1827 } else {
1828 m
1829 }
1830 };
1831 return match g.node_edges_masked(&key, &mask) {
1832 Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1833 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1834 Err(e) => graph_err(e),
1835 };
1836 }
1837
1838 let out = {
1839 let g = state.db.read();
1840 g.node_edges(&key)
1841 };
1842 match out {
1843 Ok(edges) => json_ok(node_edges_json(&edges)),
1844 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1845 Err(e) => graph_err(e),
1846 }
1847}
1848
1849async fn neighborhood(
1850 State(state): State<AppState>,
1851 Extension(identity): Extension<AuthIdentity>,
1852 Path(key): Path<String>,
1853 Query(qs): Query<BTreeMap<String, String>>,
1854) -> Response {
1855 let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1856 Ok(d) => d,
1857 Err(e) => return err_response(e),
1858 };
1859 let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1860 s if s.eq_ignore_ascii_case("out") => Dir::Out,
1861 s if s.eq_ignore_ascii_case("in") => Dir::In,
1862 s if s.eq_ignore_ascii_case("both") => Dir::Both,
1863 other => return err_response(format!("unknown dir: {other}")),
1864 };
1865 let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1866 s.split(',')
1867 .map(str::trim)
1868 .filter(|t| !t.is_empty())
1869 .map(str::to_string)
1870 .collect()
1871 });
1872 let etype_refs: Option<Vec<&str>> = edge_type_names
1873 .as_ref()
1874 .map(|v| v.iter().map(String::as_str).collect());
1875 if let AuthIdentity::Role(ref role_name) = identity {
1876 let snap = state.db.reader();
1879 let role_mask = match snap.mask_for_role(role_name) {
1880 Ok(m) => m,
1881 Err(e) => return role_mask_err(e),
1882 };
1883 return match snap.neighborhood_scoped(&key, depth, etype_refs.as_deref(), dir, &role_mask) {
1887 Ok(rs) => json_ok(result_set_json(&rs)),
1888 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1889 Err(e) => graph_err(e),
1890 };
1891 }
1892 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1898 if !mask_param.is_empty() {
1899 let stub_hidden = qs
1900 .get("stub_hidden")
1901 .map(|v| v == "true" || v == "1")
1902 .unwrap_or(false);
1903 let g = state.db.read();
1904 let mask = {
1905 let keys = mask_param
1906 .split(',')
1907 .map(str::trim)
1908 .filter(|s| !s.is_empty());
1909 let m = NodeMask::from_keys(&*g, keys);
1910 if stub_hidden {
1911 m.with_mode(MaskMode::Stub)
1912 } else {
1913 m
1914 }
1915 };
1916 return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1917 Some(rs) => json_ok(result_set_json(&rs)),
1918 None => key_not_found(key),
1919 };
1920 }
1921
1922 let rs = {
1924 let g = state.db.read();
1925 match g.node_ref(&key) {
1926 Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1927 None => Err(GraphError::KeyNotFound { key: key.clone() }),
1928 }
1929 };
1930 match rs {
1931 Ok(rs) => json_ok(result_set_json(&rs)),
1932 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1933 Err(e) => graph_err(e),
1934 }
1935}
1936
1937async fn find_similar(
1941 State(state): State<AppState>,
1942 Extension(identity): Extension<AuthIdentity>,
1943 Json(body): Json<Js>,
1944) -> Response {
1945 let field = match body.get("field").and_then(Js::as_str) {
1946 Some(s) if !s.is_empty() => s.to_string(),
1947 _ => return err_response("missing field"),
1948 };
1949 let vector = match parse_find_similar_vector(body.get("vector")) {
1950 Ok(v) => v,
1951 Err(e) => return err_response(e),
1952 };
1953 let k = match body.get("k") {
1954 None | Some(Js::Null) => 10usize,
1955 Some(v) => match v.as_u64() {
1956 Some(n) => n as usize,
1957 None => return err_response("k must be a non-negative integer"),
1958 },
1959 };
1960 let min = match body.get("min") {
1961 None | Some(Js::Null) => 0.0,
1962 Some(v) => match v.as_f64() {
1963 Some(n) => n,
1964 None => return err_response("min must be a number"),
1965 },
1966 };
1967 let label = match body.get("label").and_then(Js::as_str) {
1968 None | Some("") => None,
1969 Some(s) => Some(s.to_string()),
1970 };
1971 let mask_keys = match parse_mask_keys(body.get("mask")) {
1972 Ok(m) => m,
1973 Err(e) => return err_response(e),
1974 };
1975 let where_pred = match parse_where_body(body.get("where")) {
1976 Ok(p) => p,
1977 Err(detail) => return graph_err(GraphError::QueryError { detail }),
1978 };
1979 let exact = match body.get("exact") {
1980 None | Some(Js::Null) => false,
1981 Some(v) => match v.as_bool() {
1982 Some(b) => b,
1983 None => return err_response("exact must be a boolean"),
1984 },
1985 };
1986 let exact = exact || where_pred.is_some();
1987 let role_name = match identity {
1988 AuthIdentity::Role(r) => Some(r),
1989 AuthIdentity::Full => None,
1990 AuthIdentity::Anonymous => {
1995 return forbidden("this endpoint requires a token");
1996 }
1997 };
1998 let db = state.db.clone();
1999 match tokio::task::spawn_blocking(move || {
2000 let g = db.read();
2001 let role_mask = match role_name.as_deref() {
2002 Some(role) => Some(g.mask_for_role(role)?),
2003 None => None,
2004 };
2005 let client_mask = mask_keys
2006 .as_ref()
2007 .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
2008 let effective = match (role_mask, client_mask) {
2009 (Some(role), Some(client)) => Some(role.intersect(&client)),
2010 (Some(role), None) => Some(role),
2011 (None, Some(client)) => Some(client),
2012 (None, None) => None,
2013 };
2014 g.find_similar_vector_filtered(
2015 &field,
2016 label.as_deref(),
2017 &vector,
2018 k,
2019 min,
2020 effective.as_ref(),
2021 where_pred.as_ref(),
2022 exact,
2023 )
2024 })
2025 .await
2026 {
2027 Ok(Ok(hits)) => json_ok(json!({ "hits": hits })),
2028 Ok(Err(e)) => role_mask_err(e),
2029 Err(_) => err_response("find_similar task panicked"),
2030 }
2031}
2032
2033async fn algo_pagerank(
2042 State(state): State<AppState>,
2043 Extension(identity): Extension<AuthIdentity>,
2044 Json(body): Json<serde_json::Value>,
2045) -> Response {
2046 if let AuthIdentity::Role(_) = identity {
2048 return forbidden("role-bound token: /algo/* requires a full-access token");
2049 }
2050 let config: PageRankConfig = match serde_json::from_value(body) {
2051 Ok(c) => c,
2052 Err(e) => return err_response(format!("invalid pagerank config: {e}")),
2053 };
2054 let db = state.db.clone();
2055 match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
2056 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
2057 Err(_) => err_response("pagerank task panicked"),
2058 }
2059}
2060
2061async fn algo_wcc(
2066 State(state): State<AppState>,
2067 Extension(identity): Extension<AuthIdentity>,
2068 Json(body): Json<serde_json::Value>,
2069) -> Response {
2070 if let AuthIdentity::Role(_) = identity {
2071 return forbidden("role-bound token: /algo/* requires a full-access token");
2072 }
2073 let config: WccConfig = match serde_json::from_value(body) {
2074 Ok(c) => c,
2075 Err(e) => return err_response(format!("invalid wcc config: {e}")),
2076 };
2077 let db = state.db.clone();
2078 match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
2079 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
2080 Err(_) => err_response("wcc task panicked"),
2081 }
2082}
2083
2084async fn algo_degree(
2088 State(state): State<AppState>,
2089 Extension(identity): Extension<AuthIdentity>,
2090 Json(body): Json<serde_json::Value>,
2091) -> Response {
2092 if let AuthIdentity::Role(_) = identity {
2093 return forbidden("role-bound token: /algo/* requires a full-access token");
2094 }
2095 let config: DegreeConfig = match serde_json::from_value(body) {
2096 Ok(c) => c,
2097 Err(e) => return err_response(format!("invalid degree config: {e}")),
2098 };
2099 let db = state.db.clone();
2100 match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
2101 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
2102 Err(_) => err_response("degree task panicked"),
2103 }
2104}
2105
2106fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
2119 let obj = match v.as_object() {
2120 Some(o) => o,
2121 None => return Err("props must be a JSON object".into()),
2122 };
2123 let mut out = Vec::with_capacity(obj.len());
2124 for (k, val) in obj {
2125 if let Some(v) = json_to_value(val.clone()) {
2126 out.push((k.clone(), v));
2127 }
2128 }
2129 Ok(out)
2130}
2131
2132async fn create_node(
2136 State(state): State<AppState>,
2137 Extension(identity): Extension<AuthIdentity>,
2138 Json(body): Json<Js>,
2139) -> Response {
2140 let label = match body.get("label").and_then(Js::as_str) {
2141 Some(s) => s.to_string(),
2142 None => return err_response("missing label"),
2143 };
2144 let key = match body.get("key").and_then(Js::as_str) {
2145 Some(s) => s.to_string(),
2146 None => return err_response("missing key"),
2147 };
2148 let mut props = match body.get("props") {
2149 None | Some(Js::Null) => vec![],
2150 Some(v) => match props_from_json_obj(v) {
2151 Ok(p) => p,
2152 Err(e) => return err_response(e),
2153 },
2154 };
2155 match namespace_arg(body.get("namespace")) {
2158 Ok(None) => {}
2159 Ok(Some(ns)) => {
2160 let mut row: BTreeMap<String, Value> = props.into_iter().collect();
2161 if let Err(e) = stamp_namespace_row(&mut row, &ns) {
2162 return err_response(e);
2163 }
2164 props = row.into_iter().collect();
2165 }
2166 Err(e) => return err_response(e),
2167 }
2168 let db = state.db.clone();
2169 if let AuthIdentity::Role(role_name) = &identity {
2170 let role = role_name.clone();
2171 return match blocking_write(move || {
2172 db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
2173 })
2174 .await
2175 {
2176 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
2177 Err(resp) => resp,
2178 };
2179 }
2180 match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
2181 .await
2182 {
2183 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
2184 Err(resp) => resp,
2185 }
2186}
2187
2188async fn delete_node(
2190 State(state): State<AppState>,
2191 Extension(identity): Extension<AuthIdentity>,
2192 Path(key): Path<String>,
2193) -> Response {
2194 let db = state.db.clone();
2195 if let AuthIdentity::Role(role_name) = &identity {
2196 let role = role_name.clone();
2197 return match blocking_write(move || {
2198 db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
2199 })
2200 .await
2201 {
2202 Ok(_) => json_ok(json!({"ok": true})),
2203 Err(resp) => resp,
2204 };
2205 }
2206 match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
2207 Ok(_) => json_ok(json!({"ok": true})),
2208 Err(resp) => resp,
2209 }
2210}
2211
2212async fn create_edge(
2216 State(state): State<AppState>,
2217 Extension(identity): Extension<AuthIdentity>,
2218 Json(body): Json<Js>,
2219) -> Response {
2220 let edge_type = match body.get("type").and_then(Js::as_str) {
2221 Some(s) => s.to_string(),
2222 None => return err_response("missing type"),
2223 };
2224 let src = match body.get("src").and_then(Js::as_str) {
2225 Some(s) => s.to_string(),
2226 None => return err_response("missing src"),
2227 };
2228 let dst = match body.get("dst").and_then(Js::as_str) {
2229 Some(s) => s.to_string(),
2230 None => return err_response("missing dst"),
2231 };
2232 let db = state.db.clone();
2233 if let AuthIdentity::Role(role_name) = &identity {
2234 let role = role_name.clone();
2235 return match blocking_write(move || {
2236 db.submit_batch_authz(
2237 role,
2238 vec![BatchOp::InsertEdge {
2239 edge_type,
2240 src_key: src,
2241 dst_key: dst,
2242 }],
2243 )
2244 })
2245 .await
2246 {
2247 Ok(_) => json_ok(json!({"ok": true})),
2248 Err(resp) => resp,
2249 };
2250 }
2251 match blocking_write(move || {
2252 db.submit_batch(vec![BatchOp::InsertEdge {
2253 edge_type,
2254 src_key: src,
2255 dst_key: dst,
2256 }])
2257 })
2258 .await
2259 {
2260 Ok(_) => json_ok(json!({"ok": true})),
2261 Err(resp) => resp,
2262 }
2263}
2264
2265async fn delete_edge(
2267 State(state): State<AppState>,
2268 Extension(identity): Extension<AuthIdentity>,
2269 Path((etype, src, dst)): Path<(String, String, String)>,
2270) -> Response {
2271 let db = state.db.clone();
2272 if let AuthIdentity::Role(role_name) = &identity {
2273 let role = role_name.clone();
2274 return match blocking_write(move || {
2275 db.submit_batch_authz(
2276 role,
2277 vec![BatchOp::DeleteEdge {
2278 edge_type: etype,
2279 src_key: src,
2280 dst_key: dst,
2281 }],
2282 )
2283 })
2284 .await
2285 {
2286 Ok(_) => json_ok(json!({"ok": true})),
2287 Err(resp) => resp,
2288 };
2289 }
2290 match blocking_write(move || {
2291 db.submit_batch(vec![BatchOp::DeleteEdge {
2292 edge_type: etype,
2293 src_key: src,
2294 dst_key: dst,
2295 }])
2296 })
2297 .await
2298 {
2299 Ok(_) => json_ok(json!({"ok": true})),
2300 Err(resp) => resp,
2301 }
2302}
2303
2304async fn rename_node(
2309 State(state): State<AppState>,
2310 Extension(identity): Extension<AuthIdentity>,
2311 Path(key): Path<String>,
2312 Json(body): Json<Js>,
2313) -> Response {
2314 if let AuthIdentity::Role(_) = identity {
2315 return forbidden("role-bound token: writes are not permitted");
2316 }
2317 let new_key = match body.get("new_key").and_then(Js::as_str) {
2318 Some(s) => s.to_string(),
2319 None => return err_response("missing new_key"),
2320 };
2321 let db = state.db.clone();
2322 match tokio::task::spawn_blocking(move || {
2323 db.submit_batch(vec![BatchOp::RenameNode {
2324 old_key: key,
2325 new_key,
2326 }])
2327 })
2328 .await
2329 {
2330 Ok(Ok(_)) => json_ok(json!({"ok": true})),
2331 Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
2332 Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
2333 Ok(Err(e)) => graph_err(e),
2334 Err(_) => err_response("write task panicked"),
2335 }
2336}
2337
2338async fn upsert_edge(
2343 State(state): State<AppState>,
2344 Extension(identity): Extension<AuthIdentity>,
2345 Json(body): Json<Js>,
2346) -> Response {
2347 let edge_type = match body.get("edge_type").and_then(Js::as_str) {
2348 Some(s) => s.to_string(),
2349 None => return err_response("missing edge_type"),
2350 };
2351 let src_key = match body.get("src_key").and_then(Js::as_str) {
2352 Some(s) => s.to_string(),
2353 None => return err_response("missing src_key"),
2354 };
2355 let dst_key = match body.get("dst_key").and_then(Js::as_str) {
2356 Some(s) => s.to_string(),
2357 None => return err_response("missing dst_key"),
2358 };
2359 let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
2360 Some(s) => s.to_string(),
2361 None => return err_response("missing placeholder_label"),
2362 };
2363 let db = state.db.clone();
2364 if let AuthIdentity::Role(role_name) = &identity {
2365 let role = role_name.clone();
2366 return match blocking_write(move || {
2367 db.submit_batch_authz(
2368 role,
2369 vec![BatchOp::InsertEdgeUpsert {
2370 edge_type,
2371 src_key,
2372 dst_key,
2373 placeholder_label,
2374 }],
2375 )
2376 })
2377 .await
2378 {
2379 Ok((nodes, edges)) => json_ok(json!({
2380 "nodes_created": nodes,
2381 "edge_inserted": edges > 0,
2382 })),
2383 Err(resp) => resp,
2384 };
2385 }
2386 match blocking_write(move || {
2387 db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
2388 edge_type,
2389 src_key,
2390 dst_key,
2391 placeholder_label,
2392 }])
2393 })
2394 .await
2395 {
2396 Ok((nodes, edges)) => json_ok(json!({
2397 "nodes_created": nodes,
2398 "edge_inserted": edges > 0,
2399 })),
2400 Err(resp) => resp,
2401 }
2402}
2403
2404async fn set_node_prop(
2408 State(state): State<AppState>,
2409 Extension(identity): Extension<AuthIdentity>,
2410 Path((key, field)): Path<(String, String)>,
2411 Json(body): Json<Js>,
2412) -> Response {
2413 let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
2414 Some(v) => v,
2415 None => {
2416 return err_response(
2417 "request body must be a JSON object with a \"value\" field, \
2418 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
2419 )
2420 }
2421 };
2422 let db = state.db.clone();
2423 if let AuthIdentity::Role(role_name) = &identity {
2424 let role = role_name.clone();
2425 return match blocking_write(move || {
2426 db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
2427 })
2428 .await
2429 {
2430 Ok(_) => json_ok(json!({"ok": true})),
2431 Err(resp) => resp,
2432 };
2433 }
2434 match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
2435 .await
2436 {
2437 Ok(_) => json_ok(json!({"ok": true})),
2438 Err(resp) => resp,
2439 }
2440}
2441
2442async fn node_history_handler(
2460 State(state): State<AppState>,
2461 Extension(identity): Extension<AuthIdentity>,
2462 Path(key): Path<String>,
2463) -> Response {
2464 if let AuthIdentity::Role(ref role_name) = identity {
2465 let g = state.db.read();
2466 let role_mask = match g.mask_for_role(role_name) {
2467 Ok(m) => m,
2468 Err(e) => return role_mask_err(e),
2469 };
2470 if !role_mask.contains_node(&*g, &key) {
2472 return key_not_found(key);
2473 }
2474 let result = match g.node_history(&key) {
2475 Ok(e) => e,
2476 Err(e) => return graph_err(e),
2477 };
2478 use core_api::HistoryChange;
2482 let visible = core_api::HistoryResult {
2483 total_commits: result.total_commits,
2484 horizon: result.horizon,
2485 items: result
2486 .items
2487 .into_iter()
2488 .filter(|entry| match &entry.change {
2489 HistoryChange::EdgeAdded { other, .. }
2490 | HistoryChange::EdgeRemoved { other, .. } => {
2491 role_mask.contains_node(&*g, other)
2492 }
2493 _ => true,
2494 })
2495 .collect(),
2496 };
2497 return json_ok(node_history_json(&key, &visible));
2498 }
2499 let g = state.db.read();
2502 if !g.has_node(&key) {
2503 return key_not_found(key);
2504 }
2505 let result = match g.node_history(&key) {
2506 Ok(e) => e,
2507 Err(e) => return graph_err(e),
2508 };
2509 json_ok(node_history_json(&key, &result))
2510}
2511
2512async fn edge_history_handler(
2520 State(state): State<AppState>,
2521 Extension(identity): Extension<AuthIdentity>,
2522 Query(qs): Query<BTreeMap<String, String>>,
2523) -> Response {
2524 let a = match qs.get("a").filter(|s| !s.is_empty()) {
2525 Some(s) => s.clone(),
2526 None => return err_response("missing query param a"),
2527 };
2528 let b = match qs.get("b").filter(|s| !s.is_empty()) {
2529 Some(s) => s.clone(),
2530 None => return err_response("missing query param b"),
2531 };
2532 if let AuthIdentity::Role(ref role_name) = identity {
2533 let g = state.db.read();
2534 let role_mask = match g.mask_for_role(role_name) {
2535 Ok(m) => m,
2536 Err(e) => return role_mask_err(e),
2537 };
2538 if !role_mask.contains_node(&*g, &a) {
2540 return key_not_found(a);
2541 }
2542 if !role_mask.contains_node(&*g, &b) {
2543 return key_not_found(b);
2544 }
2545 let result = match g.edge_history(&a, &b) {
2546 Ok(r) => r,
2547 Err(e) => return graph_err(e),
2548 };
2549 return json_ok(edge_history_result_json(&a, &b, &result));
2550 }
2551 let g = state.db.read();
2553 let result = match g.edge_history(&a, &b) {
2554 Ok(r) => r,
2555 Err(e) => return graph_err(e),
2556 };
2557 json_ok(edge_history_result_json(&a, &b, &result))
2558}
2559
2560async fn was_linked_handler(
2566 State(state): State<AppState>,
2567 Extension(identity): Extension<AuthIdentity>,
2568 Query(qs): Query<BTreeMap<String, String>>,
2569) -> Response {
2570 let a = match qs.get("a").filter(|s| !s.is_empty()) {
2571 Some(s) => s.clone(),
2572 None => return err_response("missing query param a"),
2573 };
2574 let b = match qs.get("b").filter(|s| !s.is_empty()) {
2575 Some(s) => s.clone(),
2576 None => return err_response("missing query param b"),
2577 };
2578 let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
2579 Some(s) => s.clone(),
2580 None => return err_response("missing query param edge_type"),
2581 };
2582 let at_commit: u64 = match qs.get("at_commit") {
2587 Some(s) => match s.parse::<u64>() {
2588 Ok(n) => n,
2589 Err(_) => match state.db.read().resolve_date(s) {
2590 Ok(n) => n,
2591 Err(e) => return graph_err(e),
2592 },
2593 },
2594 None => return err_response("missing query param at_commit"),
2595 };
2596
2597 if let AuthIdentity::Role(ref role_name) = identity {
2598 let g = state.db.read();
2599 let role_mask = match g.mask_for_role(role_name) {
2600 Ok(m) => m,
2601 Err(e) => return role_mask_err(e),
2602 };
2603 if !role_mask.contains_node(&*g, &a) {
2604 return key_not_found(a);
2605 }
2606 if !role_mask.contains_node(&*g, &b) {
2607 return key_not_found(b);
2608 }
2609 return match g.was_linked(&a, &b, &edge_type, at_commit) {
2610 Ok(linked) => json_ok(json!({
2611 "a": a, "b": b, "edge_type": edge_type,
2612 "at_commit": at_commit, "linked": linked,
2613 })),
2614 Err(e @ GraphError::CommitOutOfRange { .. }) => (
2615 StatusCode::BAD_REQUEST,
2616 Json(json!({"error": e.to_string()})),
2617 )
2618 .into_response(),
2619 Err(e) => graph_err(e),
2620 };
2621 }
2622
2623 let g = state.db.read();
2625 match g.was_linked(&a, &b, &edge_type, at_commit) {
2626 Ok(linked) => json_ok(json!({
2627 "a": a, "b": b, "edge_type": edge_type,
2628 "at_commit": at_commit, "linked": linked,
2629 })),
2630 Err(e @ GraphError::CommitOutOfRange { .. }) => (
2631 StatusCode::BAD_REQUEST,
2632 Json(json!({"error": e.to_string()})),
2633 )
2634 .into_response(),
2635 Err(e) => graph_err(e),
2636 }
2637}
2638
2639async fn remove_node_prop(
2641 State(state): State<AppState>,
2642 Extension(identity): Extension<AuthIdentity>,
2643 Path((key, field)): Path<(String, String)>,
2644) -> Response {
2645 let db = state.db.clone();
2646 if let AuthIdentity::Role(role_name) = &identity {
2647 let role = role_name.clone();
2648 return match blocking_write(move || {
2649 db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
2650 })
2651 .await
2652 {
2653 Ok(_) => json_ok(json!({"ok": true})),
2654 Err(resp) => resp,
2655 };
2656 }
2657 match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
2658 Ok(_) => json_ok(json!({"ok": true})),
2659 Err(resp) => resp,
2660 }
2661}
2662
2663fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2668 if a.len() != b.len() {
2669 return false;
2670 }
2671 let mut diff = 0u8;
2672 for (x, y) in a.iter().zip(b.iter()) {
2673 diff |= x ^ y;
2674 }
2675 diff == 0
2676}
2677
2678const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
2683
2684fn parse_find_similar_vector(v: Option<&Js>) -> Result<Vec<f64>, String> {
2686 let Some(arr) = v.and_then(Js::as_array) else {
2687 return Err("missing vector".into());
2688 };
2689 let mut out = Vec::with_capacity(arr.len());
2690 for item in arr {
2691 match item.as_f64() {
2692 Some(n) => out.push(n),
2693 None => return Err("vector must be an array of numbers".into()),
2694 }
2695 }
2696 if out.is_empty() {
2697 return Err("vector must be a non-empty array of numbers".into());
2698 }
2699 Ok(out)
2700}
2701
2702fn parse_mask_keys(v: Option<&Js>) -> Result<Option<Vec<String>>, String> {
2704 match v {
2705 None | Some(Js::Null) => Ok(None),
2706 Some(Js::Array(arr)) => {
2707 let mut keys = Vec::with_capacity(arr.len());
2708 for item in arr {
2709 match item.as_str() {
2710 Some(s) => keys.push(s.to_string()),
2711 None => return Err("mask must be an array of strings".into()),
2712 }
2713 }
2714 Ok(Some(keys))
2715 }
2716 Some(_) => Err("mask must be an array of strings".into()),
2717 }
2718}
2719
2720fn parse_where_body(v: Option<&Js>) -> Result<Option<PropPredicate>, String> {
2722 match v {
2723 None | Some(Js::Null) => Ok(None),
2724 Some(w) => {
2725 let pred: PropPredicate =
2726 serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
2727 pred.validate_named("where")?;
2728 Ok(Some(pred))
2729 }
2730 }
2731}
2732
2733fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
2735 match raw {
2736 None => Ok(1),
2737 Some(s) => {
2738 let d: u32 = s
2739 .parse()
2740 .map_err(|_| "depth must be an integer".to_string())?;
2741 if d > MAX_NEIGHBORHOOD_DEPTH {
2742 return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
2743 }
2744 Ok(d)
2745 }
2746 }
2747}
2748
2749fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
2757 if dest.is_empty() {
2758 return Err("missing or empty \"dest\" field".into());
2759 }
2760 let dest_path = std::path::Path::new(dest);
2761 if dest_path
2762 .components()
2763 .any(|c| matches!(c, std::path::Component::ParentDir))
2764 {
2765 return Err("backup \"dest\" may not contain \"..\" path segments".into());
2766 }
2767 let joined = if dest_path.is_absolute() {
2768 dest_path.to_path_buf()
2769 } else {
2770 root.join(dest_path)
2771 };
2772 if !joined.starts_with(root) {
2773 return Err(format!(
2774 "backup \"dest\" must be within the backup root ({}); \
2775 set MUSHROOMDB_BACKUP_DIR to change it",
2776 root.display()
2777 ));
2778 }
2779 Ok(joined)
2780}
2781
2782fn backup_root() -> PathBuf {
2785 std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2786 .map(PathBuf::from)
2787 .or_else(|| std::env::current_dir().ok())
2788 .unwrap_or_else(|| PathBuf::from("."))
2789}
2790
2791async fn backup(
2812 State(state): State<AppState>,
2813 Extension(identity): Extension<AuthIdentity>,
2814 Json(body): Json<Js>,
2815) -> Response {
2816 if let AuthIdentity::Role(_) = identity {
2817 return forbidden("role-bound token: /backup requires a full-access token");
2818 }
2819 let root = backup_root();
2820 let dest = match body.get("dest").and_then(Js::as_str) {
2821 Some(s) => match confine_backup_dest(s, &root) {
2822 Ok(p) => p,
2823 Err(e) => return err_response(e),
2824 },
2825 None => return err_response("missing or empty \"dest\" field"),
2826 };
2827 let db = state.db.clone();
2828 let report: BackupReport = match tokio::task::spawn_blocking(move || {
2829 let g = db.read();
2832 g.backup_to(&dest)
2833 })
2834 .await
2835 {
2836 Ok(Ok(r)) => r,
2837 Ok(Err(e)) => return graph_err(e),
2838 Err(_) => return err_response("backup task panicked"),
2839 };
2840
2841 let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2842 Ok(v) => v,
2843 Err(e) => return err_response(e.to_string()),
2844 };
2845
2846 if report.verified {
2847 json_ok(body)
2848 } else {
2849 (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2850 }
2851}
2852
2853#[derive(serde::Serialize)]
2855struct BackupReportJson<'a> {
2856 files: &'a [String],
2857 bytes: u64,
2858 verified: bool,
2859}
2860
2861impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2862 fn from(r: &'a BackupReport) -> Self {
2863 Self {
2864 files: &r.files,
2865 bytes: r.bytes,
2866 verified: r.verified,
2867 }
2868 }
2869}
2870
2871#[cfg(test)]
2872mod tests {
2873 use super::*;
2874 use crate::json::result_set_json;
2875 use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2876
2877 #[test]
2878 fn nan_float_cell_serializes_as_null() {
2879 let mut rs = ResultSet::new(vec!["n".into()]);
2880 rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2881 let j = result_set_json(&rs);
2882 assert_eq!(j["rows"][0][0], Js::Null);
2883 }
2884
2885 #[test]
2888 fn pagerank_config_empty_body_uses_defaults() {
2889 let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2890 let default = PageRankConfig::default();
2891 assert_eq!(config.damping, default.damping);
2892 assert_eq!(config.max_iters, default.max_iters);
2893 assert_eq!(config.tol, default.tol);
2894 assert_eq!(config.budget_ms, default.budget_ms);
2895 assert_eq!(config.edge_type, default.edge_type);
2896 }
2897
2898 #[test]
2900 fn wcc_config_empty_body_uses_defaults() {
2901 let config: WccConfig = serde_json::from_str("{}").unwrap();
2902 let default = WccConfig::default();
2903 assert_eq!(config.budget_ms, default.budget_ms);
2904 assert_eq!(config.edge_type, default.edge_type);
2905 }
2906
2907 #[test]
2909 fn degree_config_empty_body_uses_defaults() {
2910 let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2911 let default = DegreeConfig::default();
2912 assert_eq!(config.budget_ms, default.budget_ms);
2913 assert_eq!(config.edge_type, default.edge_type);
2914 }
2915
2916 #[test]
2917 fn backup_dest_rejects_empty() {
2918 assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2919 }
2920
2921 #[test]
2922 fn constant_time_eq_matches_equality() {
2923 assert!(constant_time_eq(b"secret-token", b"secret-token"));
2924 assert!(constant_time_eq(b"", b""));
2925 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"));
2929 }
2930
2931 #[test]
2932 fn depth_defaults_to_one_when_absent() {
2933 assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2934 }
2935
2936 #[test]
2937 fn depth_within_cap_is_accepted() {
2938 assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2939 assert_eq!(
2940 resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2941 MAX_NEIGHBORHOOD_DEPTH
2942 );
2943 }
2944
2945 #[test]
2946 fn depth_over_cap_is_rejected() {
2947 assert!(resolve_neighborhood_depth(Some("65")).is_err());
2948 assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2949 }
2950
2951 #[test]
2952 fn depth_non_integer_is_rejected() {
2953 assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2954 }
2955
2956 #[test]
2957 fn backup_dest_rejects_parent_traversal() {
2958 let root = std::path::Path::new("/srv/backups");
2959 assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2960 assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2961 }
2962
2963 #[test]
2964 fn backup_dest_rejects_absolute_outside_root() {
2965 let root = std::path::Path::new("/srv/backups");
2966 assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2967 assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2968 }
2969
2970 #[test]
2971 fn backup_dest_allows_relative_within_root() {
2972 let root = std::path::Path::new("/srv/backups");
2973 assert_eq!(
2974 confine_backup_dest("nightly", root).unwrap(),
2975 PathBuf::from("/srv/backups/nightly")
2976 );
2977 assert_eq!(
2978 confine_backup_dest("2026/aug", root).unwrap(),
2979 PathBuf::from("/srv/backups/2026/aug")
2980 );
2981 }
2982
2983 #[test]
2984 fn backup_dest_allows_absolute_within_root() {
2985 let root = std::path::Path::new("/srv/backups");
2986 assert_eq!(
2987 confine_backup_dest("/srv/backups/x", root).unwrap(),
2988 PathBuf::from("/srv/backups/x")
2989 );
2990 }
2991}