//! `HttpService` — the v1.1.4 outbound-HTTP contract. //! //! Lives in `picloud-shared` (not `executor-core` or `manager-core`) //! so the Rhai bridge and the manager-core reqwest-backed impl can both //! depend on the same trait without dragging `executor-core` into //! `manager-core`'s dep graph — mirrors [`crate::kv`]. //! //! Unlike KV/docs, `http::*` has no app-scoped data, so there is no //! cross-app isolation boundary to enforce here. `cx.app_id` is still //! forwarded for audit-log attribution and (future, v1.2) per-app rate //! limits. The load-bearing security mechanism is the SSRF deny-list //! applied to the *resolved IP* — that lives in the manager-core impl, //! not in this contract. //! //! Body encoding + per-method dispatch happen in the Rhai bridge before //! the request reaches this trait: the service receives an already- //! encoded body plus a `content_type`, so the impl stays a thin //! transport layer. use std::collections::BTreeMap; use async_trait::async_trait; use thiserror::Error; use crate::SdkCallCx; /// A fully-resolved outbound request. The bridge builds this from the /// script-facing `(url, body, opts)` arguments; the service backend /// turns it into a real network call. #[derive(Debug, Clone)] pub struct HttpRequest { /// Uppercased HTTP method (`GET`, `POST`, …). The escape-hatch /// `http::request(method, …)` lets scripts pass arbitrary methods, /// so the impl validates this rather than the bridge. pub method: String, pub url: String, /// Caller-supplied headers, merged into the request. Header names /// are case-insensitive on the wire; stored verbatim here. pub headers: BTreeMap, /// Already-encoded body. `None` means no body (GET/HEAD, or an /// explicit `()` body). pub body: Option>, /// Content-Type the bridge chose for `body` (e.g. /// `application/json`). Ignored when the caller set their own /// `Content-Type` header. `None` when there is no body. pub content_type: Option, /// Total request budget in ms (already clamped to the 60s ceiling /// by the bridge). pub timeout_ms: u32, pub follow_redirects: bool, /// Max redirects to follow (already clamped to 10 by the bridge). pub max_redirects: u32, /// Script id for the default `User-Agent` and audit attribution. /// `None` when unavailable (the bridge always sets it from /// `cx`-adjacent context, but the field stays optional so the /// trait isn't coupled to how the id is sourced). pub script_id: Option, } /// The response shape the bridge turns into a Rhai map. JSON parsing of /// `body_raw` happens in the bridge (it needs the Rhai value types), so /// the service returns only the raw string + lowercased headers. #[derive(Debug, Clone)] pub struct HttpResponse { pub status: u16, /// Header names lowercased (per the documented response shape). pub headers: BTreeMap, pub body_raw: String, } /// Failure modes surfaced to the Rhai bridge. The bridge prefixes each /// `Display` string with `"http: "`. **None of these may leak the /// resolved IP** — the SSRF reason is a CIDR-category label only. #[derive(Debug, Error)] pub enum HttpError { /// Caller principal lacked `AppHttpRequest`. Only raised when /// `cx.principal.is_some()`; public-HTTP scripts skip the check. #[error("forbidden")] Forbidden, /// URL failed to parse, or carried no host. #[error("invalid url: {0}")] InvalidUrl(String), /// Scheme other than http/https (file, ftp, gopher, …). #[error("scheme not allowed: {0}")] BlockedScheme(String), /// Destination port is on the explicit block list (22, 25, 465, 587). #[error("port not allowed: {0}")] BlockedPort(u16), /// Resolved IP hit the SSRF deny-list. `reason` is a CIDR-category /// label (e.g. "loopback", "private", "link-local") — never the IP. #[error("blocked by SSRF policy: {0}")] Ssrf(String), /// The request exceeded the wall-clock budget. #[error("request timed out")] Timeout, /// Request or response body exceeded the configured size cap. /// `which` is `"request"` or `"response"`. #[error("{0} body exceeds size limit")] BodyTooLarge(&'static str), /// DNS / connect / TLS failure. The message is generic and MUST NOT /// contain the resolved IP. #[error("{0}")] Network(String), /// F-Q-008: user-supplied input failed validation (bad HTTP method /// name, malformed header name/value). Distinct from `Backend` /// because operators may safely retry a `Backend` error but should /// never retry an `InvalidArgs` failure — the input itself is wrong. #[error("{0}")] InvalidArgs(String), /// Anything else the impl wants to surface (still safe to show a /// script). #[error("{0}")] Backend(String), } /// Stub used by the executor-core test harness so engine integration /// tests (which don't make real network calls) can construct a /// `Services` bundle. Every call errors so accidental use surfaces. #[derive(Debug, Default, Clone, Copy)] pub struct NoopHttpService; #[async_trait] impl HttpService for NoopHttpService { async fn request(&self, _cx: &SdkCallCx, _req: HttpRequest) -> Result { Err(HttpError::Network("http is not wired in".into())) } } /// Outbound-HTTP contract. A single generic `request` method funnels /// every verb (`get`/`post`/…/`request`); the bridge maps the /// script-facing surface onto it. #[async_trait] pub trait HttpService: Send + Sync { async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result; }