gproxy_protocol/capability/mod.rs
1//! Runtime-neutral capability contracts.
2//!
3//! Capabilities are deliberately smaller than a client implementation. They do
4//! not know about conversion, routing, credentials, persistence engines, or a
5//! common content representation. A host supplies the opaque target/scope
6//! types and enforces the limits returned by each capability instance.
7
8use std::{
9 error::Error,
10 fmt,
11 future::Future,
12 pin::Pin,
13 time::{Duration, SystemTime},
14};
15
16use bytes::Bytes;
17
18use crate::{WebSocket, WireRequest, WireResponse, connection::HttpBody};
19
20/// A boxed capability operation future.
21///
22/// Native futures are `Send`; wasm futures follow the connection stream
23/// contracts and do not require `Send`.
24#[cfg(not(target_arch = "wasm32"))]
25pub type CapabilityFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
26
27#[cfg(target_arch = "wasm32")]
28pub type CapabilityFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
29
30/// The limits a capability instance is required to enforce.
31///
32/// There is no implicit unlimited configuration. Hosts must return the limits
33/// bound to this particular instance and continue enforcing stream idle and
34/// byte limits after an operation has returned a streaming body.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct CapabilityLimits {
37 /// Total wall-clock time allowed for one operation, including body transfer.
38 pub operation_total: Duration,
39 /// Maximum idle interval between stream progress events.
40 pub stream_idle: Duration,
41 /// Maximum bytes accepted by a read or an upstream response body.
42 pub read_bytes: u64,
43 /// Maximum bytes accepted by a write or an upstream request body.
44 pub write_bytes: u64,
45 /// Maximum payload bytes in one WebSocket frame.
46 pub ws_frame_bytes: u64,
47}
48
49/// A stable category for a capability failure.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
52pub enum CapabilityErrorKind {
53 Unsupported,
54 NotFound,
55 Expired,
56 Conflict,
57 Limit,
58 Transport,
59 Storage,
60 Invalid,
61}
62
63/// Whether an error occurred before or during transfer.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
66pub enum CapabilityErrorStage {
67 Start,
68 BodyTransfer,
69 Stream,
70}
71
72/// A structured capability error that retains the originating error when one
73/// exists. HTTP status responses are not represented by this type: an upstream
74/// HTTP non-2xx response is still a successful `send` result and retains its
75/// complete response body.
76#[derive(Debug)]
77pub struct CapabilityError {
78 kind: CapabilityErrorKind,
79 stage: CapabilityErrorStage,
80 message: String,
81 source: Option<Box<dyn Error + Send + Sync + 'static>>,
82}
83
84impl CapabilityError {
85 /// Creates an error without a lower-level source.
86 pub fn new(
87 kind: CapabilityErrorKind,
88 stage: CapabilityErrorStage,
89 message: impl Into<String>,
90 ) -> Self {
91 Self {
92 kind,
93 stage,
94 message: message.into(),
95 source: None,
96 }
97 }
98
99 /// Creates an error while retaining the lower-level source.
100 pub fn with_source(
101 kind: CapabilityErrorKind,
102 stage: CapabilityErrorStage,
103 message: impl Into<String>,
104 source: impl Into<Box<dyn Error + Send + Sync + 'static>>,
105 ) -> Self {
106 Self {
107 kind,
108 stage,
109 message: message.into(),
110 source: Some(source.into()),
111 }
112 }
113
114 pub fn kind(&self) -> CapabilityErrorKind {
115 self.kind
116 }
117
118 pub fn stage(&self) -> CapabilityErrorStage {
119 self.stage
120 }
121
122 pub fn source_error(&self) -> Option<&(dyn Error + Send + Sync + 'static)> {
123 self.source.as_deref()
124 }
125}
126
127impl fmt::Display for CapabilityError {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 write!(f, "{} ({:?}/{:?})", self.message, self.kind, self.stage)
130 }
131}
132
133impl Error for CapabilityError {
134 fn source(&self) -> Option<&(dyn Error + 'static)> {
135 self.source
136 .as_deref()
137 .map(|source| source as &(dyn Error + 'static))
138 }
139}
140
141/// The result of a WebSocket-capable upstream connect attempt.
142pub enum UpstreamConnection {
143 /// The handshake succeeded and the socket is ready for frames.
144 Connected {
145 handshake: WireResponse<()>,
146 socket: WebSocket,
147 },
148 /// The upstream rejected the handshake. The complete response body is
149 /// retained so the host can decode or relay the vendor error.
150 Rejected(WireResponse<HttpBody>),
151}
152
153impl fmt::Debug for UpstreamConnection {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::Connected { handshake, .. } => f
157 .debug_struct("Connected")
158 .field("handshake", handshake)
159 .field("socket", &"WebSocket { .. }")
160 .finish(),
161 Self::Rejected(response) => f.debug_tuple("Rejected").field(response).finish(),
162 }
163 }
164}
165
166/// The upstream transport family.
167pub trait Upstream {
168 /// Opaque host-owned destination identity, origin, and authentication.
169 type Target;
170
171 /// Sends one already-encoded request. A non-2xx HTTP response is returned
172 /// as `Ok(WireResponse)` and is never converted into a capability error.
173 /// Hosts must validate that the request path is origin-relative and
174 /// compatible with `target`, and must replace or remove request auth in
175 /// both headers and query parameters (such as `key` or `access_token`)
176 /// rather than inheriting source credentials. Implementations never route or
177 /// recursively invoke conversion.
178 fn send<'a>(
179 &'a self,
180 target: &'a Self::Target,
181 request: WireRequest<HttpBody>,
182 ) -> CapabilityFuture<'a, Result<WireResponse<HttpBody>, CapabilityError>>;
183
184 /// Performs an HTTP upgrade. A rejected handshake is a response result,
185 /// including its full body, while transport failures are errors. The same
186 /// origin-relative path, target compatibility, and source-auth replacement
187 /// rules as [`Self::send`] apply.
188 fn connect<'a>(
189 &'a self,
190 target: &'a Self::Target,
191 request: WireRequest<()>,
192 ) -> CapabilityFuture<'a, Result<UpstreamConnection, CapabilityError>>;
193
194 /// Limits bound to this capability instance.
195 fn limits(&self) -> CapabilityLimits;
196}
197
198/// File-only metadata exposed by resource access. A filename is retained across
199/// upload and read so adapters can preserve the source file's identity.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct ResourceMetadata {
202 pub mime: Option<String>,
203 pub length: Option<u64>,
204 pub filename: Option<String>,
205 pub expires_at: Option<SystemTime>,
206}
207
208/// A resource locator understood by the host boundary. An `Id` is interpreted
209/// using the associated scope's upstream and identity; a `Url` remains subject
210/// to host authorization. Neither form grants deletion authority.
211#[derive(Debug, Clone, PartialEq, Eq, Hash)]
212#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
213pub enum ResourceReference {
214 Id(String),
215 Url(String),
216}
217
218/// The representation requested for a newly published resource. If the host
219/// cannot provide the requested form, it must reject before consuming the body
220/// or causing any remote side effect.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
223pub enum PublicationKind {
224 Id,
225 Url,
226}
227
228/// A resource body and its metadata.
229pub struct ResourceRead {
230 pub metadata: ResourceMetadata,
231 pub body: HttpBody,
232}
233
234impl fmt::Debug for ResourceRead {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.debug_struct("ResourceRead")
237 .field("metadata", &self.metadata)
238 .field("body", &self.body)
239 .finish()
240 }
241}
242
243/// The handle and reference returned by a successful publication.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct PublishedResource<H> {
246 pub handle: H,
247 pub reference: ResourceReference,
248 pub metadata: ResourceMetadata,
249}
250
251/// The durable status of an idempotent publication operation.
252#[derive(Debug, Clone, PartialEq, Eq)]
253#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
254pub enum PublicationStatus<H> {
255 Missing,
256 Pending,
257 Published(PublishedResource<H>),
258 Expired,
259}
260
261/// Scoped resource publication and access.
262pub trait ResourceAccess {
263 type Scope;
264 type PublishedHandle;
265
266 /// Returns file metadata or `NotFound`/`Expired` as appropriate.
267 fn resolve<'a>(
268 &'a self,
269 scope: &'a Self::Scope,
270 reference: &'a ResourceReference,
271 ) -> CapabilityFuture<'a, Result<ResourceMetadata, CapabilityError>>;
272
273 /// Reads a resource as a streamable body with file-only metadata.
274 fn read<'a>(
275 &'a self,
276 scope: &'a Self::Scope,
277 reference: &'a ResourceReference,
278 ) -> CapabilityFuture<'a, Result<ResourceRead, CapabilityError>>;
279
280 /// Publishes one body under a host-assigned reference and handle.
281 ///
282 /// `operation_id` is idempotency-scoped by `scope`. Hosts check and update
283 /// its stored state atomically, including stored expiry, before validating
284 /// new metadata. No preceding status query is required.
285 ///
286 /// A duplicate completed operation with the same `kind` returns the original
287 /// publication and metadata without consuming the new body. A different
288 /// kind or a pending operation returns `Conflict`; a released or expired
289 /// operation returns `Expired`, irrespective of newly supplied metadata.
290 /// An existing publication's metadata is never overwritten by a retry.
291 /// Callers can inspect the durable state with
292 /// [`Self::publication_status`]. If the caller is dropped after remote
293 /// publication but before receiving the handle, it can recover the result
294 /// with [`Self::publication_status`] without replaying the body. The
295 /// metadata expiry for a new operation must be present and in the future;
296 /// otherwise the host returns `Invalid` before consuming the body. If `kind`
297 /// is unsupported, the host returns `Unsupported` before any side effect.
298 /// Rejection and duplicate paths do not consume the supplied body.
299 fn publish<'a>(
300 &'a self,
301 scope: &'a Self::Scope,
302 operation_id: &'a str,
303 kind: PublicationKind,
304 metadata: ResourceMetadata,
305 body: HttpBody,
306 ) -> CapabilityFuture<'a, Result<PublishedResource<Self::PublishedHandle>, CapabilityError>>;
307
308 /// Recovers publication state after cancellation or a lost result.
309 fn publication_status<'a>(
310 &'a self,
311 scope: &'a Self::Scope,
312 operation_id: &'a str,
313 ) -> CapabilityFuture<'a, Result<PublicationStatus<Self::PublishedHandle>, CapabilityError>>;
314
315 /// Releases a published handle after the host verifies signed ownership and
316 /// scope. This is intentionally separate from publication and does not add
317 /// a delete-existing-resource operation for arbitrary references.
318 /// On success the operation becomes `Expired`, retaining its idempotency
319 /// record: status queries and duplicate publishes must not return a stale
320 /// publication or recreate the resource. Expiry has the same terminal effect.
321 fn release<'a>(
322 &'a self,
323 scope: &'a Self::Scope,
324 handle: &'a Self::PublishedHandle,
325 ) -> CapabilityFuture<'a, Result<(), CapabilityError>>;
326
327 fn limits(&self) -> CapabilityLimits;
328}
329
330/// An opaque, host-generated state version. Versions must never be reused,
331/// including after delete and recreate.
332#[derive(Debug, Clone, PartialEq, Eq, Hash)]
333pub struct Version(Vec<u8>);
334
335impl Version {
336 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
337 Self(bytes.into())
338 }
339
340 pub fn as_bytes(&self) -> &[u8] {
341 &self.0
342 }
343}
344
345/// A state entry returned by a store read.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct StateEntry {
348 pub payload: Bytes,
349 pub version: Version,
350 pub expires_at: Option<SystemTime>,
351}
352
353/// Replacement data for a state CAS. `None` replacement means delete.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct StateWrite {
356 pub payload: Bytes,
357 pub expires_at: Option<SystemTime>,
358}
359
360/// The outcome of a scoped compare-and-exchange.
361#[derive(Debug, Clone, PartialEq, Eq)]
362#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
363pub enum CasResult {
364 /// The write was applied. A replacement has a fresh version; deletion has
365 /// no resulting entry and therefore returns `None`.
366 Applied(Option<Version>),
367 /// The expected version did not match. No current entry is disclosed.
368 Conflict,
369}
370
371/// An atomically scoped state store.
372pub trait StateStore {
373 type Scope;
374
375 /// Expired entries are treated as absent, consistently for reads and CAS.
376 fn get<'a>(
377 &'a self,
378 scope: &'a Self::Scope,
379 key: &'a str,
380 ) -> CapabilityFuture<'a, Result<Option<StateEntry>, CapabilityError>>;
381
382 /// `expected = None` means absent. The operation is atomic within `scope`;
383 /// a conflict never returns the current entry. Hosts generate versions and
384 /// never reuse them across delete/recreate cycles.
385 fn compare_exchange<'a>(
386 &'a self,
387 scope: &'a Self::Scope,
388 key: &'a str,
389 expected: Option<Version>,
390 replacement: Option<StateWrite>,
391 ) -> CapabilityFuture<'a, Result<CasResult, CapabilityError>>;
392
393 fn limits(&self) -> CapabilityLimits;
394}