bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! StorageClient capability trait and types for blob / object stores.
//!
//! A `StorageClientPlugin` is the runtime side of an object store: get a
//! key, put a key, delete a key, list keys under a prefix, and mint a
//! presigned URL. Backends range from S3 / R2 / MinIO / B2 to local
//! filesystem and in-memory stores for tests. The trait is narrow on
//! purpose — anything that looks like "named blob in a flat keyspace"
//! fits; anything richer (consistency tiers, object versioning beyond a
//! single `etag`, multipart upload) stays backend-specific.
//!
//! Design notes:
//!
//! - Payloads are `Vec<u8>`, not `Bytes`. Matches the rest of the crate,
//!   keeps the WASM ABI flat, and lets callers wrap in `Bytes` themselves
//!   when they want the cheap-clone semantics. Bytes-in-the-trait would
//!   pull `bytes` into every WASM guest, which is not worth the ergonomic
//!   win for a leaf crate.
//!
//! - `list` is paginated via [`StorageClientPlugin::list_page`], not a
//!   `BoxStream`. Reasons: (1) keeps the trait synchronous, matching every
//!   other E1/E2 capability; (2) avoids dragging `futures` into a leaf
//!   crate that otherwise only depends on `serde` + `serde_json`; (3)
//!   mirrors how every real object store exposes list (S3 / R2 / GCS /
//!   Azure all hand out a continuation token). Callers who want a
//!   stream-shaped surface can build one on top in two lines.
//!
//! - Errors are a flat enum with four variants — `NotFound`,
//!   `AccessDenied`, `QuotaExceeded`, `Backend(String)` — because callers
//!   branch on the classification. A route that returns 404 on `NotFound`,
//!   403 on `AccessDenied`, and 500 otherwise is a common pattern, and
//!   forcing callers to parse a stringly-typed error to implement it is a
//!   papercut we can avoid cheaply. Matches the shape of
//!   [`crate::session::SessionError`].
//!
//! - The trait is sync-only, matching the rest of the plugin API. Native
//!   backends that need async I/O (aws-sdk-s3, etc.) drive their own
//!   runtime inside each method via `tokio::runtime::Handle::block_on`
//!   or a dedicated `Runtime`. WASM guests go through the host-function
//!   bridge.

/// Errors a storage backend can return.
///
/// Kept as a flat enum so the shape is stable across backends. Callers
/// branch on the variant to map to HTTP status codes (e.g. 404 / 403 /
/// 413 / 500) without having to parse a message string.
#[derive(Debug, Clone)]
pub enum StorageError {
    /// Key not found. Maps to HTTP 404 in the usual caller pattern.
    /// Returned by `get`, `delete`, and `presigned_url` for non-existent
    /// keys; `list_page` returns an empty page instead of `NotFound`.
    NotFound,
    /// Authentication / authorization failure. Maps to HTTP 403. Covers
    /// both "wrong credentials" and "credentials valid but not allowed
    /// to touch this key".
    AccessDenied,
    /// Backend rejected the operation because a quota (object size,
    /// request rate, per-bucket ceiling) was exceeded. Maps to HTTP 413
    /// when the caller is surfacing this to a user — an upload that was
    /// too big.
    QuotaExceeded,
    /// Transport / storage layer failure (network, disk, provider 5xx,
    /// malformed response, SDK error). Maps to HTTP 500. The wrapped
    /// message is for logs, not for users.
    Backend(String),
}

impl std::fmt::Display for StorageError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound => f.write_str("storage object not found"),
            Self::AccessDenied => f.write_str("storage access denied"),
            Self::QuotaExceeded => f.write_str("storage quota exceeded"),
            Self::Backend(m) => write!(f, "storage backend error: {m}"),
        }
    }
}

impl std::error::Error for StorageError {}

/// A single object as observed via [`StorageClientPlugin::list_page`].
///
/// `etag` is the backend's content-addressable identifier — opaque to
/// callers, but stable for a given (key, content) pair. Useful for cache
/// validators and idempotent writes. `size` is the object size in bytes.
/// `last_modified_ms` is the unix millisecond of the backend's last-write
/// timestamp, or `0` if the backend does not expose one.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Object {
    pub key: String,
    pub size: u64,
    pub etag: String,
    pub last_modified_ms: u64,
}

/// Options for [`StorageClientPlugin::put`].
///
/// All fields default to empty / sensible, so callers can pass
/// `PutOpts::default()` for the common case of "upload these bytes".
#[derive(Debug, Clone, Default)]
pub struct PutOpts {
    /// Content-Type header the backend should serve the object with.
    /// Empty string means "let the backend decide" (usually
    /// `application/octet-stream`).
    pub content_type: String,
    /// Cache-Control header the backend should serve the object with.
    /// Empty string means "let the backend decide".
    pub cache_control: String,
    /// Opaque user metadata keys / values persisted alongside the
    /// object. Backends may constrain key charset (S3: ASCII-only, no
    /// colons) — callers are responsible for staying inside their
    /// backend's rules.
    pub metadata: Vec<(String, String)>,
}

/// Operation a presigned URL is minted for.
///
/// Deliberately narrow — the two operations every backend supports. We
/// can add `Delete` or `Head` later if a concrete caller needs one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PresignOp {
    /// Presigned download URL. The signed URL lets a third party `GET`
    /// the object without backend credentials.
    Get,
    /// Presigned upload URL. The signed URL lets a third party `PUT`
    /// bytes at the key without backend credentials.
    Put,
}

/// A plugin that owns a blob / object store.
///
/// Implementations are expected to be thread-safe and to hold any
/// connection-pool or client state internally behind `&self`. All
/// methods are synchronous — backends that need async I/O drive their
/// own runtime (see module docs).
///
/// Concurrency: callers may invoke any method from multiple threads
/// concurrently. `put` is last-write-wins unless the backend supports
/// conditional writes (not yet exposed in this trait).
pub trait StorageClientPlugin: Send + Sync {
    /// Unique identifier for this backend (e.g. `"s3"`, `"r2"`,
    /// `"minio"`, `"fs"`). Used in logs, metrics, and the TUI.
    fn name(&self) -> &str;

    /// Fetch the object at `key`. Returns `NotFound` if the key does not
    /// exist, `AccessDenied` if credentials are wrong or insufficient,
    /// `Backend(msg)` for transport failures.
    fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;

    /// Upload `body` to `key` with the given options. Overwrites any
    /// existing object. Returns `QuotaExceeded` if the upload exceeds a
    /// backend quota (object size, per-bucket limit), `AccessDenied` for
    /// credential failures, `Backend(msg)` otherwise.
    fn put(&self, key: &str, body: Vec<u8>, opts: PutOpts) -> Result<(), StorageError>;

    /// Delete the object at `key`. Idempotent: deleting a non-existent
    /// key is `Ok(())`, not `NotFound`. Returns `AccessDenied` for
    /// credential failures, `Backend(msg)` otherwise.
    fn delete(&self, key: &str) -> Result<(), StorageError>;

    /// List one page of objects under `prefix`.
    ///
    /// Returns `(objects, next_token)`. If `next_token` is `Some`, pass
    /// it back as `continuation_token` on the next call to get the next
    /// page. A `None` token means "no more pages". An empty page is a
    /// valid (non-error) response; backends should not return `NotFound`
    /// for an empty prefix.
    ///
    /// Page size is the backend's native default (usually 1000 for S3
    /// family). Callers that need smaller pages should filter
    /// client-side.
    fn list_page(
        &self,
        prefix: &str,
        continuation_token: Option<&str>,
    ) -> Result<(Vec<Object>, Option<String>), StorageError>;

    /// Mint a presigned URL for `key` valid for `ttl_secs` seconds.
    ///
    /// Returns the URL as a `String` — kept stringly-typed so leaf
    /// crates don't pull in `url`. Callers that want a parsed `Url`
    /// construct one themselves.
    ///
    /// Backends that cannot mint presigned URLs (e.g. a pure-filesystem
    /// plugin) should return `Backend("presigned URLs not supported")`
    /// rather than panicking.
    fn presigned_url(
        &self,
        key: &str,
        op: PresignOp,
        ttl_secs: u64,
    ) -> Result<String, StorageError>;

    /// Health check. Default: always healthy. Backends with a
    /// long-lived client should override to ping the service (e.g.
    /// `HeadBucket` for S3).
    fn is_healthy(&self) -> bool {
        true
    }
}

/// Fuel budgets for WASM storage plugin calls.
///
/// Matches the shape in [`crate::types::fuel`]. Object I/O is
/// dominated by network cost from the host's perspective, so per-call
/// fuel only covers the plugin-side marshalling; the real rate
/// limiting happens in the host's outbound HTTP budget.
pub mod fuel {
    /// Fuel for a single [`super::StorageClientPlugin::get`] call.
    pub const GET: u64 = 50_000_000;
    /// Fuel for [`super::StorageClientPlugin::put`]. Larger budget
    /// because marshalling the upload body dominates.
    pub const PUT: u64 = 200_000_000;
    /// Fuel for [`super::StorageClientPlugin::delete`].
    pub const DELETE: u64 = 20_000_000;
    /// Fuel for [`super::StorageClientPlugin::list_page`].
    pub const LIST_PAGE: u64 = 30_000_000;
    /// Fuel for [`super::StorageClientPlugin::presigned_url`] — pure
    /// CPU, no I/O, so a small budget.
    pub const PRESIGNED_URL: u64 = 10_000_000;
}