use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::routes::deletes::delete_index_on_socket;
use crate::routes::memory_rpc;
use crate::routes::verdict::{ActionVerdict, validate_id};
use crate::routes::{MEMORY_SERVICE, SEARCH_SERVICE_ID};
use crate::server::AppState;
const MAX_PRUNE_BATCH: usize = 100;
const PRUNE_BUDGET: Duration = Duration::from_secs(120);
#[derive(Debug)]
pub(crate) struct PruneOutcome {
pub(crate) rows: Vec<Value>,
pub(crate) removed: usize,
pub(crate) failed: usize,
}
impl PruneOutcome {
fn body(&self) -> Value {
json!({
"ok": self.failed == 0,
"removed": self.removed,
"failed": self.failed,
"results": self.rows,
})
}
fn status(&self) -> StatusCode {
if self.failed == 0 {
StatusCode::OK
} else {
StatusCode::CONFLICT
}
}
}
pub(crate) async fn prune_indexes_on_socket(
socket: &Path,
ids: &[String],
delete_data: bool,
deadline: Instant,
) -> PruneOutcome {
let guard = crate::routes::census_guard::OrphanGuard::new(socket);
let mut outcome = PruneOutcome {
rows: Vec::with_capacity(ids.len()),
removed: 0,
failed: 0,
};
for id in ids {
if Instant::now() >= deadline {
outcome.failed += 1;
outcome.rows.push(json!({
"id": id,
"ok": false,
"error": format!(
"not attempted: the prune batch exceeded its {}s budget",
PRUNE_BUDGET.as_secs()
),
}));
continue;
}
let expected_root = match guard.expected_root(id).await {
Ok(root) => root,
Err(reason) => {
outcome.failed += 1;
outcome
.rows
.push(json!({ "id": id, "ok": false, "error": reason }));
continue;
}
};
let verdict = delete_index_on_socket(socket, id, delete_data, Some(&expected_root)).await;
if verdict.succeeded() {
outcome.removed += 1;
outcome.rows.push(json!({ "id": verdict.id(), "ok": true }));
} else {
outcome.failed += 1;
outcome.rows.push(json!({
"id": verdict.id(),
"ok": false,
"error": verdict.reason(),
}));
}
}
outcome
}
#[derive(Debug, Deserialize)]
pub struct PruneRequest {
#[serde(default)]
ids: Vec<String>,
#[serde(default = "crate::routes::deletes::purge_data_by_default")]
delete_data: bool,
}
pub async fn prune_indexes_handler(
State(state): State<AppState>,
axum::Json(req): axum::Json<PruneRequest>,
) -> Response {
if req.ids.is_empty() {
return bad_request("the prune request named no registration ids");
}
if req.ids.len() > MAX_PRUNE_BATCH {
return bad_request(&format!(
"the prune request named {} ids; at most {MAX_PRUNE_BATCH} may be pruned at once",
req.ids.len()
));
}
for id in &req.ids {
if let Err(reason) = validate_id(id) {
return bad_request(&format!(
"id {id:?} is not one this console will forward: {reason}"
));
}
}
let socket: PathBuf = match state.search_socket_path() {
Ok(p) => p,
Err(reason) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(json!({ "ok": false, "error": reason })),
)
.into_response();
}
};
let outcome = prune_indexes_on_socket(
&socket,
&req.ids,
req.delete_data,
Instant::now() + PRUNE_BUDGET,
)
.await;
if outcome.removed > 0 {
crate::routes::deletes::refresh_metrics(
&state,
SEARCH_SERVICE_ID,
state.search_metrics_cache(),
)
.await;
}
(outcome.status(), axum::Json(outcome.body())).into_response()
}
fn bad_request(reason: &str) -> Response {
(
StatusCode::BAD_REQUEST,
axum::Json(json!({ "ok": false, "error": reason })),
)
.into_response()
}
pub(crate) async fn compact_palace_on_socket(socket: &Path, id: &str) -> ActionVerdict {
if let Err(reason) = validate_id(id) {
return ActionVerdict::Invalid {
id: id.to_string(),
reason,
};
}
let payload =
match memory_rpc::call_tool(socket, "palace_compact", json!({ "palace": id }), id).await {
Ok(payload) => payload,
Err(verdict) => return verdict,
};
match payload.get("palace").and_then(Value::as_str) {
Some(compacted) if compacted == id => ActionVerdict::Succeeded {
id: id.to_string(),
detail: payload,
},
_ => ActionVerdict::Refused {
id: id.to_string(),
reason: format!(
"{MEMORY_SERVICE} answered palace_compact without confirming it compacted '{id}'"
),
detail: payload,
},
}
}
pub async fn compact_palace_handler(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
if let Err(reason) = validate_id(&id) {
return ActionVerdict::Invalid { id, reason }.into_response();
}
let socket: PathBuf = match trusty_common::daemon_socket_path(MEMORY_SERVICE) {
Ok(p) => p,
Err(e) => {
return ActionVerdict::Unreachable {
id,
reason: format!("could not resolve the {MEMORY_SERVICE} socket path: {e:#}"),
}
.into_response();
}
};
let verdict = compact_palace_on_socket(&socket, &id).await;
if verdict.succeeded() {
crate::routes::deletes::refresh_metrics(
&state,
MEMORY_SERVICE,
state.memory_metrics_cache(),
)
.await;
}
verdict.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt as _;
use tower::ServiceExt as _;
use crate::server::build_router;
fn stub_memory_daemon(dir: &Path, reply: impl Into<String>) -> PathBuf {
let socket = dir.join("sockets").join("memory.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
}
fn tools_call_reply(payload: &str) -> String {
json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "content": [{ "type": "text", "text": payload }] },
})
.to_string()
}
fn stub_search_socket_removing_only_doomed(dir: &Path) -> PathBuf {
stub_search_socket_with_census(dir, &["doomed-a", "doomed-b", "survivor", "a", "b"])
}
fn stub_search_socket_with_census(dir: &Path, stale: &[&str]) -> PathBuf {
let stale: Vec<String> = stale.iter().map(|s| s.to_string()).collect();
crate::routes::deletes::tests::stub_search_socket(dir, move |request: &Value| {
let result = if request["method"] == json!("search.registry.orphans") {
json!({
"orphans": stale
.iter()
.map(|id| json!({ "id": id, "root_path": format!("/gone/{id}") }))
.collect::<Vec<_>>(),
"indeterminate": [],
"live_count": 0,
"total": stale.len(),
})
} else {
let id = request["params"]["index_id"].as_str().unwrap_or_default();
json!({
"id": id,
"removed": id.starts_with("doomed-"),
"data_deleted": false,
"quiesced": true,
"expected_root_path": request["params"]["expected_root_path"].clone(),
})
};
json!({ "jsonrpc": "2.0", "id": 1, "result": result })
})
}
fn ids(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
async fn post_through_router(uri: &str, body: Value) -> (StatusCode, Value) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let router =
build_router(AppState::new(vec![]).with_search_socket(tmp.path().join("absent.sock")));
let req = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("request");
let resp = router.oneshot(req).await.expect("response");
let status = resp.status();
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
let parsed = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, parsed)
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_reports_per_item_outcomes_for_a_partial_batch() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_removing_only_doomed(tmp.path());
let outcome = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a", "survivor", "doomed-b"]),
false,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(outcome.removed, 2, "{outcome:?}");
assert_eq!(outcome.failed, 1, "{outcome:?}");
let body = outcome.body();
assert_eq!(
body["ok"],
json!(false),
"a batch with one failure is not a successful cleanup: {body}"
);
let rows = body["results"].as_array().expect("rows");
assert_eq!(rows.len(), 3, "one row per requested id: {body}");
assert_eq!(rows[0]["id"], json!("doomed-a"));
assert_eq!(rows[0]["ok"], json!(true));
assert_eq!(rows[1]["id"], json!("survivor"));
assert_eq!(rows[1]["ok"], json!(false));
assert!(
rows[1]["error"]
.as_str()
.unwrap_or_default()
.contains("skipped the delete"),
"the failed row must carry the daemon's own words: {body}"
);
assert_eq!(
rows[2]["ok"],
json!(true),
"a later id is unaffected: {body}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_of_a_clean_batch_reports_every_id_removed() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_removing_only_doomed(tmp.path());
let outcome = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a", "doomed-b"]),
false,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(outcome.removed, 2);
assert_eq!(outcome.failed, 0);
assert_eq!(outcome.status(), StatusCode::OK);
assert_eq!(outcome.body()["ok"], json!(true));
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_reports_a_dead_daemon_as_a_failure_on_every_id() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let outcome = prune_indexes_on_socket(
&tmp.path().join("absent.sock"),
&ids(&["a", "b"]),
false,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.failed, 2);
for row in outcome.body()["results"].as_array().expect("rows") {
assert_eq!(row["ok"], json!(false));
assert!(
!row["error"].as_str().unwrap_or_default().is_empty(),
"every failed row must say why: {row}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_reports_an_expired_budget_as_unattempted() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_removing_only_doomed(tmp.path());
let outcome = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a", "doomed-b"]),
false,
Instant::now(),
)
.await;
assert_eq!(outcome.removed, 0, "nothing may be deleted past the budget");
assert_eq!(outcome.failed, 2);
let body = outcome.body();
for row in body["results"].as_array().expect("rows") {
assert!(
row["error"]
.as_str()
.unwrap_or_default()
.contains("not attempted"),
"an unattempted id must say so: {body}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_forwards_the_delete_data_choice() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_removing_only_doomed(tmp.path());
let without = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a"]),
false,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(without.removed, 1, "a deregister-only prune succeeds");
let with = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a"]),
true,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(
with.failed, 1,
"asking for the data and not getting it is a failure, not a success"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_refuses_an_id_the_current_census_no_longer_calls_stale() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_with_census(tmp.path(), &["doomed-b"]);
let outcome = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a", "doomed-b"]),
false,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(
outcome.removed, 1,
"#6380: only the id the CURRENT census still calls stale may be \
deleted: {outcome:?}"
);
assert_eq!(outcome.failed, 1, "{outcome:?}");
let body = outcome.body();
let rows = body["results"].as_array().expect("rows");
assert_eq!(rows[0]["id"], json!("doomed-a"));
assert_eq!(rows[0]["ok"], json!(false), "body: {body}");
assert!(
rows[0]["error"]
.as_str()
.unwrap_or_default()
.contains("no longer lists"),
"the refused row must say the census went out of date: {body}"
);
assert_eq!(
rows[1]["ok"],
json!(true),
"a still-stale id proceeds: {body}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_pins_each_delete_to_the_root_the_census_reported() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_with_census(tmp.path(), &["doomed-a"]);
let outcome = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a"]),
false,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(outcome.removed, 1, "{outcome:?}");
let echoed = crate::routes::deletes::delete_index_on_socket(
&socket,
"doomed-a",
false,
Some("/gone/doomed-a"),
)
.await;
assert!(
matches!(&echoed, ActionVerdict::Succeeded { detail, .. }
if detail["expected_root_path"] == json!("/gone/doomed-a")),
"#6380: the expectation must reach the daemon: {echoed:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_refuses_every_remaining_id_when_the_daemon_drops_mid_batch() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("search.sock");
std::fs::create_dir_all(socket.parent().expect("parent")).expect("mkdir");
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
tokio::spawn(async move {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
if let Ok((mut conn, _)) = listener.accept().await {
let mut raw = Vec::new();
let _ = conn.read_to_end(&mut raw).await;
let reply = json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"orphans": [
{ "id": "doomed-a", "root_path": "/gone/a" },
{ "id": "doomed-b", "root_path": "/gone/b" },
],
"indeterminate": [],
"live_count": 0,
"total": 2,
},
})
.to_string();
let _ = conn.write_all(reply.as_bytes()).await;
let _ = conn.write_all(b"\n").await;
let _ = conn.flush().await;
}
drop(listener);
});
let outcome = prune_indexes_on_socket(
&socket,
&ids(&["doomed-a", "doomed-b"]),
true,
Instant::now() + PRUNE_BUDGET,
)
.await;
assert_eq!(
outcome.removed, 0,
"#6380: a daemon that dropped confirmed no delete: {outcome:?}"
);
assert_eq!(outcome.failed, 2, "{outcome:?}");
let body = outcome.body();
for row in body["results"].as_array().expect("rows") {
assert_eq!(row["ok"], json!(false), "body: {body}");
assert!(
!row["error"].as_str().unwrap_or_default().is_empty(),
"every failed row must say why: {body}"
);
}
}
#[tokio::test]
async fn prune_route_rejects_an_empty_batch() {
let (status, body) =
post_through_router("/api/console/search/prune-indexes", json!({ "ids": [] })).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
assert_eq!(body["ok"], json!(false));
}
#[tokio::test]
async fn prune_route_rejects_a_bad_id_without_dialling() {
let (status, body) = post_through_router(
"/api/console/search/prune-indexes",
json!({ "ids": ["good-one", "../etc"] }),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
assert!(
body["error"].as_str().unwrap_or_default().contains(".."),
"the error must name the offending id: {body}"
);
}
#[tokio::test]
async fn prune_route_rejects_an_oversized_batch() {
let many: Vec<String> = (0..=MAX_PRUNE_BATCH).map(|n| format!("idx-{n}")).collect();
let (status, body) =
post_through_router("/api/console/search/prune-indexes", json!({ "ids": many })).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
}
#[tokio::test]
async fn prune_route_reports_a_dead_daemon_on_every_row() {
let (status, body) = post_through_router(
"/api/console/search/prune-indexes",
json!({ "ids": ["scratch", "other"] }),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "body: {body}");
assert_eq!(body["ok"], json!(false));
assert_eq!(body["removed"], json!(0));
let rows = body["results"].as_array().expect("rows");
assert_eq!(rows.len(), 2, "one row per requested id: {body}");
for row in rows {
assert_eq!(row["ok"], json!(false), "{body}");
assert!(
row["error"]
.as_str()
.unwrap_or_default()
.contains("trusty-search"),
"every failed row must name the daemon: {body}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_route_purges_by_default() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Value::Null));
let recorder = std::sync::Arc::clone(&seen);
let socket = crate::routes::deletes::tests::stub_search_socket(
tmp.path(),
move |request: &Value| {
let result = if request["method"] == json!("search.registry.orphans") {
json!({
"orphans": [{ "id": "doomed-a", "root_path": "/gone/doomed-a" }],
"indeterminate": [], "live_count": 0, "total": 1,
})
} else {
if let Ok(mut slot) = recorder.lock() {
*slot = request["params"].clone();
}
json!({ "id": "doomed-a", "removed": true, "data_deleted": true,
"quiesced": true })
};
json!({ "jsonrpc": "2.0", "id": 1, "result": result })
},
);
let router = build_router(AppState::new(vec![]).with_search_socket(socket));
let req = Request::builder()
.method("POST")
.uri("/api/console/search/prune-indexes")
.header("content-type", "application/json")
.body(Body::from(json!({ "ids": ["doomed-a"] }).to_string()))
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK, "the stub confirms the prune");
let params = seen.lock().expect("lock").clone();
assert_eq!(
params["delete_data"],
json!(true),
"a prune body with no delete_data must reclaim the disk: {params}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_confirms_a_real_compaction() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_memory_daemon(
tmp.path(),
tools_call_reply(
r#"{"palace":"scratch","total_checked":120,"orphans_removed":7,"index_size_before":120,"index_size_after":113}"#,
),
);
let verdict = compact_palace_on_socket(&socket, "scratch").await;
assert!(
matches!(&verdict, ActionVerdict::Succeeded { id, .. } if id == "scratch"),
"a confirmed compaction must read as success: {verdict:?}"
);
let response = verdict.into_response();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response
.into_body()
.collect()
.await
.expect("body")
.to_bytes();
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["ok"], json!(true));
assert_eq!(
body["detail"]["orphans_removed"],
json!(7),
"the operator sees what was reclaimed: {body}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_reports_an_unconfirmed_answer_as_a_failure() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_memory_daemon(tmp.path(), tools_call_reply(r#"{"status":"noop"}"#));
let verdict = compact_palace_on_socket(&socket, "scratch").await;
assert!(
matches!(&verdict, ActionVerdict::Refused { reason, .. } if reason.contains("without confirming")),
"an unconfirmed answer must read as a failure: {verdict:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_rejects_a_confirmation_for_another_palace() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_memory_daemon(
tmp.path(),
tools_call_reply(r#"{"palace":"someone-else","orphans_removed":3}"#),
);
let verdict = compact_palace_on_socket(&socket, "scratch").await;
assert!(
matches!(verdict, ActionVerdict::Refused { .. }),
"a confirmation for another palace is not one for this one: {verdict:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_reports_a_daemon_refusal_as_a_failure() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_memory_daemon(
tmp.path(),
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"palace 'scratch' is not open"}}"#,
);
let verdict = compact_palace_on_socket(&socket, "scratch").await;
assert!(
matches!(&verdict, ActionVerdict::Refused { reason, .. } if reason.contains("is not open")),
"the refusal must carry the daemon's words: {verdict:?}"
);
assert_eq!(verdict.status(), StatusCode::CONFLICT);
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_reports_a_dead_socket_as_unreachable() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let verdict = compact_palace_on_socket(&tmp.path().join("absent.sock"), "scratch").await;
assert!(
matches!(verdict, ActionVerdict::Unreachable { .. }),
"a dead socket must read as unreachable: {verdict:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn compact_refuses_a_bad_id_without_dialling() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let verdict = compact_palace_on_socket(&tmp.path().join("absent.sock"), "../x").await;
assert!(
matches!(verdict, ActionVerdict::Invalid { .. }),
"a traversal id must be refused at the console: {verdict:?}"
);
}
#[tokio::test]
async fn compact_route_rejects_a_traversal_id() {
let (status, body) =
post_through_router("/api/console/memory/palaces/..%2Fetc/compact", json!({})).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
assert_eq!(body["ok"], json!(false));
}
#[tokio::test]
async fn cleanup_routes_reject_a_cross_origin_caller() {
for uri in [
"/api/console/search/prune-indexes",
"/api/console/memory/palaces/scratch/compact",
] {
let router = build_router(AppState::new(vec![]));
let req = Request::builder()
.method("POST")
.uri(uri)
.header("origin", "https://evil.example")
.header("content-type", "application/json")
.body(Body::from(json!({ "ids": ["scratch"] }).to_string()))
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"{uri} must refuse a cross-origin cleanup"
);
}
}
}