Skip to main content

seq_runtime/
http_client.rs

1//! HTTP client operations for Seq.
2//!
3//! Replaces the ureq-era client with a hand-rolled HTTP/1.1
4//! implementation that yields the strand on every IO step. Sits on
5//! top of the may-aware DNS, TCP, and TLS layers from PR1-PR3 and
6//! maintains its own connection pool keyed by `(scheme, host, port)`.
7//!
8//! ## API (unchanged from the ureq-era surface)
9//!
10//! ```seq
11//! "https://api.example.com/users" net.http.get
12//! # Stack: ( Map ) where Map = { "status": 200, "body": "...", "ok": true }
13//!
14//! "https://api.example.com/users" "{\"name\":\"Alice\"}" "application/json" net.http.post
15//! # Stack: ( Map ) where Map = { "status": 201, "body": "...", "ok": true }
16//!
17//! dup "ok" map.get if
18//!   "body" map.get json.decode
19//! else
20//!   "error" map.get io.write-line
21//! then
22//! ```
23//!
24//! ## Response Map
25//!
26//! - `"status"` (Int): HTTP status code, or 0 on connection-level error.
27//! - `"body"` (String): response body as raw bytes (byte-clean — binary downloads round-trip intact).
28//! - `"ok"` (Bool): true iff status is 2xx.
29//! - `"error"` (String): error message; present only on failure.
30//!
31//! ## Security: SSRF protection
32//!
33//! Requests are blocked when the URL's host resolves to a private,
34//! loopback, link-local (cloud metadata), or unique-local IP. The
35//! check uses the may-aware DNS layer (see `crate::dns::resolve`) and
36//! passes its resolved address list to the connect path, so there is
37//! exactly one `getaddrinfo` per request and it runs on a dedicated
38//! worker thread — never on a may carrier.
39//!
40//! ## v1 limitations
41//!
42//! - No redirect following: 3xx is returned to the caller as-is.
43//! - No automatic decompression: we send `Accept-Encoding: identity`.
44//!   Use `compress.gunzip` etc. on the body if you ask for an encoded
45//!   transfer manually.
46//! - No per-request timeout (a deadline pass is planned across all
47//!   networking layers).
48//! - No client certificate authentication, ALPN selection, or
49//!   peer-cert inspection — inherited from `net.tls.client`.
50//! - No header customisation beyond `Content-Type` (set automatically
51//!   for POST/PUT).
52
53pub(crate) mod conn;
54mod pool;
55mod request;
56mod ssrf;
57mod wire;
58
59use crate::seqstring::{global_bytes, global_string};
60use crate::stack::{Stack, pop, push};
61use crate::value::{MapKey, Value};
62use std::collections::HashMap;
63
64// Re-export for the existing unit tests that pre-date the submodule
65// split.
66#[cfg(test)]
67pub(crate) use ssrf::{is_dangerous_ipv4, is_dangerous_ipv6, validate_url_for_ssrf};
68
69/// Build the response Map shape that user code consumes.
70///
71/// `body` is the raw response payload — HTTP bodies are arbitrary
72/// octets per RFC 9110, so we store them in a byte-clean SeqString
73/// without UTF-8 validation. Seq programs that need text decode the
74/// bytes themselves; binary downloads keep the original bytes intact.
75pub(crate) fn build_response_map(
76    status: i64,
77    body: Vec<u8>,
78    ok: bool,
79    error: Option<String>,
80) -> Value {
81    let mut map: HashMap<MapKey, Value> = HashMap::new();
82    map.insert(
83        MapKey::String(global_string("status".to_string())),
84        Value::Int(status),
85    );
86    map.insert(
87        MapKey::String(global_string("body".to_string())),
88        Value::String(global_bytes(body)),
89    );
90    map.insert(
91        MapKey::String(global_string("ok".to_string())),
92        Value::Bool(ok),
93    );
94    if let Some(err) = error {
95        map.insert(
96            MapKey::String(global_string("error".to_string())),
97            Value::String(global_string(err)),
98        );
99    }
100    Value::Map(Box::new(map))
101}
102
103/// Build an error response Map (status=0, ok=false).
104pub(crate) fn error_response(error: String) -> Value {
105    build_response_map(0, Vec::new(), false, Some(error))
106}
107
108/// HTTP GET. `( url -- response )`.
109///
110/// # Safety
111/// Stack must have a String (URL) on top.
112#[unsafe(no_mangle)]
113pub unsafe extern "C" fn patch_seq_http_get(stack: Stack) -> Stack {
114    assert!(!stack.is_null(), "http.get: stack is empty");
115    let (stack, url_value) = unsafe { pop(stack) };
116    match url_value {
117        Value::String(url) => {
118            let response = request::perform_request("GET", url.as_str_or_empty(), None);
119            unsafe { push(stack, response) }
120        }
121        _ => panic!("http.get: expected String (URL), got {:?}", url_value),
122    }
123}
124
125/// HTTP POST. `( url body content-type -- response )`.
126///
127/// # Safety
128/// Stack must have three Strings on top: url, body, content-type.
129#[unsafe(no_mangle)]
130pub unsafe extern "C" fn patch_seq_http_post(stack: Stack) -> Stack {
131    assert!(!stack.is_null(), "http.post: stack is empty");
132    let (stack, content_type_value) = unsafe { pop(stack) };
133    let (stack, body_value) = unsafe { pop(stack) };
134    let (stack, url_value) = unsafe { pop(stack) };
135    match (url_value, body_value, content_type_value) {
136        (Value::String(url), Value::String(body), Value::String(content_type)) => {
137            let response = request::perform_request(
138                "POST",
139                url.as_str_or_empty(),
140                Some((content_type.as_str_or_empty(), body.as_bytes())),
141            );
142            unsafe { push(stack, response) }
143        }
144        (url, body, ct) => panic!(
145            "http.post: expected (String, String, String), got ({:?}, {:?}, {:?})",
146            url, body, ct
147        ),
148    }
149}
150
151/// HTTP PUT. `( url body content-type -- response )`.
152///
153/// # Safety
154/// Stack must have three Strings on top: url, body, content-type.
155#[unsafe(no_mangle)]
156pub unsafe extern "C" fn patch_seq_http_put(stack: Stack) -> Stack {
157    assert!(!stack.is_null(), "http.put: stack is empty");
158    let (stack, content_type_value) = unsafe { pop(stack) };
159    let (stack, body_value) = unsafe { pop(stack) };
160    let (stack, url_value) = unsafe { pop(stack) };
161    match (url_value, body_value, content_type_value) {
162        (Value::String(url), Value::String(body), Value::String(content_type)) => {
163            let response = request::perform_request(
164                "PUT",
165                url.as_str_or_empty(),
166                Some((content_type.as_str_or_empty(), body.as_bytes())),
167            );
168            unsafe { push(stack, response) }
169        }
170        (url, body, ct) => panic!(
171            "http.put: expected (String, String, String), got ({:?}, {:?}, {:?})",
172            url, body, ct
173        ),
174    }
175}
176
177/// HTTP DELETE. `( url -- response )`.
178///
179/// # Safety
180/// Stack must have a String (URL) on top.
181#[unsafe(no_mangle)]
182pub unsafe extern "C" fn patch_seq_http_delete(stack: Stack) -> Stack {
183    assert!(!stack.is_null(), "http.delete: stack is empty");
184    let (stack, url_value) = unsafe { pop(stack) };
185    match url_value {
186        Value::String(url) => {
187            let response = request::perform_request("DELETE", url.as_str_or_empty(), None);
188            unsafe { push(stack, response) }
189        }
190        _ => panic!("http.delete: expected String (URL), got {:?}", url_value),
191    }
192}
193
194#[cfg(test)]
195mod integration_tests;
196#[cfg(test)]
197mod tests;