trusty_memory/transport/api_error.rs
1//! The failure type every folded method reports, and its one mapping onto the
2//! wire (#6286).
3//!
4//! Why: these handlers used to be axum handlers, and `ApiError` used to be an
5//! HTTP status plus a message with an `IntoResponse` impl. ADR-0032 retired the
6//! listener, so the status code has nothing left to set. What the status
7//! ENCODED is still worth keeping — "the palace is not there" and "the palace
8//! could not be read" are different answers and #5549 is the issue that proves
9//! a caller acts on the difference — so the type survives as a failure KIND and
10//! the single [`From<ApiError> for RpcError`] below is where a kind becomes a
11//! code.
12//!
13//! What: [`ApiError`] carries an [`ErrorKind`] and a message; [`open_handle`]
14//! is the one palace lookup every method that names a palace by id runs.
15//!
16//! Test: `api_error_kind_maps_to_its_rpc_code`,
17//! `api_error_message_survives_the_conversion`.
18
19use trusty_common::memory_core::palace::PalaceId;
20use trusty_common::memory_core::PalaceRegistry;
21#[cfg_attr(not(test), allow(unused_imports))]
22use trusty_common::uds::server::{
23 RpcError, CODE_INTERNAL_ERROR, CODE_INVALID_PARAMS, CODE_METHOD_NOT_FOUND,
24};
25
26use crate::AppState;
27
28/// The request named something that is not there.
29///
30/// Why: `CODE_METHOD_NOT_FOUND` says the METHOD is unknown, which is a
31/// different fact from a well-formed call naming a palace, drawer or session
32/// that does not exist. trusty-analyze took the same code for the same
33/// distinction (#5049), and a caller that already reads `-32004` from one
34/// daemon reads it the same way here.
35pub const CODE_NOT_FOUND: i64 = -32004;
36
37/// The request is well formed and refused anyway.
38///
39/// Covers both a state conflict — deleting a palace that still holds drawers
40/// without `force` — and a refusal by the multi-tenant authz seam (#1714). The
41/// two were 409 and 403 over HTTP; on this wire the caller's next move is the
42/// same for both, which is to read the message.
43pub const CODE_REFUSED: i64 = -32006;
44
45/// Why one call failed, at the granularity a caller acts on.
46///
47/// Why: not the HTTP status set it replaces. `NotFound` and `Internal` were 404
48/// and 500 and stay distinct because #5549 turns on exactly that distinction;
49/// `Conflict` and `Forbidden` were 409 and 403 and collapse into `Refused`
50/// because nothing downstream branched on which.
51/// Test: `api_error_kind_maps_to_its_rpc_code`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ErrorKind {
54 /// The caller's arguments do not describe a request this method can run.
55 BadRequest,
56 /// Well formed, and it names something absent.
57 NotFound,
58 /// Well formed, understood, and refused — a state conflict or an authz
59 /// denial.
60 Refused,
61 /// The daemon could not complete the call.
62 Internal,
63}
64
65impl ErrorKind {
66 /// The JSON-RPC code this kind crosses the wire as.
67 fn code(self) -> i64 {
68 match self {
69 Self::BadRequest => CODE_INVALID_PARAMS,
70 Self::NotFound => CODE_NOT_FOUND,
71 Self::Refused => CODE_REFUSED,
72 Self::Internal => CODE_INTERNAL_ERROR,
73 }
74 }
75}
76
77/// One folded method's failure.
78///
79/// Why: the handlers below return `Result<Value, ApiError>` rather than
80/// `Result<Value, RpcError>` so the code mapping lives in one place instead of
81/// at every `return`. That is the shape trusty-analyze's `ApiError` took
82/// through the same migration.
83/// What: a kind and a message. The message reaches the caller verbatim.
84/// Test: `api_error_message_survives_the_conversion`.
85#[derive(Debug, Clone)]
86pub struct ApiError {
87 /// Which failure this is.
88 pub kind: ErrorKind,
89 /// What to tell the caller.
90 pub message: String,
91}
92
93impl ApiError {
94 /// The caller's arguments are wrong.
95 pub fn bad_request(msg: impl Into<String>) -> Self {
96 Self {
97 kind: ErrorKind::BadRequest,
98 message: msg.into(),
99 }
100 }
101
102 /// The thing named is not there.
103 pub fn not_found(msg: impl Into<String>) -> Self {
104 Self {
105 kind: ErrorKind::NotFound,
106 message: msg.into(),
107 }
108 }
109
110 /// Understood, and refused. Formerly 409 Conflict.
111 pub fn conflict(msg: impl Into<String>) -> Self {
112 Self {
113 kind: ErrorKind::Refused,
114 message: msg.into(),
115 }
116 }
117
118 /// Understood, and denied. Formerly 403 Forbidden (#1714).
119 pub fn forbidden(msg: impl Into<String>) -> Self {
120 Self {
121 kind: ErrorKind::Refused,
122 message: msg.into(),
123 }
124 }
125
126 /// Structurally valid and semantically unacceptable — content too short to
127 /// be worth storing (#466). Formerly 422.
128 pub fn unprocessable(msg: impl Into<String>) -> Self {
129 Self {
130 kind: ErrorKind::BadRequest,
131 message: msg.into(),
132 }
133 }
134
135 /// The daemon could not complete the call.
136 pub fn internal(msg: impl Into<String>) -> Self {
137 Self {
138 kind: ErrorKind::Internal,
139 message: msg.into(),
140 }
141 }
142}
143
144impl std::fmt::Display for ApiError {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.write_str(&self.message)
147 }
148}
149
150impl std::error::Error for ApiError {}
151
152/// The one place a failure kind becomes a JSON-RPC code (#6286).
153///
154/// Every folded method's `?` runs through here, so a change to the mapping is a
155/// change to the whole surface rather than to whichever handler was edited last.
156///
157/// Test: `api_error_kind_maps_to_its_rpc_code`.
158impl From<ApiError> for RpcError {
159 fn from(e: ApiError) -> Self {
160 RpcError::new(e.kind.code(), e.message)
161 }
162}
163
164impl From<crate::service::ServiceError> for ApiError {
165 fn from(e: crate::service::ServiceError) -> Self {
166 match e {
167 crate::service::ServiceError::BadRequest(m) => ApiError::bad_request(m),
168 crate::service::ServiceError::NotFound(m) => ApiError::not_found(m),
169 crate::service::ServiceError::Conflict(m) => ApiError::conflict(m),
170 crate::service::ServiceError::Internal(m) => ApiError::internal(m),
171 crate::service::ServiceError::Forbidden(m) => ApiError::forbidden(m),
172 }
173 }
174}
175
176/// Open a palace handle by id, telling absence apart from an open that failed.
177///
178/// Why: every method that references a palace by id runs the same registry
179/// lookup, and #5549 (ADR-0045) is what makes the distinction load-bearing:
180/// mapping every open failure to "not found" sent an operator looking for a
181/// deleted palace when what they had was a denied read or a jammed redb lock.
182/// What: calls `PalaceRegistry::open_palace`, then asks
183/// `PalaceRegistry::open_error_is_absent` which failure it got —
184/// [`ApiError::not_found`] only for a genuine absence, [`ApiError::internal`]
185/// otherwise.
186/// Test: `unreadable_palace_is_internal_not_not_found_at_open_handle`.
187pub fn open_handle(
188 state: &AppState,
189 id: &str,
190) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>, ApiError> {
191 state
192 .registry
193 .open_palace(&state.data_root, &PalaceId::new(id))
194 .map_err(|e| {
195 if PalaceRegistry::open_error_is_absent(&e) {
196 ApiError::not_found(format!("palace not found: {id} ({e:#})"))
197 } else {
198 ApiError::internal(format!("palace could not be loaded: {id} ({e:#})"))
199 }
200 })
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 /// Why: the mapping is the whole contract this type exists to hold, and a
208 /// silent change to it would look identical on the happy path. The two
209 /// codes that are not JSON-RPC standard are the ones a consumer has to
210 /// learn, so both are asserted by value rather than by name.
211 /// Test: itself.
212 #[test]
213 fn api_error_kind_maps_to_its_rpc_code() {
214 let cases = [
215 (ApiError::bad_request("x"), CODE_INVALID_PARAMS),
216 (ApiError::unprocessable("x"), CODE_INVALID_PARAMS),
217 (ApiError::not_found("x"), -32004),
218 (ApiError::conflict("x"), -32006),
219 (ApiError::forbidden("x"), -32006),
220 (ApiError::internal("x"), CODE_INTERNAL_ERROR),
221 ];
222 for (error, expected) in cases {
223 let kind = error.kind;
224 let rpc: RpcError = error.into();
225 assert_eq!(rpc.code, expected, "{kind:?} must map to {expected}");
226 }
227 // A method-not-found is the router's to report, never a handler's; this
228 // asserts no kind claims that code.
229 assert!(
230 !cases_map_to(CODE_METHOD_NOT_FOUND),
231 "no handler failure may impersonate method_not_found"
232 );
233 }
234
235 fn cases_map_to(code: i64) -> bool {
236 [
237 ErrorKind::BadRequest,
238 ErrorKind::NotFound,
239 ErrorKind::Refused,
240 ErrorKind::Internal,
241 ]
242 .iter()
243 .any(|k| k.code() == code)
244 }
245
246 /// Why: the message is what an operator reads; a conversion that dropped
247 /// or rewrote it would leave every failure looking the same on the wire.
248 /// Test: itself.
249 #[test]
250 fn api_error_message_survives_the_conversion() {
251 let rpc: RpcError = ApiError::not_found("palace not found: alpha").into();
252 assert_eq!(rpc.message, "palace not found: alpha");
253 }
254
255 /// Why (#5549, ADR-0045): mapping every open failure to `NotFound` sent an
256 /// operator looking for a deleted palace when what they had was a palace
257 /// that could not be READ. The two answers call for different next moves,
258 /// and the only thing separating them is this branch.
259 /// What: creates a palace directory the process cannot enter (`0o000`),
260 /// then opens it by id and asserts the failure is `Internal` rather than
261 /// `NotFound` — the palace is plainly there.
262 /// Test: itself.
263 #[tokio::test]
264 async fn unreadable_palace_is_internal_not_not_found_at_open_handle() {
265 use std::os::unix::fs::PermissionsExt as _;
266
267 // Running as root defeats the mode bits — the open would succeed and
268 // the test would assert nothing.
269 if unsafe { libc::geteuid() } == 0 {
270 eprintln!("SKIP: running as root, so 0o000 does not deny this process");
271 return;
272 }
273
274 let tmp = tempfile::tempdir().expect("tempdir");
275 let state = AppState::new(tmp.path().to_path_buf());
276 let dir = tmp.path().join("unreadable");
277 std::fs::create_dir_all(&dir).expect("create the palace directory");
278 std::fs::write(dir.join("palace.json"), "{}").expect("seed metadata");
279 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000))
280 .expect("deny access");
281
282 let failure = open_handle(&state, "unreadable")
283 .err()
284 .expect("an unreadable palace cannot be opened");
285
286 // Restore before the tempdir drop, or the cleanup cannot descend.
287 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
288
289 assert_eq!(
290 failure.kind,
291 ErrorKind::Internal,
292 "a palace that is present but unreadable must not report as absent: {}",
293 failure.message
294 );
295 }
296}