1use base64::Engine as _;
23use base64::engine::general_purpose::STANDARD as B64;
24use tmprl_core::payload::Payload;
25
26use super::OpError;
27
28#[derive(Debug, Clone)]
30pub struct Codec {
31 endpoint: String,
32 auth: Option<String>,
33 http: reqwest::Client,
34}
35
36impl Codec {
37 pub fn new(endpoint: impl Into<String>, auth: Option<String>) -> Self {
38 Self {
39 endpoint: endpoint.into().trim_end_matches('/').to_string(),
40 auth,
41 http: reqwest::Client::new(),
42 }
43 }
44
45 pub async fn decode(
55 &self,
56 namespace: &str,
57 payloads: &[Payload],
58 ) -> Result<Vec<Payload>, OpError> {
59 if payloads.is_empty() {
60 return Ok(Vec::new());
61 }
62 let url = format!("{}/decode", self.endpoint);
63 let body = serde_json::json!({
64 "payloads": payloads.iter().map(to_wire).collect::<Vec<_>>(),
65 });
66
67 let mut req = self
68 .http
69 .post(&url)
70 .header("X-Namespace", namespace)
71 .json(&body);
72 if let Some(auth) = &self.auth {
73 req = req.header("Authorization", auth);
74 }
75
76 let resp = req.send().await.map_err(|e| OpError::Codec {
77 message: format!("could not reach the codec server at {url}: {e}"),
78 })?;
79
80 let status = resp.status();
81 if !status.is_success() {
82 let detail = resp.text().await.unwrap_or_default();
85 let detail = detail.trim();
86 return Err(OpError::Codec {
87 message: if detail.is_empty() {
88 format!("codec server returned {status}")
89 } else {
90 format!("codec server returned {status}: {detail}")
91 },
92 });
93 }
94
95 let value: serde_json::Value = resp.json().await.map_err(|e| OpError::Codec {
96 message: format!("codec server sent a body that is not JSON: {e}"),
97 })?;
98 let out = value
99 .get("payloads")
100 .and_then(|p| p.as_array())
101 .ok_or_else(|| OpError::Codec {
102 message: "codec server sent no `payloads` array".into(),
103 })?;
104
105 if out.len() != payloads.len() {
106 return Err(OpError::Codec {
107 message: format!(
108 "codec server returned {} payload(s) for {} sent; they cannot be paired up",
109 out.len(),
110 payloads.len()
111 ),
112 });
113 }
114 out.iter().map(from_wire).collect()
115 }
116}
117
118fn to_wire(p: &Payload) -> serde_json::Value {
121 let mut metadata = serde_json::Map::new();
122 metadata.insert(
123 "encoding".into(),
124 serde_json::Value::String(B64.encode(p.encoding.as_bytes())),
125 );
126 if let Some(t) = &p.type_hint {
127 metadata.insert(
128 "type".into(),
129 serde_json::Value::String(B64.encode(t.as_bytes())),
130 );
131 }
132 serde_json::json!({
133 "metadata": metadata,
134 "data": B64.encode(&p.data),
135 })
136}
137
138fn from_wire(v: &serde_json::Value) -> Result<Payload, OpError> {
143 let decode_b64 = |s: &str| -> Result<Vec<u8>, OpError> {
144 B64.decode(s).map_err(|e| OpError::Codec {
145 message: format!("codec server sent a field that is not base64: {e}"),
146 })
147 };
148
149 let data = match v.get("data").and_then(|d| d.as_str()) {
150 Some(s) => decode_b64(s)?,
151 None => Vec::new(),
152 };
153
154 let meta = |key: &str| -> Result<Option<String>, OpError> {
155 let Some(s) = v
156 .pointer(&format!("/metadata/{key}"))
157 .and_then(|m| m.as_str())
158 else {
159 return Ok(None);
160 };
161 let bytes = decode_b64(s)?;
162 Ok(String::from_utf8(bytes).ok())
163 };
164
165 Ok(Payload {
166 encoding: meta("encoding")?.unwrap_or_default(),
167 type_hint: meta("type")?,
168 data,
169 })
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn a_payload_goes_out_as_proto3_json_with_base64_everywhere() {
178 let p = Payload::new("binary/encrypted", b"cipher".to_vec());
181 let wire = to_wire(&p);
182
183 assert_eq!(wire["data"], B64.encode(b"cipher"));
184 assert_eq!(
185 wire["metadata"]["encoding"],
186 B64.encode(b"binary/encrypted")
187 );
188 assert_ne!(
189 wire["metadata"]["encoding"], "binary/encrypted",
190 "the encoding must not be sent as a plain string"
191 );
192 }
193
194 #[test]
195 fn a_type_hint_is_carried_only_when_present() {
196 let mut p = Payload::new("json/plain", b"1".to_vec());
197 assert!(to_wire(&p)["metadata"].get("type").is_none());
198
199 p.type_hint = Some("Keyword".into());
200 assert_eq!(to_wire(&p)["metadata"]["type"], B64.encode(b"Keyword"));
201 }
202
203 #[test]
204 fn a_decoded_payload_round_trips_back() {
205 let original = Payload::new("json/plain", br#"{"amount":100}"#.to_vec());
206 let back = from_wire(&to_wire(&original)).unwrap();
207 assert_eq!(back, original);
208 }
209
210 #[test]
211 fn a_payload_with_no_data_field_is_empty_rather_than_an_error() {
212 let v = serde_json::json!({"metadata": {"encoding": B64.encode(b"binary/null")}});
215 let p = from_wire(&v).unwrap();
216 assert_eq!(p.encoding, "binary/null");
217 assert!(p.data.is_empty());
218 }
219
220 #[test]
221 fn a_field_that_is_not_base64_is_reported() {
222 let v = serde_json::json!({"data": "not base64!!", "metadata": {}});
223 let err = from_wire(&v).unwrap_err();
224 assert!(err.to_string().contains("base64"), "got {err}");
225 }
226
227 #[test]
228 fn decoding_nothing_does_not_call_the_server() {
229 let codec = Codec::new("http://127.0.0.1:1", None);
231 let out = tokio::runtime::Builder::new_current_thread()
232 .enable_all()
233 .build()
234 .unwrap()
235 .block_on(codec.decode("default", &[]))
236 .unwrap();
237 assert!(out.is_empty());
238 }
239
240 #[test]
241 fn the_endpoint_loses_a_trailing_slash() {
242 let codec = Codec::new("http://localhost:8081/", None);
244 assert_eq!(codec.endpoint, "http://localhost:8081");
245 }
246}
247
248#[cfg(test)]
255mod live {
256 use super::*;
257 use std::sync::{Arc, Mutex};
258 use tokio::io::{AsyncReadExt, AsyncWriteExt};
259 use tokio::net::TcpListener;
260
261 async fn serve(status: &'static str, body: &'static str) -> (String, Arc<Mutex<String>>) {
263 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
264 let addr = listener.local_addr().unwrap();
265 let seen = Arc::new(Mutex::new(String::new()));
266 let captured = seen.clone();
267
268 tokio::spawn(async move {
269 let (mut sock, _) = listener.accept().await.unwrap();
270 let mut buf = Vec::new();
272 let mut tmp = [0u8; 4096];
273 loop {
274 let n = sock.read(&mut tmp).await.unwrap();
275 if n == 0 {
276 break;
277 }
278 buf.extend_from_slice(&tmp[..n]);
279 let text = String::from_utf8_lossy(&buf).to_string();
280 if let Some(head_end) = text.find("\r\n\r\n") {
281 let len: usize = text
282 .lines()
283 .find_map(|l| {
284 l.strip_prefix("content-length: ")
285 .or_else(|| l.strip_prefix("Content-Length: "))
286 })
287 .and_then(|v| v.trim().parse().ok())
288 .unwrap_or(0);
289 if buf.len() >= head_end + 4 + len {
290 *captured.lock().unwrap() = text;
291 break;
292 }
293 }
294 }
295 let resp = format!(
296 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
297 body.len()
298 );
299 sock.write_all(resp.as_bytes()).await.unwrap();
300 sock.flush().await.unwrap();
301 });
302
303 (format!("http://{addr}"), seen)
304 }
305
306 #[tokio::test]
307 async fn the_server_sees_base64_metadata_and_the_namespace_header() {
308 let decoded = concat!(
309 r#"{"payloads":[{"metadata":{"encoding":"anNvbi9wbGFpbg=="},"#,
310 r#""data":"eyJhbW91bnQiOjEwMH0="}]}"#
311 );
312 let (addr, seen) = serve("200 OK", decoded).await;
313 let codec = Codec::new(addr, Some("Bearer tok".into()));
314
315 let out = codec
316 .decode(
317 "payments",
318 &[Payload::new("binary/encrypted", b"cipher".to_vec())],
319 )
320 .await
321 .expect("a decode");
322
323 assert_eq!(out.len(), 1);
325 assert_eq!(out[0].encoding, "json/plain");
326 assert_eq!(out[0].data, br#"{"amount":100}"#);
327
328 let request = seen.lock().unwrap().clone();
329 assert!(
330 request.starts_with("POST /decode "),
331 "wrong path:\n{request}"
332 );
333 assert!(
334 request.contains("x-namespace: payments"),
335 "namespace header missing:\n{request}"
336 );
337 assert!(
338 request.contains("authorization: Bearer tok"),
339 "auth missing:\n{request}"
340 );
341 assert!(
344 request.contains(&B64.encode(b"binary/encrypted")),
345 "the encoding must be base64 on the wire:\n{request}"
346 );
347 assert!(
348 !request.contains(r#""encoding":"binary/encrypted""#),
349 "the encoding must not be sent as a plain string:\n{request}"
350 );
351 }
352
353 #[tokio::test]
354 async fn no_authorization_header_is_sent_when_none_is_configured() {
355 let (addr, seen) = serve("200 OK", r#"{"payloads":[{"metadata":{},"data":""}]}"#).await;
358 let codec = Codec::new(addr, None);
359 codec
360 .decode("default", &[Payload::new("binary/encrypted", vec![1])])
361 .await
362 .unwrap();
363 assert!(
364 !seen
365 .lock()
366 .unwrap()
367 .to_lowercase()
368 .contains("authorization")
369 );
370 }
371
372 #[tokio::test]
373 async fn a_server_error_body_is_reported_rather_than_just_the_status() {
374 let (addr, _) = serve("500 Internal Server Error", "key rotation in progress").await;
375 let err = Codec::new(addr, None)
376 .decode("default", &[Payload::new("binary/encrypted", vec![1])])
377 .await
378 .unwrap_err();
379 assert!(err.to_string().contains("500"), "got {err}");
380 assert!(
381 err.to_string().contains("key rotation"),
382 "the server's own words: {err}"
383 );
384 }
385
386 #[tokio::test]
387 async fn a_short_response_is_refused_rather_than_mispaired() {
388 let (addr, _) = serve("200 OK", r#"{"payloads":[{"metadata":{},"data":""}]}"#).await;
391 let err = Codec::new(addr, None)
392 .decode(
393 "default",
394 &[
395 Payload::new("binary/encrypted", vec![1]),
396 Payload::new("binary/encrypted", vec![2]),
397 ],
398 )
399 .await
400 .unwrap_err();
401 assert!(err.to_string().contains("cannot be paired up"), "got {err}");
402 }
403
404 #[tokio::test]
405 async fn an_unreachable_codec_server_names_the_url() {
406 let err = Codec::new("http://127.0.0.1:1", None)
408 .decode("default", &[Payload::new("binary/encrypted", vec![1])])
409 .await
410 .unwrap_err();
411 assert!(err.to_string().contains("127.0.0.1:1/decode"), "got {err}");
412 }
413}