uni_plugin/host_services.rs
1//! Host service traits for capability-gated plugin host functions.
2//!
3//! `uni.kms.*` and `uni.http.*` host functions need a backing host service to
4//! perform real work. These traits define that seam in the shared `uni-plugin`
5//! crate so every loader (Rhai today; Extism / WASM at the host-fn cutover)
6//! binds the *same* abstraction rather than each inventing its own. The host
7//! supplies concrete implementations (e.g. a `reqwest`-backed [`HttpEgress`] in
8//! `uni-plugin-host`) and hands them to the loader.
9//!
10//! Secret acquisition has no trait here — it reuses
11//! [`crate::secrets::SecretStore`] directly.
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use crate::capability::{Capability, CapabilitySet};
17use crate::errors::FnError;
18
19/// A signing / verification service backing the `uni.kms.*` host functions.
20///
21/// Implementations are expected to enforce nothing about *which* key ids are
22/// permissible — that attenuation is checked against the plugin's granted
23/// [`crate::Capability::Kms`] before this trait is called.
24pub trait KmsProvider: Send + Sync {
25 /// Sign `data` with the key identified by `key_id`, returning the raw
26 /// signature bytes.
27 ///
28 /// # Errors
29 ///
30 /// Returns [`FnError`] if the key is unknown or the signing operation
31 /// fails.
32 fn sign(&self, key_id: &str, data: &[u8]) -> Result<Vec<u8>, FnError>;
33
34 /// Verify `signature` over `data` against the key identified by `key_id`.
35 ///
36 /// # Errors
37 ///
38 /// Returns [`FnError`] if the key is unknown or verification cannot be
39 /// performed (a *valid* result of "signature does not match" is `Ok(false)`,
40 /// not an error).
41 fn verify(&self, key_id: &str, data: &[u8], signature: &[u8]) -> Result<bool, FnError>;
42}
43
44/// Response returned by an [`HttpEgress`] request.
45#[derive(Clone, Debug)]
46pub struct HttpResponse {
47 /// HTTP status code.
48 pub status: u16,
49 /// Response body, truncated to the caller's `max_bytes` limit.
50 pub body: Vec<u8>,
51}
52
53/// A **blocking** HTTP egress service backing the `uni.http.*` host functions.
54///
55/// Methods are synchronous because the Rhai engine runs scripts synchronously
56/// (inside DataFusion scalar/procedure execution). Implementations must be safe
57/// to call from within a Tokio runtime context — e.g. by running the request on
58/// a dedicated OS thread rather than blocking a Tokio worker. URL allow-listing,
59/// timeout, and response-size limits are enforced by the caller against the
60/// plugin's granted [`crate::Capability::Network`]; the `timeout` and
61/// `max_bytes` arguments carry those decisions into the request.
62///
63/// `traceparent`, when `Some`, is injected as the W3C `traceparent` request
64/// header so the host's trace context propagates across the plugin boundary
65/// into the outbound call (see [`crate::observability::TraceContext::to_traceparent`]).
66pub trait HttpEgress: Send + Sync {
67 /// Perform a blocking HTTP GET, reading at most `max_bytes` of the body.
68 ///
69 /// # Errors
70 ///
71 /// Returns [`FnError`] on connection, timeout, or transport failure.
72 fn get(
73 &self,
74 url: &str,
75 timeout: Duration,
76 max_bytes: usize,
77 traceparent: Option<&str>,
78 ) -> Result<HttpResponse, FnError>;
79
80 /// Perform a blocking HTTP POST of `body`, reading at most `max_bytes` of
81 /// the response body.
82 ///
83 /// # Errors
84 ///
85 /// Returns [`FnError`] on connection, timeout, or transport failure.
86 fn post(
87 &self,
88 url: &str,
89 body: &[u8],
90 timeout: Duration,
91 max_bytes: usize,
92 traceparent: Option<&str>,
93 ) -> Result<HttpResponse, FnError>;
94}
95
96// ---------------------------------------------------------------------------
97// Shared `uni.http.*` policy
98// ---------------------------------------------------------------------------
99
100/// Default per-call HTTP timeout when the grant carries no
101/// [`Capability::WallClockMillisPerCall`].
102///
103/// Conservative: long enough for a typical API call, short enough to bound a
104/// wedged request.
105pub const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
106
107/// Maximum response body bytes read before truncation — bounds host memory so a
108/// hostile or oversized response cannot exhaust it.
109pub const MAX_HTTP_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
110
111/// Why a capability-gated HTTP call was refused.
112///
113/// The loaders share the *decisions* and keep their own *encoding*: the Extism
114/// loader maps these onto the numeric `FnError` codes its guest ABI pins
115/// (`0xC20`, `0xC21`, `0xC23`), the Rhai loader onto `EvalAltResult` strings.
116/// Splitting it this way is what lets both share the policy without either
117/// changing its published error contract.
118#[derive(Debug)]
119pub enum HttpPolicyError {
120 /// The URL is outside the granted [`Capability::Network`] allow-list.
121 NotAllowed,
122 /// No [`HttpEgress`] implementation was wired in.
123 NoEgress,
124 /// The transport itself failed.
125 Transport(FnError),
126 /// The response carried a `>= 400` status.
127 Status(u16),
128}
129
130/// Resolve the per-call HTTP timeout from the granted capabilities.
131///
132/// The first [`Capability::WallClockMillisPerCall`] in the set wins; absent
133/// one, [`DEFAULT_HTTP_TIMEOUT`].
134#[must_use]
135pub fn resolve_http_timeout(caps: &CapabilitySet) -> Duration {
136 caps.iter()
137 .find_map(|c| match c {
138 Capability::WallClockMillisPerCall(ms) => Some(Duration::from_millis(*ms)),
139 _ => None,
140 })
141 .unwrap_or(DEFAULT_HTTP_TIMEOUT)
142}
143
144/// Run a capability-gated HTTP request: allow-list check, egress presence,
145/// timeout resolution, dispatch, then the `>= 400` status gate.
146///
147/// `body` present selects POST, absent selects GET. `traceparent` is the host's
148/// active W3C trace context, threaded through as a parameter rather than read
149/// from ambient state so the dispatch stays unit-testable.
150///
151/// # Errors
152///
153/// Returns [`HttpPolicyError`] for each refusal reason; see its variants.
154pub fn http_request(
155 egress: Option<&Arc<dyn HttpEgress>>,
156 caps: &CapabilitySet,
157 url: &str,
158 body: Option<&[u8]>,
159 traceparent: Option<&str>,
160) -> Result<HttpResponse, HttpPolicyError> {
161 if !caps.iter().any(|c| c.network_allows(url)) {
162 return Err(HttpPolicyError::NotAllowed);
163 }
164 let egress = egress.ok_or(HttpPolicyError::NoEgress)?;
165 let timeout = resolve_http_timeout(caps);
166
167 let response = match body {
168 Some(b) => egress.post(url, b, timeout, MAX_HTTP_RESPONSE_BYTES, traceparent),
169 None => egress.get(url, timeout, MAX_HTTP_RESPONSE_BYTES, traceparent),
170 }
171 .map_err(HttpPolicyError::Transport)?;
172
173 if response.status >= 400 {
174 return Err(HttpPolicyError::Status(response.status));
175 }
176 Ok(response)
177}