pub(crate) mod map;
pub(crate) mod routes;
use std::path::{Path, PathBuf};
use std::time::Duration;
use axum::body::Body;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde_json::{Value, json};
use trusty_common::uds::server::{
CODE_INTERNAL_ERROR, CODE_INVALID_PARAMS, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND,
CODE_PARSE_ERROR, RpcResponse,
};
use trusty_common::uds::stream_client::FramedStream;
pub(crate) const SEARCH_SERVICE: &str = "trusty-search";
pub(crate) const METHOD_HEALTH: &str = "search.health";
pub(crate) const METHOD_INDEXES_LIST: &str = "search.indexes.list";
pub(crate) const METHOD_INDEX_CREATE: &str = "search.index.create";
pub(crate) const METHOD_INDEX_DELETE: &str = "search.index.delete";
pub(crate) const METHOD_INDEX_STATUS: &str = "search.index.status";
pub(crate) const METHOD_INDEX_CONFIG_GET: &str = "search.index.config.get";
pub(crate) const METHOD_INDEX_CONFIG_SET: &str = "search.index.config.set";
pub(crate) const METHOD_QUERY: &str = "search.query";
pub(crate) const METHOD_QUERY_ALL: &str = "search.query.all";
pub(crate) const METHOD_INDEX_REINDEX: &str = "search.index.reindex";
pub(crate) const METHOD_CONFIG_GET: &str = "search.config.get";
pub(crate) const METHOD_CONFIG_SET: &str = "search.config.set";
pub(crate) const METHOD_LOGS_TAIL: &str = "search.logs.tail";
pub(crate) const METHOD_REGISTRY_ORPHANS: &str = "search.registry.orphans";
pub(crate) const METHOD_STATUS_STREAM: &str = "search.status.stream";
pub(crate) const METHOD_INDEX_REINDEX_STREAM: &str = "search.index.reindex.stream";
pub(crate) const CALL_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
pub(crate) const STREAM_FRAME_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
pub(crate) const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const MAX_FRAME_BYTES: u64 = 64 * 1024 * 1024;
const CODE_NOT_FOUND: i64 = -32004;
const CODE_UNAVAILABLE: i64 = -32002;
const CODE_UNAVAILABLE_PERMANENT: i64 = -32012;
const CODE_DEADLINE_EXCEEDED: i64 = -32005;
const CODE_FORBIDDEN: i64 = -32003;
const CODE_CONFLICT: i64 = -32009;
const CODE_TOO_MANY_REQUESTS: i64 = -32013;
#[derive(Debug)]
pub(crate) enum SearchRpcError {
Unresolved(String),
Unreachable(String),
Refused { code: i64, message: String },
Malformed(String),
}
impl SearchRpcError {
pub(crate) fn status(&self) -> StatusCode {
match self {
Self::Unresolved(_) | Self::Unreachable(_) | Self::Malformed(_) => {
StatusCode::BAD_GATEWAY
}
Self::Refused { code, .. } => match *code {
CODE_NOT_FOUND => StatusCode::NOT_FOUND,
CODE_UNAVAILABLE | CODE_UNAVAILABLE_PERMANENT => StatusCode::SERVICE_UNAVAILABLE,
CODE_DEADLINE_EXCEEDED => StatusCode::REQUEST_TIMEOUT,
CODE_FORBIDDEN => StatusCode::FORBIDDEN,
CODE_CONFLICT => StatusCode::CONFLICT,
CODE_TOO_MANY_REQUESTS => StatusCode::TOO_MANY_REQUESTS,
CODE_INVALID_PARAMS | CODE_INVALID_REQUEST => StatusCode::BAD_REQUEST,
CODE_METHOD_NOT_FOUND => StatusCode::NOT_IMPLEMENTED,
CODE_PARSE_ERROR | CODE_INTERNAL_ERROR => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
}
}
pub(crate) fn message(&self) -> &str {
match self {
Self::Unresolved(m) | Self::Unreachable(m) | Self::Malformed(m) => m,
Self::Refused { message, .. } => message,
}
}
}
impl IntoResponse for SearchRpcError {
fn into_response(self) -> Response {
let status = self.status();
let body = json!({
"error": self.message(),
"service": SEARCH_SERVICE,
});
(status, axum::Json(body)).into_response()
}
}
pub(crate) fn socket_path() -> Result<PathBuf, String> {
trusty_common::daemon_socket_path(SEARCH_SERVICE)
.map_err(|e| format!("could not resolve the {SEARCH_SERVICE} socket path: {e:#}"))
}
pub(crate) async fn call(
socket: &Path,
method: &str,
params: Value,
timeout: Duration,
) -> Result<Value, SearchRpcError> {
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let response: RpcResponse =
trusty_common::uds::send_framed_request_capped(socket, &request, timeout, MAX_FRAME_BYTES)
.await
.map_err(|e| {
SearchRpcError::Unreachable(format!(
"{SEARCH_SERVICE} did not answer {method}: {e}"
))
})?;
if let Some(error) = response.error {
return Err(SearchRpcError::Refused {
code: error.code,
message: error.message,
});
}
response.result.ok_or_else(|| {
SearchRpcError::Malformed(format!(
"{SEARCH_SERVICE} answered {method} with neither a result nor an error"
))
})
}
pub(crate) async fn open_stream(
socket: &Path,
method: &str,
params: Value,
open_timeout: Duration,
) -> Result<FramedStream<Value>, SearchRpcError> {
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
"stream": true,
});
let opened = tokio::time::timeout(
open_timeout,
trusty_common::uds::stream_client::send_framed_stream_request_capped(
socket,
&request,
STREAM_FRAME_TIMEOUT,
MAX_FRAME_BYTES,
),
)
.await
.map_err(|_| {
SearchRpcError::Unreachable(format!(
"{SEARCH_SERVICE} did not open {method} within {}s",
open_timeout.as_secs_f32()
))
})?;
opened.map_err(|e| {
SearchRpcError::Unreachable(format!("{SEARCH_SERVICE} did not open {method}: {e}"))
})
}
pub(crate) fn json_response(value: &Value) -> Response {
match serde_json::to_vec(value) {
Ok(bytes) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(bytes))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
Err(e) => SearchRpcError::Malformed(format!(
"{SEARCH_SERVICE} answered a body the console could not re-encode: {e}"
))
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn stub_daemon(dir: &Path, reply: impl Into<String>) -> PathBuf {
let socket = dir.join("sockets").join("search.sock");
let reply = reply.into();
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
tokio::spawn(async move {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let Ok((mut conn, _)) = listener.accept().await else {
return;
};
let mut sink = Vec::new();
let _ = conn.read_to_end(&mut sink).await;
let _ = conn.write_all(reply.as_bytes()).await;
let _ = conn.write_all(b"\n").await;
let _ = conn.flush().await;
});
socket
}
#[test]
fn error_status_maps_every_documented_code() {
let cases = [
(CODE_NOT_FOUND, StatusCode::NOT_FOUND),
(CODE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE),
(CODE_UNAVAILABLE_PERMANENT, StatusCode::SERVICE_UNAVAILABLE),
(CODE_DEADLINE_EXCEEDED, StatusCode::REQUEST_TIMEOUT),
(CODE_FORBIDDEN, StatusCode::FORBIDDEN),
(CODE_CONFLICT, StatusCode::CONFLICT),
(CODE_TOO_MANY_REQUESTS, StatusCode::TOO_MANY_REQUESTS),
(CODE_INVALID_PARAMS, StatusCode::BAD_REQUEST),
(CODE_INVALID_REQUEST, StatusCode::BAD_REQUEST),
(CODE_METHOD_NOT_FOUND, StatusCode::NOT_IMPLEMENTED),
(CODE_INTERNAL_ERROR, StatusCode::INTERNAL_SERVER_ERROR),
(CODE_PARSE_ERROR, StatusCode::INTERNAL_SERVER_ERROR),
(-31999, StatusCode::INTERNAL_SERVER_ERROR),
];
for (code, expected) in cases {
let err = SearchRpcError::Refused {
code,
message: "refused".to_string(),
};
assert_eq!(err.status(), expected, "code {code}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn call_reports_a_dead_socket_as_unreachable() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let err = call(
&tmp.path().join("absent.sock"),
METHOD_HEALTH,
json!({}),
Duration::from_secs(2),
)
.await
.expect_err("a dead socket is not a success");
assert!(matches!(err, SearchRpcError::Unreachable(_)), "{err:?}");
assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test(flavor = "multi_thread")]
async fn call_reports_a_jsonrpc_error_with_the_http_status_it_came_from() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_daemon(
tmp.path(),
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32004,"message":"no index 'ghost'"}}"#,
);
let err = call(
&socket,
METHOD_INDEX_STATUS,
json!({ "index_id": "ghost" }),
Duration::from_secs(5),
)
.await
.expect_err("an error frame is not a success");
assert_eq!(err.status(), StatusCode::NOT_FOUND);
assert!(err.message().contains("no index 'ghost'"), "{err:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn call_reports_an_empty_answer_as_malformed() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_daemon(tmp.path(), r#"{"jsonrpc":"2.0","id":1}"#);
let err = call(&socket, METHOD_HEALTH, json!({}), Duration::from_secs(5))
.await
.expect_err("an empty answer is not a success");
assert!(matches!(err, SearchRpcError::Malformed(_)), "{err:?}");
assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test(flavor = "multi_thread")]
async fn call_returns_the_daemon_result() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_daemon(
tmp.path(),
r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9"}}"#,
);
let result = call(&socket, METHOD_HEALTH, json!({}), Duration::from_secs(5))
.await
.expect("the exchange succeeds");
assert_eq!(result["version"], json!("9.9.9"));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_response_over_the_shared_default_is_read() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let big = "x".repeat(9 * 1024 * 1024);
let reply = json!({ "jsonrpc": "2.0", "id": 1, "result": { "blob": big } }).to_string();
assert!(
reply.len() as u64 > trusty_common::uds::MAX_FRAME_BYTES,
"the fixture must exceed the shared default to prove anything"
);
let socket = stub_daemon(tmp.path(), reply);
let result = call(
&socket,
METHOD_QUERY_ALL,
json!({}),
Duration::from_secs(20),
)
.await
.expect("an oversized-but-permitted response is read");
assert_eq!(result["blob"].as_str().map(str::len), Some(9 * 1024 * 1024));
}
#[tokio::test(flavor = "multi_thread")]
async fn open_stream_reports_a_dead_socket_as_unreachable() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let err = open_stream(
&tmp.path().join("absent.sock"),
METHOD_STATUS_STREAM,
json!({}),
STREAM_OPEN_TIMEOUT,
)
.await
.expect_err("a dead socket cannot open a stream");
assert!(matches!(err, SearchRpcError::Unreachable(_)), "{err:?}");
}
#[test]
fn the_frame_budget_is_at_least_the_listeners() {
let socket_rs = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../trusty-search/src/service/socket.rs");
let source = std::fs::read_to_string(&socket_rs)
.unwrap_or_else(|e| panic!("read {}: {e}", socket_rs.display()));
let decl = source
.lines()
.find_map(|line| {
line.trim()
.strip_prefix("pub const MAX_FRAME_BYTES: u64 = ")?
.strip_suffix(';')
.map(str::to_owned)
})
.unwrap_or_else(|| {
panic!(
"no `pub const MAX_FRAME_BYTES: u64 = …;` in {} — the listener's budget moved, \
so this floor is unverified",
socket_rs.display()
)
});
let listener: u64 = decl
.split('*')
.map(|factor| {
factor
.trim()
.parse::<u64>()
.unwrap_or_else(|e| panic!("parse `{decl}`: {e}"))
})
.product();
assert!(
MAX_FRAME_BYTES >= listener,
"this client's frame budget ({MAX_FRAME_BYTES}) is under the listener's ({listener}); \
a response trusty-search already produced would come back as FrameTooLarge"
);
}
#[test]
fn socket_path_matches_the_daemon_resolver() {
let _guard = crate::detect::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::TempDir::new().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let resolved = socket_path();
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert_eq!(
resolved.expect("the override resolves"),
tmp.path().join(SEARCH_SERVICE).join("trusty-search.sock")
);
}
}