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;