stygian_graph/adapters/
signing.rs1use std::collections::HashMap;
29use std::time::Duration;
30
31use reqwest::Client;
32use serde::{Deserialize, Serialize};
33
34use crate::ports::signing::{SigningError, SigningInput, SigningOutput, SigningPort};
35
36#[cfg(test)]
37use crate::ports::signing::ErasedSigningPort;
38
39pub struct NoopSigningAdapter;
68
69impl SigningPort for NoopSigningAdapter {
70 async fn sign(&self, _input: SigningInput) -> Result<SigningOutput, SigningError> {
71 Ok(SigningOutput::default())
72 }
73}
74
75#[derive(Debug, Clone)]
95pub struct HttpSigningConfig {
96 pub endpoint: String,
98 pub timeout: Duration,
100 pub bearer_token: Option<String>,
102 pub extra_headers: HashMap<String, String>,
104}
105
106impl Default for HttpSigningConfig {
107 fn default() -> Self {
108 Self {
109 endpoint: "http://localhost:27042/sign".to_string(),
110 timeout: Duration::from_secs(10),
111 bearer_token: None,
112 extra_headers: HashMap::new(),
113 }
114 }
115}
116
117#[derive(Debug, Serialize)]
119struct SignRequest {
120 method: String,
121 url: String,
122 headers: HashMap<String, String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 body_b64: Option<String>,
125 context: serde_json::Value,
126}
127
128#[derive(Debug, Deserialize)]
130struct SignResponse {
131 #[serde(default)]
132 headers: HashMap<String, String>,
133 #[serde(default)]
134 query_params: Vec<(String, String)>,
135 #[serde(default)]
136 body_b64: Option<String>,
137}
138
139pub struct HttpSigningAdapter {
179 config: HttpSigningConfig,
180 client: Client,
181}
182
183impl HttpSigningAdapter {
184 #[must_use]
194 pub fn new(config: HttpSigningConfig) -> Self {
195 let client = Client::builder()
196 .timeout(config.timeout)
197 .build()
198 .unwrap_or_default();
199 Self { config, client }
200 }
201}
202
203impl SigningPort for HttpSigningAdapter {
204 async fn sign(&self, input: SigningInput) -> Result<SigningOutput, SigningError> {
205 let body_b64 = input.body.as_deref().map(base64_encode);
206
207 let req_body = SignRequest {
208 method: input.method,
209 url: input.url,
210 headers: input.headers,
211 body_b64,
212 context: input.context,
213 };
214
215 let mut req = self.client.post(&self.config.endpoint).json(&req_body);
216
217 if let Some(token) = &self.config.bearer_token {
218 req = req.bearer_auth(token);
219 }
220 for (k, v) in &self.config.extra_headers {
221 req = req.header(k, v);
222 }
223
224 let response = req.send().await.map_err(|e| {
225 if e.is_timeout() {
226 SigningError::Timeout(
227 self.config
228 .timeout
229 .as_millis()
230 .try_into()
231 .unwrap_or(u64::MAX),
232 )
233 } else {
234 SigningError::BackendUnavailable(e.to_string())
235 }
236 })?;
237
238 if !response.status().is_success() {
239 let status = response.status().as_u16();
240 let body = response.text().await.unwrap_or_default();
241 return Err(SigningError::InvalidResponse(format!(
242 "sidecar returned HTTP {status}: {body}"
243 )));
244 }
245
246 let sign_resp: SignResponse = response
247 .json()
248 .await
249 .map_err(|e| SigningError::InvalidResponse(e.to_string()))?;
250
251 let body_override = sign_resp
252 .body_b64
253 .map(|b64| base64_decode(&b64))
254 .transpose()
255 .map_err(|e| SigningError::InvalidResponse(format!("base64 decode failed: {e}")))?;
256
257 Ok(SigningOutput {
258 headers: sign_resp.headers,
259 query_params: sign_resp.query_params,
260 body_override,
261 })
262 }
263}
264
265fn base64_encode(input: &[u8]) -> String {
270 use std::fmt::Write;
271 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
272 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
273 for chunk in input.chunks(3) {
274 let b0 = usize::from(*chunk.first().unwrap_or(&0));
275 let b1 = if chunk.len() > 1 {
276 usize::from(*chunk.get(1).unwrap_or(&0))
277 } else {
278 0
279 };
280 let b2 = if chunk.len() > 2 {
281 usize::from(*chunk.get(2).unwrap_or(&0))
282 } else {
283 0
284 };
285 let first = TABLE.get(b0 >> 2).copied().unwrap_or_default();
286 let second = TABLE
287 .get(((b0 & 3) << 4) | (b1 >> 4))
288 .copied()
289 .unwrap_or_default();
290 let _ = write!(out, "{}", char::from(first));
291 let _ = write!(out, "{}", char::from(second));
292 if chunk.len() > 1 {
293 let third = TABLE
294 .get(((b1 & 0xf) << 2) | (b2 >> 6))
295 .copied()
296 .unwrap_or_default();
297 let _ = write!(out, "{}", char::from(third));
298 } else {
299 out.push('=');
300 }
301 if chunk.len() > 2 {
302 let fourth = TABLE.get(b2 & 0x3f).copied().unwrap_or_default();
303 let _ = write!(out, "{}", char::from(fourth));
304 } else {
305 out.push('=');
306 }
307 }
308 out
309}
310
311fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
312 let input = input.trim_end_matches('=');
313 let mut out = Vec::with_capacity(input.len() * 3 / 4 + 1);
314 let decode_char = |c: u8| -> Result<u8, String> {
315 match c {
316 b'A'..=b'Z' => Ok(c - b'A'),
317 b'a'..=b'z' => Ok(c - b'a' + 26),
318 b'0'..=b'9' => Ok(c - b'0' + 52),
319 b'+' => Ok(62),
320 b'/' => Ok(63),
321 _ => Err(format!("invalid base64 char: {c}")),
322 }
323 };
324 let bytes = input.as_bytes();
325 let mut i = 0;
326 while i + 1 < bytes.len() {
327 let v0 = decode_char(*bytes.get(i).unwrap_or(&0))?;
328 let v1 = decode_char(*bytes.get(i + 1).unwrap_or(&0))?;
329 out.push((v0 << 2) | (v1 >> 4));
330 if i + 2 < bytes.len() {
331 let v2 = decode_char(*bytes.get(i + 2).unwrap_or(&0))?;
332 out.push(((v1 & 0xf) << 4) | (v2 >> 2));
333 if i + 3 < bytes.len() {
334 let v3 = decode_char(*bytes.get(i + 3).unwrap_or(&0))?;
335 out.push(((v2 & 3) << 6) | v3);
336 }
337 }
338 i += 4;
339 }
340 Ok(out)
341}
342
343#[cfg(test)]
348mod tests {
349 use super::*;
350 use serde_json::json;
351
352 #[tokio::test]
353 async fn noop_returns_empty_output() -> std::result::Result<(), Box<dyn std::error::Error>> {
354 let signer = NoopSigningAdapter;
355 let output = signer
356 .sign(SigningInput {
357 method: "GET".to_string(),
358 url: "https://example.com".to_string(),
359 headers: HashMap::new(),
360 body: None,
361 context: json!({}),
362 })
363 .await?;
364 assert!(output.headers.is_empty());
365 assert!(output.query_params.is_empty());
366 assert!(output.body_override.is_none());
367 Ok(())
368 }
369
370 #[tokio::test]
371 async fn noop_is_erased_signing_port() -> std::result::Result<(), Box<dyn std::error::Error>> {
372 let signer: std::sync::Arc<dyn ErasedSigningPort> = std::sync::Arc::new(NoopSigningAdapter);
373 let output = signer
374 .erased_sign(SigningInput {
375 method: "POST".to_string(),
376 url: "https://api.example.com/data".to_string(),
377 headers: HashMap::new(),
378 body: Some(b"{\"key\":\"val\"}".to_vec()),
379 context: json!({"session": "abc"}),
380 })
381 .await?;
382 assert!(output.headers.is_empty());
383 Ok(())
384 }
385
386 #[test]
387 fn base64_roundtrip() -> std::result::Result<(), Box<dyn std::error::Error>> {
388 let input = b"Hello, Stygian signing!";
389 let encoded = base64_encode(input);
390 let decoded = base64_decode(&encoded)
391 .map_err(|e| std::io::Error::other(format!("base64 decode failed: {e}")))?;
392 assert_eq!(decoded, input);
393 Ok(())
394 }
395
396 #[test]
397 fn base64_encode_known_value() {
398 assert_eq!(base64_encode(b"Man"), "TWFu");
400 assert_eq!(base64_encode(b"Ma"), "TWE=");
401 assert_eq!(base64_encode(b"M"), "TQ==");
402 }
403}