beck_core/
net.rs

1//! Outbound HTTP, as a thing that is supplied rather than a thing that is ambient.
2//!
3//! [`docs/14-review-findings.md`](../../../../../docs/14-review-findings.md) F11 names three
4//! resources that cannot be retrofitted — clock, **network** and disk.
5//! [`crate::clock`] is the first of them. This is the second, and it arrives with the feature that
6//! first needs it rather than after: a program's outbound call goes through this trait, so a
7//! simulator, a recorder and a refusal are all the same shape.
8//!
9//! # What is on the seam
10//!
11//! One request/response exchange, which is what the `http_fetch` primitive is. Not connection
12//! reuse, not a pool, not a redirect policy, not retries — those are decisions of an
13//! implementation, and an implementation is what this trait is for.
14//!
15//! # Transport security is a field, not a mode
16//!
17//! [`Request::tls`] says whether the exchange is over TLS, and it is a field of the request rather
18//! than a property of the client, because a program that calls two peers may reach one of them
19//! over a plaintext hop inside a cluster and the other across the internet. What a *name* is
20//! verified against is the implementation's business; that the caller asked for TLS is the
21//! program's ([`docs/adr/0023`](../../../../../docs/adr/0023-tls-and-the-signature-it-brings.md)).
22
23use std::fmt;
24use std::sync::Arc;
25use std::sync::OnceLock;
26
27/// One outbound request.
28///
29/// The **host is not part of the body of the program**: it is the argument of the `net.out(host)`
30/// atom the call site performs, which is what becomes a NetworkPolicy peer (§6.5). Everything else
31/// here is data a program computed.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct Request {
34    pub host: Arc<str>,
35    pub port: u16,
36    /// Whether the exchange happens inside a TLS session whose certificate names [`Request::host`].
37    ///
38    /// A field rather than a scheme in the path, because the host is already the atom the call
39    /// site performs and a URL would give a program a second place to write it.
40    pub tls: bool,
41    pub method: Arc<str>,
42    /// Origin-form: `/v1/todos?limit=10`. Sent as written — this seam does not encode, because a
43    /// program that built a path is the only thing that knows what in it was data.
44    pub path: Arc<str>,
45    pub headers: Vec<(Arc<str>, Arc<str>)>,
46    pub body: Arc<str>,
47}
48
49/// What came back. A status is a *reply*, not a failure — including a 500.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Reply {
52    pub status: i64,
53    pub headers: Vec<(Arc<str>, Arc<str>)>,
54    pub body: Arc<str>,
55}
56
57/// Why no reply came back.
58///
59/// Three of these are the peer's doing and one is the caller's, which is the distinction worth
60/// keeping: a program that asked and did not get an answer has a failure to handle, and a program
61/// whose *scope* stopped wanting the answer has nothing to handle at all. A fifth that said
62/// "something went wrong" would be a `Str` with extra steps.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum Failure {
65    /// Connect or write failed, or nothing is listening.
66    Unreachable(String),
67    /// The exchange did not finish inside the deadline the implementation was given.
68    TimedOut(i64),
69    /// Bytes arrived and were not an HTTP response.
70    BadResponse(String),
71    /// The caller stopped wanting the reply — [`Stop::asked`] became true while the exchange was
72    /// in flight.
73    ///
74    /// Not a failure of the request, and a caller that installed a [`Stop`] is expected to notice
75    /// this before it renders anything: `beck-eval` turns it back into the cancellation it came
76    /// from. `crate::host::failure_value` renders it as `HttpUnreachable` if one ever reaches a
77    /// program, because `HttpError` is a *published* union and a fourth variant would be a wire
78    /// change made for a case no program can observe.
79    Stopped,
80}
81
82/// Whether the caller still wants the reply.
83///
84/// [`docs/80`](../../../../../docs/80-structured-concurrency-report.md) §80.12 named the gap this
85/// closes: cancellation rides the evaluator's step counter, so a `parallel:` child blocked *inside*
86/// an outbound call was stopped only when the call came back — a scope whose first child failed
87/// still waited out a sibling's ten-second timeout. §80.12 also says where the fix belongs: "a
88/// **deadline on the [`net`](crate::net) seam** rather than a change to the scope", because the
89/// scope is already right about *when* to stop a child and the thing it cannot reach is a socket.
90///
91/// A predicate rather than a token or a channel, for the reason the rest of this seam is what it
92/// is: the caller already knows the answer — `beck-eval` has the chain of enclosing scopes and
93/// their first-failed indices — and anything richer would be a second copy of that state, kept in
94/// step by hand.
95#[derive(Clone)]
96pub struct Stop(Option<Arc<dyn Fn() -> bool + Send + Sync>>);
97
98impl Stop {
99    /// Nobody is going to stop this call, so an implementation need not watch for it.
100    ///
101    /// [`Stop::watched`] is false for this one, which is what lets a client take the path with no
102    /// timer in it — the ordinary case, since only a child of a `parallel:` can be cancelled.
103    pub fn never() -> Stop {
104        Stop(None)
105    }
106
107    pub fn when(f: impl Fn() -> bool + Send + Sync + 'static) -> Stop {
108        Stop(Some(Arc::new(f)))
109    }
110
111    /// Whether the caller has stopped wanting the reply. Called repeatedly, so it is cheap.
112    pub fn asked(&self) -> bool {
113        self.0.as_ref().is_some_and(|f| f())
114    }
115
116    /// Whether anybody could ever ask.
117    pub fn watched(&self) -> bool {
118        self.0.is_some()
119    }
120}
121
122impl fmt::Debug for Stop {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(if self.watched() {
125            "Stop(watched)"
126        } else {
127            "Stop(never)"
128        })
129    }
130}
131
132impl Default for Stop {
133    fn default() -> Stop {
134        Stop::never()
135    }
136}
137
138/// Somewhere for an outbound request to go.
139pub trait Outbound: Send + Sync + fmt::Debug {
140    /// Make the exchange, watching `stop` while it is in flight.
141    ///
142    /// `stop` is a parameter rather than a default-implemented second method on purpose: an
143    /// implementation that ignores it is one a `parallel:` cannot cancel, and a seam whose
144    /// implementations can opt out of a property by not mentioning it is
145    /// [`docs/82`](../../../../../docs/82-the-edge-report.md) §82.10's gate that cannot fail. Every
146    /// implementation has to say what it does about this, even if what it does is nothing because
147    /// it never blocks.
148    fn fetch(&self, request: &Request, stop: &Stop) -> Result<Reply, Failure>;
149}
150
151/// The default: every request fails, and says why in a sentence a program can print.
152///
153/// A process that has not installed a client has not *decided* to make outbound calls, and
154/// `beck test` is the ordinary case — a `net.out` atom is auto-stubbed there (§21.3), so a test
155/// that reaches this has one the harness could not stub.
156#[derive(Clone, Copy, Debug, Default)]
157pub struct Refusing;
158
159impl Outbound for Refusing {
160    /// Nothing to watch: this answers before it returns.
161    fn fetch(&self, request: &Request, _stop: &Stop) -> Result<Reply, Failure> {
162        Err(Failure::Unreachable(format!(
163            "no outbound HTTP client is installed in this process, so `{}` was not called",
164            request.host
165        )))
166    }
167}
168
169/// A canned client: replies decided in advance, requests kept.
170///
171/// The seam's second implementation, and a seam with one implementation is an abstraction nobody
172/// has checked. It is also what a Rust-level test uses when it wants to assert what a program
173/// *sent*, which a stub in Beck cannot see.
174#[derive(Debug, Default)]
175pub struct Canned {
176    replies: std::sync::Mutex<Vec<Result<Reply, Failure>>>,
177    sent: std::sync::Mutex<Vec<Request>>,
178}
179
180impl Canned {
181    /// Replies are handed out in order; a request past the end gets [`Failure::Unreachable`].
182    pub fn new(replies: Vec<Result<Reply, Failure>>) -> Canned {
183        Canned {
184            replies: std::sync::Mutex::new(replies.into_iter().rev().collect()),
185            sent: std::sync::Mutex::new(Vec::new()),
186        }
187    }
188
189    /// One 200 with this body, once.
190    pub fn ok(body: &str) -> Canned {
191        Canned::new(vec![Ok(Reply {
192            status: 200,
193            headers: vec![(Arc::from("content-type"), Arc::from("application/json"))],
194            body: Arc::from(body),
195        })])
196    }
197
198    pub fn sent(&self) -> Vec<Request> {
199        self.sent.lock().expect("not poisoned").clone()
200    }
201}
202
203impl Outbound for Canned {
204    /// Nothing to watch: a canned reply is already in hand.
205    fn fetch(&self, request: &Request, _stop: &Stop) -> Result<Reply, Failure> {
206        self.sent
207            .lock()
208            .expect("not poisoned")
209            .push(request.clone());
210        self.replies
211            .lock()
212            .expect("not poisoned")
213            .pop()
214            .unwrap_or_else(|| {
215                Err(Failure::Unreachable(
216                    "this canned client has no reply left".into(),
217                ))
218            })
219    }
220}
221
222static PROCESS: OnceLock<Arc<dyn Outbound>> = OnceLock::new();
223
224/// The process's outbound client — read by the evaluator, which has no other way to reach one.
225pub fn process_outbound() -> &'static Arc<dyn Outbound> {
226    PROCESS.get_or_init(|| Arc::new(Refusing))
227}
228
229/// Install it. Returns `false` if one has already been read or installed.
230///
231/// Once, at startup, before anything reads it — the same discipline and the same reason as
232/// [`crate::clock::set_process_clock`]: a test binary running two tests in one process must not
233/// abort on the second.
234pub fn set_process_outbound(client: Arc<dyn Outbound>) -> bool {
235    PROCESS.set(client).is_ok()
236}
237
238/// Is this a host the `net.out(host)` atom can name?
239///
240/// The atom is written in a `uses` clause as bare tokens — `net.out(payments.example.com)` — so a
241/// host a program *calls* has to be one a program can *declare*. Dotted ASCII labels, which is
242/// what a DNS name is and what a NetworkPolicy peer is written as. No port: a port is transport,
243/// and the policy peer is the name.
244pub fn is_nameable_host(host: &str) -> bool {
245    !host.is_empty()
246        && host.len() <= 253
247        && host.split('.').all(|label| {
248            !label.is_empty()
249                && label.len() <= 63
250                && !label.starts_with('-')
251                && !label.ends_with('-')
252                && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
253        })
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn the_default_client_refuses_and_names_the_host() {
262        let r = Request {
263            host: Arc::from("api.example.com"),
264            port: 80,
265            tls: false,
266            method: Arc::from("GET"),
267            path: Arc::from("/"),
268            headers: Vec::new(),
269            body: Arc::from(""),
270        };
271        match Refusing.fetch(&r, &Stop::never()) {
272            Err(Failure::Unreachable(why)) => assert!(why.contains("api.example.com"), "{why}"),
273            other => panic!("expected a refusal, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn a_canned_client_hands_out_its_replies_in_order_and_keeps_what_was_sent() {
279        let c = Canned::new(vec![
280            Ok(Reply {
281                status: 204,
282                headers: Vec::new(),
283                body: Arc::from(""),
284            }),
285            Err(Failure::TimedOut(1_000)),
286        ]);
287        let req = |path: &str| Request {
288            host: Arc::from("h.example.com"),
289            port: 80,
290            tls: false,
291            method: Arc::from("GET"),
292            path: Arc::from(path),
293            headers: Vec::new(),
294            body: Arc::from(""),
295        };
296        assert_eq!(
297            c.fetch(&req("/a"), &Stop::never()).map(|r| r.status),
298            Ok(204)
299        );
300        assert_eq!(
301            c.fetch(&req("/b"), &Stop::never()),
302            Err(Failure::TimedOut(1_000))
303        );
304        assert!(matches!(
305            c.fetch(&req("/c"), &Stop::never()),
306            Err(Failure::Unreachable(_))
307        ));
308        let sent: Vec<Arc<str>> = c.sent().into_iter().map(|r| r.path).collect();
309        assert_eq!(
310            sent,
311            vec![Arc::from("/a"), Arc::from("/b"), Arc::from("/c")]
312        );
313    }
314
315    #[test]
316    fn a_host_is_nameable_when_a_uses_clause_could_have_written_it() {
317        assert!(is_nameable_host("payments.example.com"));
318        assert!(is_nameable_host("localhost"));
319        assert!(is_nameable_host("api-2.example.com"));
320        // A port is transport, and the policy peer is the name.
321        assert!(!is_nameable_host("api.example.com:8080"));
322        assert!(!is_nameable_host("api..example.com"));
323        assert!(!is_nameable_host("-api.example.com"));
324        assert!(!is_nameable_host("https://api.example.com"));
325        assert!(!is_nameable_host(""));
326        assert!(!is_nameable_host("héllo.example.com"));
327    }
328}