Skip to main content

termivar_scanner/plugin/
transport.rs

1use async_trait::async_trait;
2use serde::Serialize;
3use std::{collections::BTreeMap, fmt};
4use tokio_util::sync::CancellationToken;
5use url::Url;
6
7use super::{
8    limits::{
9        invalid_config, HARD_MAX_PLUGIN_RESPONSE_BODY_BYTES, MAX_PLUGIN_HEADERS,
10        MAX_PLUGIN_HEADER_NAME_BYTES, MAX_PLUGIN_HEADER_VALUE_BYTES, MAX_PLUGIN_URL_BYTES,
11    },
12    PluginError,
13};
14
15/// Bodyless request methods exposed through the host broker.
16#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "UPPERCASE")]
19pub enum PluginHttpMethod {
20    /// Read a representation.
21    Get,
22    /// Read response metadata only.
23    Head,
24    /// Discover server-declared method support.
25    Options,
26}
27
28/// Immutable request passed to the host-owned broker.
29pub struct PluginHttpRequest {
30    pub(super) method: PluginHttpMethod,
31    pub(super) url: Url,
32    pub(super) max_response_body_bytes: u64,
33    pub(super) cancellation: CancellationToken,
34}
35
36impl PluginHttpRequest {
37    /// Request method.
38    pub const fn method(&self) -> PluginHttpMethod {
39        self.method
40    }
41
42    /// Exact scoped URL.
43    pub fn url(&self) -> &Url {
44        &self.url
45    }
46
47    /// Maximum response bytes the broker may read and retain for this request.
48    ///
49    /// The host derives this from both the per-response ceiling and the
50    /// invocation-wide unreserved remainder. Brokers must stop body collection
51    /// at this boundary and mark the response truncated when more data exists.
52    pub const fn max_response_body_bytes(&self) -> u64 {
53        self.max_response_body_bytes
54    }
55
56    /// Invocation-scoped cancellation signal.
57    pub fn cancellation(&self) -> &CancellationToken {
58        &self.cancellation
59    }
60}
61
62impl fmt::Debug for PluginHttpRequest {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        formatter
65            .debug_struct("PluginHttpRequest")
66            .field("method", &self.method)
67            .field("origin", &origin_string(&self.url))
68            .field("path", &"[redacted]")
69            .field("max_response_body_bytes", &self.max_response_body_bytes)
70            .finish_non_exhaustive()
71    }
72}
73
74/// Bounded response returned by a host-owned request broker.
75pub struct PluginHttpResponse {
76    status: u16,
77    final_url: Url,
78    headers: BTreeMap<String, String>,
79    body: Vec<u8>,
80    pub(super) delivered_body_bytes: u64,
81    body_truncated: bool,
82}
83
84impl PluginHttpResponse {
85    /// Creates a response with no headers and exact retained/delivered length.
86    pub fn new(status: u16, final_url: Url, body: Vec<u8>) -> Result<Self, PluginError> {
87        if !(100..=599).contains(&status) {
88            return Err(invalid_config(
89                "plugin broker returned an invalid HTTP status",
90            ));
91        }
92        if body.len() > HARD_MAX_PLUGIN_RESPONSE_BODY_BYTES as usize {
93            return Err(PluginError::ResponseBodyBudgetExceeded {
94                actual: u64::try_from(body.len()).unwrap_or(u64::MAX),
95                maximum: HARD_MAX_PLUGIN_RESPONSE_BODY_BYTES,
96            });
97        }
98        let delivered_body_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX);
99        Ok(Self {
100            status,
101            final_url,
102            headers: BTreeMap::new(),
103            body,
104            delivered_body_bytes,
105            body_truncated: false,
106        })
107    }
108
109    /// Adds one bounded response header.
110    pub fn with_header(
111        mut self,
112        name: impl Into<String>,
113        value: impl Into<String>,
114    ) -> Result<Self, PluginError> {
115        let name = name.into().to_ascii_lowercase();
116        let value = value.into();
117        validate_header(&name, &value)?;
118        if !self.headers.contains_key(&name) && self.headers.len() >= MAX_PLUGIN_HEADERS {
119            return Err(invalid_config(
120                "plugin response header count exceeds the maximum",
121            ));
122        }
123        self.headers.insert(name, value);
124        Ok(self)
125    }
126
127    /// Sets delivered-byte accounting and truncation state reported by the host.
128    pub fn with_capture_metadata(
129        mut self,
130        delivered_body_bytes: u64,
131        body_truncated: bool,
132    ) -> Result<Self, PluginError> {
133        let retained = u64::try_from(self.body.len()).unwrap_or(u64::MAX);
134        if delivered_body_bytes < retained {
135            return Err(invalid_config(
136                "delivered response bytes cannot be smaller than retained bytes",
137            ));
138        }
139        if !body_truncated && delivered_body_bytes != retained {
140            return Err(invalid_config(
141                "an incomplete response body must be marked truncated",
142            ));
143        }
144        self.delivered_body_bytes = delivered_body_bytes;
145        self.body_truncated = body_truncated;
146        Ok(self)
147    }
148
149    /// HTTP status.
150    pub const fn status(&self) -> u16 {
151        self.status
152    }
153
154    /// Final URL reported by the broker.
155    pub fn final_url(&self) -> &Url {
156        &self.final_url
157    }
158
159    /// Case-normalized response header value.
160    pub fn header(&self, name: &str) -> Option<&str> {
161        self.headers
162            .get(&name.to_ascii_lowercase())
163            .map(String::as_str)
164    }
165
166    /// Retained bounded body.
167    pub fn body(&self) -> &[u8] {
168        &self.body
169    }
170
171    /// Bytes delivered by the broker before retention stopped.
172    pub const fn delivered_body_bytes(&self) -> u64 {
173        self.delivered_body_bytes
174    }
175
176    /// Whether the host truncated retention.
177    pub const fn body_truncated(&self) -> bool {
178        self.body_truncated
179    }
180}
181
182impl fmt::Debug for PluginHttpResponse {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter
185            .debug_struct("PluginHttpResponse")
186            .field("status", &self.status)
187            .field("final_origin", &origin_string(&self.final_url))
188            .field("header_count", &self.headers.len())
189            .field("retained_body_bytes", &self.body.len())
190            .field("delivered_body_bytes", &self.delivered_body_bytes)
191            .field("body_truncated", &self.body_truncated)
192            .finish()
193    }
194}
195
196/// Host-owned transport capability used by plugin contexts.
197///
198/// Implementations must not follow redirects or retry requests. They must stop
199/// body collection at [`PluginHttpRequest::max_response_body_bytes`]. The
200/// context independently checks the request and final response origin, capture
201/// metadata, and immutable accounting envelope.
202#[async_trait]
203pub trait PluginRequestBroker: Send + Sync {
204    /// Executes one already-scoped bodyless request.
205    async fn execute(&self, request: PluginHttpRequest) -> Result<PluginHttpResponse, PluginError>;
206}
207
208pub(super) fn validate_authorized_origin(origin: &Url) -> Result<(), PluginError> {
209    if !matches!(origin.scheme(), "http" | "https")
210        || origin.host_str().is_none()
211        || !origin.username().is_empty()
212        || origin.password().is_some()
213        || origin.path() != "/"
214        || origin.query().is_some()
215        || origin.fragment().is_some()
216        || origin.as_str().len() > MAX_PLUGIN_URL_BYTES
217    {
218        return Err(PluginError::ScopeViolation);
219    }
220    Ok(())
221}
222
223pub(super) fn validate_scoped_url(origin: &Url, url: &Url) -> Result<(), PluginError> {
224    if !matches!(url.scheme(), "http" | "https")
225        || url.host_str().is_none()
226        || !url.username().is_empty()
227        || url.password().is_some()
228        || url.fragment().is_some()
229        || url.origin() != origin.origin()
230    {
231        return Err(PluginError::ScopeViolation);
232    }
233    Ok(())
234}
235
236pub(super) fn origin_string(url: &Url) -> String {
237    url.origin().ascii_serialization()
238}
239
240fn validate_header(name: &str, value: &str) -> Result<(), PluginError> {
241    if name.is_empty()
242        || name.len() > MAX_PLUGIN_HEADER_NAME_BYTES
243        || value.len() > MAX_PLUGIN_HEADER_VALUE_BYTES
244        || !name
245            .bytes()
246            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
247        || value.contains(['\r', '\n'])
248    {
249        return Err(invalid_config("plugin response header is invalid"));
250    }
251    Ok(())
252}