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