Skip to main content

bext_plugin_api/
storage.rs

1//! StorageClient capability trait and types for blob / object stores.
2//!
3//! A `StorageClientPlugin` is the runtime side of an object store: get a
4//! key, put a key, delete a key, list keys under a prefix, and mint a
5//! presigned URL. Backends range from S3 / R2 / MinIO / B2 to local
6//! filesystem and in-memory stores for tests. The trait is narrow on
7//! purpose — anything that looks like "named blob in a flat keyspace"
8//! fits; anything richer (consistency tiers, object versioning beyond a
9//! single `etag`, multipart upload) stays backend-specific.
10//!
11//! Design notes:
12//!
13//! - Payloads are `Vec<u8>`, not `Bytes`. Matches the rest of the crate,
14//!   keeps the WASM ABI flat, and lets callers wrap in `Bytes` themselves
15//!   when they want the cheap-clone semantics. Bytes-in-the-trait would
16//!   pull `bytes` into every WASM guest, which is not worth the ergonomic
17//!   win for a leaf crate.
18//!
19//! - `list` is paginated via [`StorageClientPlugin::list_page`], not a
20//!   `BoxStream`. Reasons: (1) keeps the trait synchronous, matching every
21//!   other E1/E2 capability; (2) avoids dragging `futures` into a leaf
22//!   crate that otherwise only depends on `serde` + `serde_json`; (3)
23//!   mirrors how every real object store exposes list (S3 / R2 / GCS /
24//!   Azure all hand out a continuation token). Callers who want a
25//!   stream-shaped surface can build one on top in two lines.
26//!
27//! - Errors are a flat enum with four variants — `NotFound`,
28//!   `AccessDenied`, `QuotaExceeded`, `Backend(String)` — because callers
29//!   branch on the classification. A route that returns 404 on `NotFound`,
30//!   403 on `AccessDenied`, and 500 otherwise is a common pattern, and
31//!   forcing callers to parse a stringly-typed error to implement it is a
32//!   papercut we can avoid cheaply. Matches the shape of
33//!   [`crate::session::SessionError`].
34//!
35//! - The trait is sync-only, matching the rest of the plugin API. Native
36//!   backends that need async I/O (aws-sdk-s3, etc.) drive their own
37//!   runtime inside each method via `tokio::runtime::Handle::block_on`
38//!   or a dedicated `Runtime`. WASM guests go through the host-function
39//!   bridge.
40
41/// Errors a storage backend can return.
42///
43/// Kept as a flat enum so the shape is stable across backends. Callers
44/// branch on the variant to map to HTTP status codes (e.g. 404 / 403 /
45/// 413 / 500) without having to parse a message string.
46#[derive(Debug, Clone)]
47pub enum StorageError {
48    /// Key not found. Maps to HTTP 404 in the usual caller pattern.
49    /// Returned by `get`, `delete`, and `presigned_url` for non-existent
50    /// keys; `list_page` returns an empty page instead of `NotFound`.
51    NotFound,
52    /// Authentication / authorization failure. Maps to HTTP 403. Covers
53    /// both "wrong credentials" and "credentials valid but not allowed
54    /// to touch this key".
55    AccessDenied,
56    /// Backend rejected the operation because a quota (object size,
57    /// request rate, per-bucket ceiling) was exceeded. Maps to HTTP 413
58    /// when the caller is surfacing this to a user — an upload that was
59    /// too big.
60    QuotaExceeded,
61    /// Transport / storage layer failure (network, disk, provider 5xx,
62    /// malformed response, SDK error). Maps to HTTP 500. The wrapped
63    /// message is for logs, not for users.
64    Backend(String),
65}
66
67impl std::fmt::Display for StorageError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::NotFound => f.write_str("storage object not found"),
71            Self::AccessDenied => f.write_str("storage access denied"),
72            Self::QuotaExceeded => f.write_str("storage quota exceeded"),
73            Self::Backend(m) => write!(f, "storage backend error: {m}"),
74        }
75    }
76}
77
78impl std::error::Error for StorageError {}
79
80/// A single object as observed via [`StorageClientPlugin::list_page`].
81///
82/// `etag` is the backend's content-addressable identifier — opaque to
83/// callers, but stable for a given (key, content) pair. Useful for cache
84/// validators and idempotent writes. `size` is the object size in bytes.
85/// `last_modified_ms` is the unix millisecond of the backend's last-write
86/// timestamp, or `0` if the backend does not expose one.
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
88pub struct Object {
89    pub key: String,
90    pub size: u64,
91    pub etag: String,
92    pub last_modified_ms: u64,
93}
94
95/// Options for [`StorageClientPlugin::put`].
96///
97/// All fields default to empty / sensible, so callers can pass
98/// `PutOpts::default()` for the common case of "upload these bytes".
99#[derive(Debug, Clone, Default)]
100pub struct PutOpts {
101    /// Content-Type header the backend should serve the object with.
102    /// Empty string means "let the backend decide" (usually
103    /// `application/octet-stream`).
104    pub content_type: String,
105    /// Cache-Control header the backend should serve the object with.
106    /// Empty string means "let the backend decide".
107    pub cache_control: String,
108    /// Opaque user metadata keys / values persisted alongside the
109    /// object. Backends may constrain key charset (S3: ASCII-only, no
110    /// colons) — callers are responsible for staying inside their
111    /// backend's rules.
112    pub metadata: Vec<(String, String)>,
113}
114
115/// Operation a presigned URL is minted for.
116///
117/// Deliberately narrow — the two operations every backend supports. We
118/// can add `Delete` or `Head` later if a concrete caller needs one.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum PresignOp {
122    /// Presigned download URL. The signed URL lets a third party `GET`
123    /// the object without backend credentials.
124    Get,
125    /// Presigned upload URL. The signed URL lets a third party `PUT`
126    /// bytes at the key without backend credentials.
127    Put,
128}
129
130/// A plugin that owns a blob / object store.
131///
132/// Implementations are expected to be thread-safe and to hold any
133/// connection-pool or client state internally behind `&self`. All
134/// methods are synchronous — backends that need async I/O drive their
135/// own runtime (see module docs).
136///
137/// Concurrency: callers may invoke any method from multiple threads
138/// concurrently. `put` is last-write-wins unless the backend supports
139/// conditional writes (not yet exposed in this trait).
140pub trait StorageClientPlugin: Send + Sync {
141    /// Unique identifier for this backend (e.g. `"s3"`, `"r2"`,
142    /// `"minio"`, `"fs"`). Used in logs, metrics, and the TUI.
143    fn name(&self) -> &str;
144
145    /// Fetch the object at `key`. Returns `NotFound` if the key does not
146    /// exist, `AccessDenied` if credentials are wrong or insufficient,
147    /// `Backend(msg)` for transport failures.
148    fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
149
150    /// Upload `body` to `key` with the given options. Overwrites any
151    /// existing object. Returns `QuotaExceeded` if the upload exceeds a
152    /// backend quota (object size, per-bucket limit), `AccessDenied` for
153    /// credential failures, `Backend(msg)` otherwise.
154    fn put(&self, key: &str, body: Vec<u8>, opts: PutOpts) -> Result<(), StorageError>;
155
156    /// Delete the object at `key`. Idempotent: deleting a non-existent
157    /// key is `Ok(())`, not `NotFound`. Returns `AccessDenied` for
158    /// credential failures, `Backend(msg)` otherwise.
159    fn delete(&self, key: &str) -> Result<(), StorageError>;
160
161    /// List one page of objects under `prefix`.
162    ///
163    /// Returns `(objects, next_token)`. If `next_token` is `Some`, pass
164    /// it back as `continuation_token` on the next call to get the next
165    /// page. A `None` token means "no more pages". An empty page is a
166    /// valid (non-error) response; backends should not return `NotFound`
167    /// for an empty prefix.
168    ///
169    /// Page size is the backend's native default (usually 1000 for S3
170    /// family). Callers that need smaller pages should filter
171    /// client-side.
172    fn list_page(
173        &self,
174        prefix: &str,
175        continuation_token: Option<&str>,
176    ) -> Result<(Vec<Object>, Option<String>), StorageError>;
177
178    /// Mint a presigned URL for `key` valid for `ttl_secs` seconds.
179    ///
180    /// Returns the URL as a `String` — kept stringly-typed so leaf
181    /// crates don't pull in `url`. Callers that want a parsed `Url`
182    /// construct one themselves.
183    ///
184    /// Backends that cannot mint presigned URLs (e.g. a pure-filesystem
185    /// plugin) should return `Backend("presigned URLs not supported")`
186    /// rather than panicking.
187    fn presigned_url(
188        &self,
189        key: &str,
190        op: PresignOp,
191        ttl_secs: u64,
192    ) -> Result<String, StorageError>;
193
194    /// Health check. Default: always healthy. Backends with a
195    /// long-lived client should override to ping the service (e.g.
196    /// `HeadBucket` for S3).
197    fn is_healthy(&self) -> bool {
198        true
199    }
200}
201
202/// Fuel budgets for WASM storage plugin calls.
203///
204/// Matches the shape in [`crate::types::fuel`]. Object I/O is
205/// dominated by network cost from the host's perspective, so per-call
206/// fuel only covers the plugin-side marshalling; the real rate
207/// limiting happens in the host's outbound HTTP budget.
208pub mod fuel {
209    /// Fuel for a single [`super::StorageClientPlugin::get`] call.
210    pub const GET: u64 = 50_000_000;
211    /// Fuel for [`super::StorageClientPlugin::put`]. Larger budget
212    /// because marshalling the upload body dominates.
213    pub const PUT: u64 = 200_000_000;
214    /// Fuel for [`super::StorageClientPlugin::delete`].
215    pub const DELETE: u64 = 20_000_000;
216    /// Fuel for [`super::StorageClientPlugin::list_page`].
217    pub const LIST_PAGE: u64 = 30_000_000;
218    /// Fuel for [`super::StorageClientPlugin::presigned_url`] — pure
219    /// CPU, no I/O, so a small budget.
220    pub const PRESIGNED_URL: u64 = 10_000_000;
221}