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}