use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::routes::ACTION_TIMEOUT;
use crate::routes::SEARCH_SERVICE_ID;
pub(crate) struct OrphanGuard {
socket: PathBuf,
}
impl OrphanGuard {
pub(crate) fn new(socket: &Path) -> Self {
Self {
socket: socket.to_path_buf(),
}
}
pub(crate) async fn expected_root(&self, id: &str) -> Result<String, String> {
let census = self.census(id, "not deleted", "still stale").await?;
match orphan_root(&census, id) {
Some(root) => Ok(root),
None => Err(format!(
"not deleted: {SEARCH_SERVICE_ID} no longer lists '{id}' as a stale \
registration, so the census it was confirmed from is out of date"
)),
}
}
pub(crate) async fn unjudged_root(&self, id: &str) -> Result<String, String> {
let census = self
.census(id, "not deregistered", "still uncheckable")
.await?;
match place_in_census(&census, id) {
CensusPlace::Unjudged(root) => Ok(root),
CensusPlace::Stale => Err(format!(
"not deregistered: {SEARCH_SERVICE_ID} can check '{id}' now and calls it a \
stale registration, so it is no longer the row that was reviewed; re-scan \
and remove it with the stale batch"
)),
CensusPlace::Absent => Err(format!(
"not deregistered: {SEARCH_SERVICE_ID} no longer lists '{id}' as a \
registration it could not check, so the review it was confirmed from is \
out of date"
)),
}
}
async fn census(&self, id: &str, verb: &str, claim: &str) -> Result<Value, String> {
crate::search_uds::call(
&self.socket,
crate::search_uds::METHOD_REGISTRY_ORPHANS,
serde_json::json!({}),
ACTION_TIMEOUT,
)
.await
.map_err(|e| {
format!(
"{verb}: {SEARCH_SERVICE_ID} could not re-check whether '{id}' is {claim} ({})",
e.message()
)
})
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CensusPlace {
Unjudged(String),
Stale,
Absent,
}
pub(crate) fn place_in_census(census: &Value, id: &str) -> CensusPlace {
if let Some(root) = row_in(census, "indeterminate", id) {
return CensusPlace::Unjudged(root);
}
if row_in(census, "orphans", id).is_some() {
return CensusPlace::Stale;
}
CensusPlace::Absent
}
fn row_in(census: &Value, list: &str, id: &str) -> Option<String> {
census
.get(list)?
.as_array()?
.iter()
.find(|row| row.get("id").and_then(Value::as_str) == Some(id))?
.get("root_path")?
.as_str()
.map(str::to_string)
}
fn orphan_root(census: &Value, id: &str) -> Option<String> {
row_in(census, "orphans", id)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn census() -> Value {
json!({
"orphans": [{ "id": "wiped", "root_path": "/tmp/wiped", "colocated": false }],
"indeterminate": [{ "id": "unplugged", "root_path": "/Volumes/x", "reason": "…" }],
"live_count": 1,
"total": 3,
})
}
#[test]
fn guard_returns_the_root_path_the_daemon_reports_now() {
assert_eq!(
orphan_root(&census(), "wiped").as_deref(),
Some("/tmp/wiped")
);
}
#[test]
fn guard_reads_the_orphans_list_and_not_the_indeterminate_one() {
assert_eq!(orphan_root(&census(), "unplugged"), None);
}
#[test]
fn guard_refuses_an_id_the_current_census_no_longer_calls_stale() {
let recreated = json!({ "orphans": [], "indeterminate": [], "live_count": 1, "total": 1 });
assert_eq!(orphan_root(&recreated, "wiped"), None);
}
#[test]
fn guard_refuses_a_census_it_cannot_parse() {
for malformed in [json!({}), json!({ "orphans": "nope" }), Value::Null] {
assert_eq!(orphan_root(&malformed, "wiped"), None, "{malformed}");
}
}
#[test]
fn guard_returns_the_root_of_a_row_the_daemon_still_cannot_check() {
assert_eq!(
place_in_census(&census(), "unplugged"),
CensusPlace::Unjudged("/Volumes/x".to_string())
);
}
#[test]
fn guard_refuses_a_reviewed_id_the_census_now_calls_stale() {
assert_eq!(place_in_census(&census(), "wiped"), CensusPlace::Stale);
}
#[test]
fn guard_refuses_a_reviewed_id_that_left_the_census() {
assert_eq!(
place_in_census(&census(), "never-seen"),
CensusPlace::Absent
);
for malformed in [json!({}), json!({ "indeterminate": "nope" }), Value::Null] {
assert_eq!(
place_in_census(&malformed, "unplugged"),
CensusPlace::Absent,
"{malformed}"
);
}
}
#[test]
fn guard_places_an_id_in_the_list_that_holds_it() {
assert_eq!(orphan_root(&census(), "unplugged"), None);
assert!(matches!(
place_in_census(&census(), "unplugged"),
CensusPlace::Unjudged(_)
));
}
#[tokio::test(flavor = "multi_thread")]
async fn guard_refuses_a_reviewed_id_once_the_daemon_stops_answering() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let guard = OrphanGuard::new(&tmp.path().join("absent.sock"));
let refusal = guard
.unjudged_root("unplugged")
.await
.expect_err("a dead socket must refuse the deregistration");
assert!(
refusal.contains("not deregistered") && refusal.contains("re-check"),
"the refusal must say the deregistration did not happen and why: {refusal}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn guard_refuses_every_id_once_the_daemon_stops_answering() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let guard = OrphanGuard::new(&tmp.path().join("absent.sock"));
let refusal = guard
.expected_root("wiped")
.await
.expect_err("a dead socket must refuse the delete");
assert!(
refusal.contains("not deleted") && refusal.contains("re-check"),
"the row must say the delete did not happen and why: {refusal}"
);
}
}