Skip to main content

tmprl_client/ops/
codec.rs

1//! The codec-server round trip.
2//!
3//! When a cluster encrypts payloads, only a service the *user* runs can read them. Temporal's
4//! contract for that service is plain HTTP rather than gRPC:
5//!
6//! ```text
7//! POST {endpoint}/decode
8//! Content-Type: application/json
9//! X-Namespace: {namespace}
10//! Authorization: {auth}          (only when configured)
11//!
12//! {"payloads":[{"metadata":{"encoding":"<base64>"},"data":"<base64>"}]}
13//! ```
14//!
15//! The body is **proto3-JSON**, which is why every `bytes` field, the payload data *and*
16//! each metadata value, is base64. Sending `metadata.encoding` as the plain string
17//! `binary/encrypted` is the mistake this module exists to not make: a conforming server
18//! decodes it as base64, gets nonsense, and refuses the payload.
19//!
20//! The response is the same shape, with the payloads decoded.
21
22use base64::Engine as _;
23use base64::engine::general_purpose::STANDARD as B64;
24use tmprl_core::payload::Payload;
25
26use super::OpError;
27
28/// A configured codec server.
29#[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    /// Decode a batch of payloads.
46    ///
47    /// Batched rather than one call per payload: a row carries several, and a codec server is
48    /// usually a network hop away.
49    ///
50    /// The server is required to return exactly as many payloads as it was given, in order,
51    /// that is what makes the result assignable back to what was sent. A server that returns
52    /// a different number is a protocol error rather than something to guess around, because
53    /// pairing them up wrongly would show one payload's plaintext under another's label.
54    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            // The server's own body is usually the diagnosis, a wrong path, a rejected
83            // credential, so it is shown rather than just the status code.
84            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
118/// Domain payload to proto3-JSON. Both `data` and every metadata value are `bytes`, so both
119/// are base64.
120fn 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
138/// proto3-JSON back to a domain payload.
139///
140/// A missing `data` is an empty payload, not an error: proto3-JSON omits fields at their
141/// default, so a decoded empty value legitimately arrives with no `data` key at all.
142fn 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        // The mistake this pins: `encoding` is a bytes field, so it is base64 on the wire.
179        // Sent as a plain string, a conforming server base64-decodes it into nonsense.
180        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        // proto3-JSON omits fields at their default, so an empty decoded value arrives with
213        // no `data` key at all.
214        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        // A round trip for an empty batch is a wasted network hop on every cursor move.
230        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        // `//decode` is not the same path to every server.
243        let codec = Codec::new("http://localhost:8081/", None);
244        assert_eq!(codec.endpoint, "http://localhost:8081");
245    }
246}
247
248/// End-to-end against a real HTTP server.
249///
250/// The unit tests above assert the *shape* of the body; these assert that a server on the
251/// other end of a socket sees what Temporal's contract says it should. That distinction
252/// matters here because the base64-metadata rule is exactly the sort of thing a shape
253/// assertion can agree with while the wire is still wrong.
254#[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    /// A one-shot HTTP server. Returns its address and a handle to the request it saw.
262    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            // Read headers, then exactly Content-Length bytes of body.
271            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        // The decoded payload came back as plaintext JSON.
324        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        // The contract's whole trap: `encoding` is a bytes field, so it is base64 on the
342        // wire. A server that received the plain string would base64-decode it to nonsense.
343        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        // A codec server is a service the user runs; forwarding a credential they did not
356        // configure would be a surprise.
357        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        // Pairing two sent payloads with one returned would show one value's plaintext
389        // under the other's label.
390        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        // "connection refused" with no address is the least useful error there is.
407        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}