1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
//! The one door a destructive trusty-search index delete may pass through (#4743).
//!
//! Why: `search_gc` had two independent sites that formatted
//! `DELETE /indexes/{id}?delete_data=true` — the decommission-time removal and
//! the orphan sweep's delete loop. Destroying the data is opt-in (#4123), and
//! the daemon those requests reached was whichever one discovery found. Under
//! `cargo test` that is the OPERATOR'S live daemon: a test process resolves it
//! exactly like production does. Fixture workspaces derive their index id from
//! a bare `file_name()` — `full`, `sess`, `live` — so a real index sharing that
//! basename is destroyed by a test run, silently.
//!
//! Why a capability type rather than an `if` at each site: two review rounds on
//! PR #4725 each found a different destructive effect that a caller had failed
//! to gate, and the response there was the same one taken here — stop relying on
//! every call site remembering, and make the ungated shape unrepresentable. A
//! caller cannot build the request, because [`DestructiveIndexDelete`] holds the
//! only copy of the `delete_data` opt-in and exposes no constructor that takes a
//! daemon address. The only way to obtain one is
//! [`DestructiveIndexDelete::acquire`], which decides for itself whether the
//! process may destroy data. A new destructive site added later inherits the
//! refusal by construction: it has to call `acquire` to get anything it can
//! delete with.
//!
//! Why the refusal is a RUNTIME check: the delete lands in a DIFFERENT PROCESS.
//! Issue #4094's `cfg(test)` arm in trusty-search's `default_data_dir` isolates
//! that daemon's own data-dir resolution, and #4255/PR #4864 extended isolation
//! to registry writes — but neither can help here. No compile-time guard in
//! trusty-search governs what a trusty-mpm test binary puts on the wire.
//! `trusty_common::running_under_test_harness` is the process-level answer, and
//! reusing it keeps this crate from growing a second, drifting copy of the same
//! detection.
//!
//! What #6285 changed: the transport, and nothing else. The call is
//! `search.index.delete` over the daemon's Unix socket (ADR-0032) rather than
//! `DELETE /indexes/{id}`, sent through [`crate::daemon::search_rpc`] — this
//! crate's one trusty-search client. Who may acquire the capability did not
//! move: the test-harness refusal is still the first thing `acquire` evaluates,
//! still ahead of any resolution, and the `delete_data` opt-in still exists in
//! exactly one place.
//!
//! Test: `acquire_is_refused_under_a_test_harness`,
//! `acquire_refuses_when_no_daemon_socket_is_bound`,
//! `acquire_succeeds_when_production_state_is_explicitly_allowed`,
//! `delete_params_opt_into_data_deletion`,
//! `delete_over_a_stale_socket_is_a_transport_failure`,
//! `a_refusal_is_never_reported_as_removed`,
//! `a_result_that_did_not_remove_is_not_reported_as_removed`; end-to-end,
//! `decommission_issues_no_request_to_a_live_daemon_under_test` in
//! `search_gc_guard_tests`.
use std::path::PathBuf;
use std::time::Duration;
use serde_json::Value;
use tracing::{debug, warn};
use crate::daemon::search_rpc::{self, SearchRpcError};
/// Per-request timeout for the index delete.
///
/// Why: the destructive call runs off the interactive request path
/// (decommission, periodic GC) but must still never hang the daemon
/// indefinitely if trusty-search is wedged.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
/// What a destructive index delete did, for the caller to log in its own voice.
///
/// Why: the two call sites want different log messages for the same outcomes,
/// and threading the RPC types back to them would leak the transport this
/// module exists to own.
///
/// Why four variants rather than worked / did not work: only [`Self::Removed`]
/// licenses a caller to record an index as gone. The other three are distinct
/// evidence — the daemon refused, the daemon answered but kept the index, or
/// nothing answered at all — and collapsing them lets an unanswered call read
/// as a completed one.
#[derive(Debug)]
pub(super) enum DeleteOutcome {
/// The daemon answered, and its answer says the registration is gone.
Removed,
/// The daemon answered, and its answer says the index is still registered.
///
/// Reachable when an in-flight writer never released the teardown lock
/// (#3049): with `delete_data` requested, trusty-search abandons the delete
/// rather than destroying data underneath a running write.
NotRemoved(String),
/// The daemon answered with an error frame — its own code and wording.
Refused {
/// The daemon's JSON-RPC error code.
code: i64,
/// The daemon's own message.
message: String,
},
/// The call never got an answer.
Transport(String),
}
/// The capability to destroy a trusty-search index's on-disk data (#4743).
///
/// Why: see the module doc — holding one of these is the proof that this
/// process is allowed to delete real data, and it is the only thing in the
/// crate that can produce a `delete_data` request.
/// What: the socket of a trusty-search daemon that was bound when the capability
/// was acquired. The field is private and there is no constructor other than
/// [`Self::acquire`], so the capability cannot be forged from a path a caller
/// happens to have.
/// Test: see the module doc.
pub(super) struct DestructiveIndexDelete {
socket: PathBuf,
}
impl DestructiveIndexDelete {
/// Acquire the capability, or `None` when this process must not destroy
/// index data.
///
/// Why: the refusal lives here, at the single point of acquisition, rather
/// than at each call site — a guard a caller has to remember is one new
/// call site away from being forgotten, which is precisely how the second
/// destructive site in `search_gc` came to have no guard at all.
/// What: `None` when (a) `trusty_common::running_under_test_harness()` says
/// this is a `cargo test` process, (b) the socket path cannot be resolved,
/// or (c) nothing is bound at it. `Some` otherwise. The test refusal is
/// checked FIRST so a test run never even resolves where the operator's
/// daemon lives.
///
/// #6285: (c) replaces the old "no `http_addr` discovery file" arm. A bound
/// socket is what a running daemon leaves behind, so its absence carries the
/// same "there is nothing to talk to" signal, evaluated at the same point —
/// before any request is built. A socket file that outlived its daemon still
/// passes here and fails at [`Self::delete`], which reports it as
/// [`DeleteOutcome::Transport`] and deletes nothing.
///
/// A test that genuinely needs to drive a real daemon sets
/// `TRUSTY_ALLOW_PRODUCTION_STATE=1` (`trusty_common::test_harness::ALLOW_PRODUCTION_ENV`),
/// which makes that intent explicit and greppable instead of ambient.
/// Test: `acquire_is_refused_under_a_test_harness`,
/// `acquire_refuses_when_no_daemon_socket_is_bound`,
/// `acquire_succeeds_when_production_state_is_explicitly_allowed`.
pub(super) fn acquire() -> Option<Self> {
// #4743: a `cargo test` process may not destroy index data. Checked
// before resolution so a test run does not even look up where the
// operator's daemon lives.
if trusty_common::running_under_test_harness() {
warn!(
"refusing a destructive trusty-search index delete: this is a test process \
(#4743). Set {} to override.",
trusty_common::test_harness::ALLOW_PRODUCTION_ENV
);
return None;
}
let socket = match search_rpc::search_socket() {
Ok(socket) => socket,
Err(e) => {
warn!("cannot resolve the trusty-search socket; skipping index removal: {e:#}");
return None;
}
};
// #6285: nothing bound means no daemon to ask — the no-op the old
// `resolve_daemon_base_url` miss produced.
if !socket.exists() {
debug!(
socket = %socket.display(),
"no trusty-search daemon socket; skipping index removal (#2033)"
);
return None;
}
Some(Self { socket })
}
/// The destructive params for `index_id` — the crate's only `delete_data`
/// opt-in.
///
/// Why (#4123): trusty-search's `search.index.delete` preserves on-disk data
/// unless `delete_data` is `true`. Both callers opt in deliberately: the
/// workspace each index describes is a disposable worktree that is being (or
/// has been) deleted, so preserved index data would be unreachable garbage
/// on disk forever.
/// What: `{"index_id": …, "delete_data": true}`. A method rather than a free
/// function so it cannot be called without a capability in hand.
/// Test: `delete_params_opt_into_data_deletion`.
fn delete_params(&self, index_id: &str) -> Value {
serde_json::json!({ "index_id": index_id, "delete_data": true })
}
/// Issue the delete and classify the result.
///
/// Why: never returns an error — every failure mode maps to a
/// [`DeleteOutcome`] variant so both callers stay fail-soft (an unreachable
/// or erroring search daemon must not block session teardown).
/// What: one [`REQUEST_TIMEOUT`]-bounded `search.index.delete`. A refusal
/// carries the daemon's own code, an unanswered call is
/// [`DeleteOutcome::Transport`], and a result is believed only as far as its
/// own `removed` field — see [`classify_delete_result`]. No arm but a
/// daemon-confirmed removal produces [`DeleteOutcome::Removed`].
/// Test: `delete_over_a_stale_socket_is_a_transport_failure`,
/// `a_refusal_is_never_reported_as_removed`,
/// `a_result_that_did_not_remove_is_not_reported_as_removed`; end-to-end by
/// `decommission_issues_no_request_to_a_live_daemon_under_test`, which
/// asserts this never runs under a test harness.
pub(super) async fn delete(&self, index_id: &str) -> DeleteOutcome {
let params = self.delete_params(index_id);
match search_rpc::call_at(
&self.socket,
search_rpc::METHOD_INDEX_DELETE,
params,
REQUEST_TIMEOUT,
)
.await
{
Ok(body) => classify_delete_result(&body),
Err(e) => match e.downcast_ref::<SearchRpcError>() {
Some(rpc) => DeleteOutcome::Refused {
code: rpc.code,
message: rpc.message.clone(),
},
None => DeleteOutcome::Transport(format!("{e:#}")),
},
}
}
}
/// Read a delete result as the daemon's own verdict, not as "it answered".
///
/// Why: `search.index.delete` answers a RESULT — not an error — for a delete it
/// abandoned because an in-flight writer never quiesced (#3049). Over HTTP that
/// was a `200`, and treating any 2xx as success recorded an index as reclaimed
/// while it was still registered and still on disk. The daemon states the
/// outcome in the body; this reads it.
/// What: [`DeleteOutcome::Removed`] only when the body's `removed` is `true`. A
/// body that says otherwise — or that omits the field, which is unverifiable
/// rather than affirmative — is [`DeleteOutcome::NotRemoved`] carrying the
/// daemon's `quiesced` flag, the field that explains the abandonment.
/// Test: `a_result_that_did_not_remove_is_not_reported_as_removed`,
/// `a_result_reporting_removal_is_removed`.
fn classify_delete_result(body: &Value) -> DeleteOutcome {
if body.get("removed").and_then(Value::as_bool) == Some(true) {
return DeleteOutcome::Removed;
}
let quiesced = match body.get("quiesced").and_then(Value::as_bool) {
Some(v) => v.to_string(),
None => "unreported".to_string(),
};
DeleteOutcome::NotRemoved(format!(
"the daemon did not remove the index (quiesced: {quiesced})"
))
}
#[cfg(test)]
#[path = "index_delete_guard_tests.rs"]
mod tests;