parse_rust_server/response.rs
1//! Error response envelopes.
2//!
3//! Parse has **three different error bodies**, and they are not interchangeable. Getting this
4//! wrong is invisible in a browser and breaks SDKs, because clients branch on the presence of
5//! `code` and read `error` rather than `message`.
6//!
7//! | Source | Status | Body |
8//! |---|---|---|
9//! | A `Parse.Error` | 400, or 404 for `OBJECT_NOT_FOUND`, or 500 for `INTERNAL_SERVER_ERROR` | `{"code":N,"error":"..."}` |
10//! | An HTTP-level rejection | as given, e.g. 403 | `{"error":"..."}` with **no `code`** |
11//! | Anything else thrown | 500 | `{"code":1,"message":"Internal server error."}`, key `message` |
12//!
13//! Upstream: `handleParseErrors` (`middlewares.js:596-646`), which branches on the type of the
14//! thrown value in that order. The `code`-less shape comes from the `err.status && err.message`
15//! branch at `:629-631`; the third from the `else` at `:635-644`.
16//!
17//! The third row is the reason [`parse_rust_core::ErrorOrigin`] exists. It is not "code 1": a
18//! `Parse.Error` deliberately carrying `INTERNAL_SERVER_ERROR` is row one and keeps its message,
19//! and there are several of those upstream. Branching on the code instead of on the origin would
20//! blank out those messages, which is a worse defect than the disclosure it would be fixing.
21
22use axum::response::{IntoResponse, Response};
23use http::StatusCode;
24use parse_rust_core::{ErrorCode, ErrorDetail, ErrorOrigin, ParseError, PERMISSION_DENIED};
25
26/// The whole of the generic 500 body's message (`middlewares.js:640`). Note the trailing period.
27pub const INTERNAL_SERVER_ERROR_MESSAGE: &str = "Internal server error.";
28
29/// An HTTP-level rejection: a status and a message, with no Parse error code.
30///
31/// Kept as a distinct type from `ParseError` rather than a variant of it, so that a route
32/// cannot accidentally emit one envelope where the other is required.
33#[derive(Debug, Clone)]
34pub struct HttpError {
35 pub status: StatusCode,
36 pub message: String,
37}
38
39impl HttpError {
40 /// The master-key gate's rejection.
41 ///
42 /// `promiseEnforceMasterKeyAccess` builds this through `createSanitizedHttpError`
43 /// (`Error.js:32-43`), which logs the detailed reason server-side and sends a generic one to
44 /// the client when `enableSanitizedErrorResponse` is true, which is the default.
45 ///
46 /// The detailed message is `unauthorized: master key is required`; the client sees
47 /// `Permission denied`. Both strings are asserted by `spec/features.spec.js`, the first via
48 /// a logger spy and the second in the response body.
49 pub fn master_key_required(detail: ErrorDetail) -> Self {
50 Self {
51 status: StatusCode::FORBIDDEN,
52 message: match detail {
53 ErrorDetail::Withheld => PERMISSION_DENIED.to_string(),
54 ErrorDetail::Disclosed => "unauthorized: master key is required".to_string(),
55 },
56 }
57 }
58
59 /// The header layer's rejection. Upstream's `invalidRequest` (`middlewares.js:829-832`).
60 ///
61 /// Note it is **not** sanitization-dependent and the message is lowercase `unauthorized`,
62 /// unlike the master-key gate above. Two similar-looking 403s with different bodies.
63 pub fn unauthorized() -> Self {
64 Self {
65 status: StatusCode::FORBIDDEN,
66 message: "unauthorized".to_string(),
67 }
68 }
69}
70
71impl IntoResponse for HttpError {
72 fn into_response(self) -> Response {
73 let body = format!("{{\"error\":{}}}", json_string(&self.message));
74 (
75 self.status,
76 [(
77 http::header::CONTENT_TYPE,
78 "application/json; charset=utf-8",
79 )],
80 body,
81 )
82 .into_response()
83 }
84}
85
86/// A failure raised by a route, rendered as whichever of the two `code`-carrying bodies it is.
87pub struct ParseErrorResponse(pub ParseError);
88
89impl IntoResponse for ParseErrorResponse {
90 fn into_response(self) -> Response {
91 let (status, body) = match self.0.origin {
92 // Anything that was not a `Parse.Error` upstream. The detail was logged where it was
93 // built; here it is dropped, because this is the byte stream a client reads.
94 ErrorOrigin::Internal => (
95 StatusCode::INTERNAL_SERVER_ERROR,
96 format!(
97 "{{\"code\":{},\"message\":{}}}",
98 ErrorCode::InternalServerError.as_i32(),
99 json_string(INTERNAL_SERVER_ERROR_MESSAGE)
100 ),
101 ),
102 // `handleParseErrors` maps exactly two codes and defaults everything else to 400.
103 // The upstream comment on that switch is a literal "TODO: fill out this mapping", so
104 // the sparseness is the contract rather than an oversight to improve on.
105 ErrorOrigin::Parse => {
106 let status = match self.0.code {
107 ErrorCode::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
108 ErrorCode::ObjectNotFound => StatusCode::NOT_FOUND,
109 _ => StatusCode::BAD_REQUEST,
110 };
111 (
112 status,
113 format!(
114 "{{\"code\":{},\"error\":{}}}",
115 self.0.code.as_i32(),
116 json_string(&self.0.message)
117 ),
118 )
119 }
120 };
121 (
122 status,
123 [(
124 http::header::CONTENT_TYPE,
125 "application/json; charset=utf-8",
126 )],
127 body,
128 )
129 .into_response()
130 }
131}
132
133/// Minimal JSON string escaping for error messages, which are server-authored and short.
134fn json_string(s: &str) -> String {
135 let mut out = String::with_capacity(s.len() + 2);
136 out.push('"');
137 for c in s.chars() {
138 match c {
139 '"' => out.push_str("\\\""),
140 '\\' => out.push_str("\\\\"),
141 '\n' => out.push_str("\\n"),
142 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
143 c => out.push(c),
144 }
145 }
146 out.push('"');
147 out
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn the_two_envelopes_are_distinguishable() {
156 // An HTTP rejection has no `code` key at all. A client branching on it must not find one.
157 let http = HttpError::master_key_required(ErrorDetail::Withheld);
158 assert_eq!(http.message, "Permission denied");
159 assert_eq!(http.status, StatusCode::FORBIDDEN);
160
161 // The header layer's is a different string.
162 assert_eq!(HttpError::unauthorized().message, "unauthorized");
163 }
164
165 #[test]
166 fn sanitization_toggles_only_the_master_key_message() {
167 assert_eq!(
168 HttpError::master_key_required(ErrorDetail::Disclosed).message,
169 "unauthorized: master key is required"
170 );
171 // The header rejection does not participate in sanitization.
172 assert_eq!(HttpError::unauthorized().message, "unauthorized");
173 }
174
175 /// Read the body back off a rendered response.
176 async fn rendered(e: ParseError) -> (StatusCode, String) {
177 let response = ParseErrorResponse(e).into_response();
178 let status = response.status();
179 let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
180 .await
181 .expect("body");
182 (status, String::from_utf8(bytes.to_vec()).expect("utf-8"))
183 }
184
185 /// The third branch: key `message`, fixed text, nothing of the detail.
186 #[tokio::test]
187 async fn a_non_parse_error_renders_the_generic_five_hundred() {
188 let (status, body) = rendered(ParseError::internal(
189 "pointer permissions: Invoice ownerRef",
190 ))
191 .await;
192 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
193 assert_eq!(body, r#"{"code":1,"message":"Internal server error."}"#);
194 assert!(!body.contains("Invoice"));
195 assert!(!body.contains("ownerRef"));
196 // The key is `message`, and `error` must not appear. An SDK reading `error` is meant to
197 // find nothing here.
198 assert!(!body.contains("\"error\""));
199 }
200
201 /// The trap: a `Parse.Error` that carries code 1 keeps its own message and its own key.
202 /// Blanking these out would be a worse bug than the disclosure the branch above prevents.
203 #[tokio::test]
204 async fn a_parse_error_carrying_code_one_keeps_its_message() {
205 let (status, body) = rendered(ParseError::new(
206 ErrorCode::InternalServerError,
207 "Invalid object ID.",
208 ))
209 .await;
210 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
211 assert_eq!(body, r#"{"code":1,"error":"Invalid object ID."}"#);
212 }
213
214 #[tokio::test]
215 async fn an_ordinary_parse_error_is_unchanged() {
216 let (status, body) = rendered(ParseError::new(
217 ErrorCode::ObjectNotFound,
218 "Object not found.",
219 ))
220 .await;
221 assert_eq!(status, StatusCode::NOT_FOUND);
222 assert_eq!(body, r#"{"code":101,"error":"Object not found."}"#);
223 }
224
225 #[test]
226 fn escaping_is_applied_to_messages() {
227 assert_eq!(json_string(r#"a"b"#), r#""a\"b""#);
228 assert_eq!(json_string("a\nb"), r#""a\nb""#);
229 }
230}