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 cases, because three are distinguishable by an implementation. A fourth that said
60/// "something went wrong" would be a `Str` with extra steps.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum Failure {
63    /// Connect or write failed, or nothing is listening.
64    Unreachable(String),
65    /// The exchange did not finish inside the deadline the implementation was given.
66    TimedOut(i64),
67    /// Bytes arrived and were not an HTTP response.
68    BadResponse(String),
69}
70
71/// Somewhere for an outbound request to go.
72pub trait Outbound: Send + Sync + fmt::Debug {
73    fn fetch(&self, request: &Request) -> Result<Reply, Failure>;
74}
75
76/// The default: every request fails, and says why in a sentence a program can print.
77///
78/// A process that has not installed a client has not *decided* to make outbound calls, and
79/// `beck test` is the ordinary case — a `net.out` atom is auto-stubbed there (§21.3), so a test
80/// that reaches this has one the harness could not stub.
81#[derive(Clone, Copy, Debug, Default)]
82pub struct Refusing;
83
84impl Outbound for Refusing {
85    fn fetch(&self, request: &Request) -> Result<Reply, Failure> {
86        Err(Failure::Unreachable(format!(
87            "no outbound HTTP client is installed in this process, so `{}` was not called",
88            request.host
89        )))
90    }
91}
92
93/// A canned client: replies decided in advance, requests kept.
94///
95/// The seam's second implementation, and a seam with one implementation is an abstraction nobody
96/// has checked. It is also what a Rust-level test uses when it wants to assert what a program
97/// *sent*, which a stub in Beck cannot see.
98#[derive(Debug, Default)]
99pub struct Canned {
100    replies: std::sync::Mutex<Vec<Result<Reply, Failure>>>,
101    sent: std::sync::Mutex<Vec<Request>>,
102}
103
104impl Canned {
105    /// Replies are handed out in order; a request past the end gets [`Failure::Unreachable`].
106    pub fn new(replies: Vec<Result<Reply, Failure>>) -> Canned {
107        Canned {
108            replies: std::sync::Mutex::new(replies.into_iter().rev().collect()),
109            sent: std::sync::Mutex::new(Vec::new()),
110        }
111    }
112
113    /// One 200 with this body, once.
114    pub fn ok(body: &str) -> Canned {
115        Canned::new(vec![Ok(Reply {
116            status: 200,
117            headers: vec![(Arc::from("content-type"), Arc::from("application/json"))],
118            body: Arc::from(body),
119        })])
120    }
121
122    pub fn sent(&self) -> Vec<Request> {
123        self.sent.lock().expect("not poisoned").clone()
124    }
125}
126
127impl Outbound for Canned {
128    fn fetch(&self, request: &Request) -> Result<Reply, Failure> {
129        self.sent
130            .lock()
131            .expect("not poisoned")
132            .push(request.clone());
133        self.replies
134            .lock()
135            .expect("not poisoned")
136            .pop()
137            .unwrap_or_else(|| {
138                Err(Failure::Unreachable(
139                    "this canned client has no reply left".into(),
140                ))
141            })
142    }
143}
144
145static PROCESS: OnceLock<Arc<dyn Outbound>> = OnceLock::new();
146
147/// The process's outbound client — read by the evaluator, which has no other way to reach one.
148pub fn process_outbound() -> &'static Arc<dyn Outbound> {
149    PROCESS.get_or_init(|| Arc::new(Refusing))
150}
151
152/// Install it. Returns `false` if one has already been read or installed.
153///
154/// Once, at startup, before anything reads it — the same discipline and the same reason as
155/// [`crate::clock::set_process_clock`]: a test binary running two tests in one process must not
156/// abort on the second.
157pub fn set_process_outbound(client: Arc<dyn Outbound>) -> bool {
158    PROCESS.set(client).is_ok()
159}
160
161/// Is this a host the `net.out(host)` atom can name?
162///
163/// The atom is written in a `uses` clause as bare tokens — `net.out(payments.example.com)` — so a
164/// host a program *calls* has to be one a program can *declare*. Dotted ASCII labels, which is
165/// what a DNS name is and what a NetworkPolicy peer is written as. No port: a port is transport,
166/// and the policy peer is the name.
167pub fn is_nameable_host(host: &str) -> bool {
168    !host.is_empty()
169        && host.len() <= 253
170        && host.split('.').all(|label| {
171            !label.is_empty()
172                && label.len() <= 63
173                && !label.starts_with('-')
174                && !label.ends_with('-')
175                && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
176        })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn the_default_client_refuses_and_names_the_host() {
185        let r = Request {
186            host: Arc::from("api.example.com"),
187            port: 80,
188            tls: false,
189            method: Arc::from("GET"),
190            path: Arc::from("/"),
191            headers: Vec::new(),
192            body: Arc::from(""),
193        };
194        match Refusing.fetch(&r) {
195            Err(Failure::Unreachable(why)) => assert!(why.contains("api.example.com"), "{why}"),
196            other => panic!("expected a refusal, got {other:?}"),
197        }
198    }
199
200    #[test]
201    fn a_canned_client_hands_out_its_replies_in_order_and_keeps_what_was_sent() {
202        let c = Canned::new(vec![
203            Ok(Reply {
204                status: 204,
205                headers: Vec::new(),
206                body: Arc::from(""),
207            }),
208            Err(Failure::TimedOut(1_000)),
209        ]);
210        let req = |path: &str| Request {
211            host: Arc::from("h.example.com"),
212            port: 80,
213            tls: false,
214            method: Arc::from("GET"),
215            path: Arc::from(path),
216            headers: Vec::new(),
217            body: Arc::from(""),
218        };
219        assert_eq!(c.fetch(&req("/a")).map(|r| r.status), Ok(204));
220        assert_eq!(c.fetch(&req("/b")), Err(Failure::TimedOut(1_000)));
221        assert!(matches!(c.fetch(&req("/c")), Err(Failure::Unreachable(_))));
222        let sent: Vec<Arc<str>> = c.sent().into_iter().map(|r| r.path).collect();
223        assert_eq!(
224            sent,
225            vec![Arc::from("/a"), Arc::from("/b"), Arc::from("/c")]
226        );
227    }
228
229    #[test]
230    fn a_host_is_nameable_when_a_uses_clause_could_have_written_it() {
231        assert!(is_nameable_host("payments.example.com"));
232        assert!(is_nameable_host("localhost"));
233        assert!(is_nameable_host("api-2.example.com"));
234        // A port is transport, and the policy peer is the name.
235        assert!(!is_nameable_host("api.example.com:8080"));
236        assert!(!is_nameable_host("api..example.com"));
237        assert!(!is_nameable_host("-api.example.com"));
238        assert!(!is_nameable_host("https://api.example.com"));
239        assert!(!is_nameable_host(""));
240        assert!(!is_nameable_host("héllo.example.com"));
241    }
242}