use std::path::{Path, PathBuf};
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::json;
use crate::routes::SEARCH_SERVICE_ID;
use crate::routes::census_guard::OrphanGuard;
use crate::routes::deletes::delete_index_on_socket;
use crate::routes::verdict::{ActionVerdict, validate_id};
use crate::server::AppState;
#[derive(Debug, Deserialize)]
pub struct DeregisterUnjudgedRequest {
id: String,
root_path: String,
}
pub(crate) async fn deregister_unjudged_on_socket(
socket: &Path,
id: &str,
reviewed_root: &str,
) -> ActionVerdict {
if let Err(reason) = validate_id(id) {
return ActionVerdict::Invalid {
id: id.to_string(),
reason,
};
}
let current_root = match OrphanGuard::new(socket).unjudged_root(id).await {
Ok(root) => root,
Err(reason) => {
return ActionVerdict::Refused {
id: id.to_string(),
reason,
detail: json!({ "reviewed_root_path": reviewed_root }),
};
}
};
if current_root != reviewed_root {
return ActionVerdict::Refused {
id: id.to_string(),
reason: format!(
"not deregistered: '{id}' now names {current_root}, not the {reviewed_root} \
that was reviewed"
),
detail: json!({
"reviewed_root_path": reviewed_root,
"current_root_path": current_root,
}),
};
}
delete_index_on_socket(socket, id, false, Some(¤t_root)).await
}
pub async fn deregister_unjudged_handler(
State(state): State<AppState>,
axum::Json(req): axum::Json<DeregisterUnjudgedRequest>,
) -> Response {
if let Err(reason) = validate_id(&req.id) {
return ActionVerdict::Invalid { id: req.id, reason }.into_response();
}
if req.root_path.trim().is_empty() {
return ActionVerdict::Invalid {
id: req.id,
reason: "the request named no reviewed root path, so there is nothing to \
check the registration against"
.to_string(),
}
.into_response();
}
let socket: PathBuf = match state.search_socket_path() {
Ok(p) => p,
Err(reason) => {
return ActionVerdict::Unreachable {
id: req.id,
reason: format!("could not resolve the {SEARCH_SERVICE_ID} socket path: {reason}"),
}
.into_response();
}
};
let verdict = deregister_unjudged_on_socket(&socket, &req.id, &req.root_path).await;
if verdict.succeeded() {
crate::routes::deletes::refresh_metrics(
&state,
SEARCH_SERVICE_ID,
state.search_metrics_cache(),
)
.await;
}
verdict.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt as _;
use serde_json::Value;
use tower::ServiceExt as _;
use crate::server::build_router;
fn stub_search_socket_with(dir: &Path, unjudged_root: &str) -> PathBuf {
let unjudged_root = unjudged_root.to_string();
crate::routes::deletes::tests::stub_search_socket(dir, move |request: &Value| {
let result = if request["method"] == json!("search.registry.orphans") {
json!({
"orphans": [{ "id": "wiped", "root_path": "/gone/wiped" }],
"indeterminate": [{
"id": "retired",
"root_path": unjudged_root,
"reason": "the root is missing and so is its parent directory",
"colocated": false,
"repo_identity": null,
}],
"live_count": 0,
"total": 2,
})
} else {
json!({
"id": request["params"]["index_id"].clone(),
"removed": true,
"data_deleted": request["params"]["delete_data"].clone(),
"quiesced": true,
})
};
json!({ "jsonrpc": "2.0", "id": 1, "result": result })
})
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_settles_a_row_the_daemon_still_cannot_check() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_with(tmp.path(), "/retired/.base/.worktrees/x");
let verdict =
deregister_unjudged_on_socket(&socket, "retired", "/retired/.base/.worktrees/x").await;
assert!(verdict.succeeded(), "{verdict:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_refuses_a_row_whose_root_moved_since_the_review() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_with(tmp.path(), "/retired/now-somewhere-else");
let verdict =
deregister_unjudged_on_socket(&socket, "retired", "/retired/.base/.worktrees/x").await;
assert!(!verdict.succeeded(), "a moved root must refuse");
assert!(
verdict.reason().contains("now names")
&& verdict.reason().contains("/retired/.base/.worktrees/x"),
"the refusal must name both paths: {}",
verdict.reason()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_refuses_a_row_the_census_now_calls_stale() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_search_socket_with(tmp.path(), "/retired/x");
let verdict = deregister_unjudged_on_socket(&socket, "wiped", "/gone/wiped").await;
assert!(!verdict.succeeded(), "a stale row must refuse this route");
assert!(
verdict.reason().contains("stale registration"),
"the refusal must say where the row went: {}",
verdict.reason()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_refuses_when_the_daemon_cannot_be_reached() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let verdict =
deregister_unjudged_on_socket(&tmp.path().join("absent.sock"), "retired", "/retired/x")
.await;
assert!(!verdict.succeeded(), "a dead daemon must not read as done");
assert!(
verdict.reason().contains("not deregistered"),
"the refusal must say the deregistration did not happen: {}",
verdict.reason()
);
}
async fn post_through_router(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("/api/console/search/deregister-unjudged")
.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();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
)
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_route_rejects_a_traversal_id() {
let (status, body) =
post_through_router(json!({ "id": "../etc", "root_path": "/gone" })).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
assert_eq!(body["ok"], json!(false));
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_route_refuses_when_the_daemon_cannot_be_reached() {
let (status, body) =
post_through_router(json!({ "id": "retired", "root_path": "/gone/retired" })).await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert_eq!(body["ok"], json!(false));
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("not deregistered"),
"the body must say the deregistration did not happen: {body}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn deregister_route_rejects_an_empty_reviewed_path() {
let (status, body) =
post_through_router(json!({ "id": "retired", "root_path": " " })).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
assert!(
body["error"].as_str().unwrap_or_default().contains("root"),
"the refusal must name the missing field: {body}"
);
}
}