beck_core/host.rs
1//! What a *host* answers, as one description rather than one per backend.
2//!
3//! # Why this is here and not in a backend
4//!
5//! Four of Beck's primitives cannot be computed. `uuid()` and `now()` are `nondet`, `secret_env`
6//! is `env`, and `http_fetch` is `net.out(host)` — each of them is a question whose answer is
7//! outside the program, which is exactly what an effect atom *is* (§3.2). A backend does not know
8//! the answers; it knows how to ask.
9//!
10//! The tree-walker asked by calling four methods on `beck_eval::interp::Host`, which is a trait in
11//! the evaluator's crate. A second backend that reached the same four answers a second way would
12//! be two descriptions of one thing, and the differential between the backends would be comparing
13//! them rather than comparing the *program*. So the four live here, on [`Atoms`], and the
14//! evaluator's `Host` extends it.
15//!
16//! # The defaults are the seams
17//!
18//! Every method has a default, and every default goes through the process seam
19//! [`docs/14-review-findings.md`](../../../../../docs/14-review-findings.md) F11 asks for:
20//! [`crate::clock`] for the wall clock, [`crate::net`] for the outbound call, the process
21//! environment for a secret. A host that wants to answer differently overrides one method; a host
22//! that does not still never names a clock or a network stack.
23//!
24//! # A request is a value, and that conversion is here too
25//!
26//! `http_fetch` takes a value the program built and answers with a value the program reads, and
27//! the translation between those and [`crate::net`]'s `Request`/`Reply` is neither the evaluator's
28//! business nor a compiler's. [`request_of`], [`reply_value`] and [`failure_value`] are that
29//! translation, in one place, so that two backends making the same call cannot send two different
30//! requests.
31
32use std::sync::Arc;
33
34use crate::core::{Fields, Value};
35use crate::net::{Failure, Reply, Request, Stop};
36use crate::pmap::PMap;
37
38/// The impure capabilities a host supplies, one method per effect atom.
39///
40/// `Send + Sync` because a compiled backend hands one to a worker that the runtime calls from a
41/// sequencer task and a connection task alike, and a host that cannot survive that is not a host
42/// for this runtime.
43pub trait Atoms: Send + Sync {
44 /// Mint an id — `nondet`. Called only where the checker has proved we are not inside a fold.
45 fn new_uuid(&self) -> Arc<str> {
46 Arc::from(uuid_v7())
47 }
48
49 /// Read the wall clock — `nondet`, and forbidden inside a fold for the same reason `uuid()`
50 /// is: time is data on the envelope (§3.7).
51 fn now_millis(&self) -> i64 {
52 crate::clock::process_clock().now_millis()
53 }
54
55 /// Read a secret from the process environment — `env`, which no client tier discharges.
56 fn secret(&self, name: &str) -> Arc<str> {
57 std::env::var(name).unwrap_or_default().into()
58 }
59
60 /// Make an outbound request — the runtime half of `net.out(host)`.
61 ///
62 /// `stop` is how a `parallel:` reaches a child that is blocked in the socket rather than in
63 /// the evaluator ([`crate::net::Stop`]). A host that answers without blocking ignores it; one
64 /// that talks to a peer watches it, and a caller that cannot be cancelled passes
65 /// [`crate::net::Stop::never`].
66 fn fetch(&self, request: &Request, stop: &Stop) -> Result<Reply, Failure> {
67 crate::net::process_outbound().fetch(request, stop)
68 }
69}
70
71/// The host every default answers for: the process this is running in.
72///
73/// A named type rather than an anonymous one because a backend has to be able to say what it was
74/// given when nobody gave it anything, and "`ProcessAtoms`" is an answer where a `Box<dyn Atoms>`
75/// built out of nothing is not.
76#[derive(Clone, Copy, Debug, Default)]
77pub struct ProcessAtoms;
78
79impl Atoms for ProcessAtoms {}
80
81/// A time-ordered id, without pulling a uuid crate into the tree for one call.
82///
83/// UUIDv7 layout: 48 bits of Unix milliseconds, then version and variant bits, then randomness.
84/// The randomness comes from the system, via `getrandom` through the standard library's hash seed
85/// — good enough for an id at the edge, and never reached inside a fold, which is where
86/// determinism actually matters (§3.7).
87pub fn uuid_v7() -> String {
88 use std::collections::hash_map::RandomState;
89 use std::hash::{BuildHasher, Hasher};
90
91 let ms = (crate::clock::process_clock().now_millis().max(0) as u64) & 0x0000_FFFF_FFFF_FFFF;
92 let rand = || RandomState::new().build_hasher().finish();
93 let (a, b) = (rand(), rand());
94
95 let mut bytes = [0u8; 16];
96 bytes[..6].copy_from_slice(&ms.to_be_bytes()[2..]);
97 bytes[6..12].copy_from_slice(&a.to_be_bytes()[..6]);
98 bytes[12..].copy_from_slice(&b.to_be_bytes()[..4]);
99 bytes[6] = (bytes[6] & 0x0F) | 0x70; // version 7
100 bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 10
101
102 let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
103 format!(
104 "{}-{}-{}-{}-{}",
105 &h[0..8],
106 &h[8..12],
107 &h[12..16],
108 &h[16..20],
109 &h[20..32]
110 )
111}
112
113/// An `HttpRequest` the program built, as the request the seam sends.
114///
115/// The host is not read out of the value: it is the argument of the `net.out(host)` atom the call
116/// site performs, so it arrives separately and cannot be computed (§6.5).
117pub fn request_of(host: &str, v: &Value) -> Result<Request, String> {
118 let field = |name: &str| v.field(name).cloned().unwrap_or(Value::Unit);
119 let text = |name: &str| -> Arc<str> {
120 match field(name) {
121 Value::Str(s) => Arc::from(s.as_str()),
122 _ => Arc::from(""),
123 }
124 };
125 let port = match field("port") {
126 Value::Int(p) if (1..=65_535).contains(&p) => p as u16,
127 Value::Int(0) | Value::Unit => 80,
128 Value::Int(p) => return Err(format!("`{p}` is not a port an outbound call can use")),
129 _ => 80,
130 };
131 let mut headers: Vec<(Arc<str>, Arc<str>)> = match field("headers") {
132 Value::Map(m) => m
133 .iter()
134 .filter_map(|(k, val)| match (k, val) {
135 (Value::Str(k), Value::Str(v)) => {
136 Some((Arc::from(k.as_str()), Arc::from(v.as_str())))
137 }
138 _ => None,
139 })
140 .collect(),
141 _ => Vec::new(),
142 };
143 // The secret half, unwrapped here and nowhere else. §3.5 gives a program no way to read a
144 // `secret[Str]`; this is the edge, past every tier the checker places, so the credential
145 // becomes bytes exactly where it becomes a request and never becomes a value the program
146 // could have put somewhere else.
147 if let Value::Map(m) = field("secrets") {
148 for (k, val) in m.iter() {
149 if let (Value::Str(name), Some(Value::Str(secret))) = (k, val.field("value")) {
150 headers.push((Arc::from(name.as_str()), Arc::from(secret.as_str())));
151 }
152 }
153 }
154 let method = text("method");
155 Ok(Request {
156 host: Arc::from(host),
157 port,
158 // Plaintext unless the program said otherwise, which is the same default `lib/http.beck`
159 // writes: `over_tls` is a call somebody makes, so a request that crosses the internet
160 // without one is a thing in the source rather than a thing in the runtime.
161 tls: field("tls").as_bool().unwrap_or(false),
162 method: if method.is_empty() {
163 Arc::from("GET")
164 } else {
165 method
166 },
167 path: {
168 let p = text("path");
169 if p.is_empty() {
170 Arc::from("/")
171 } else {
172 p
173 }
174 },
175 headers,
176 body: text("body"),
177 })
178}
179
180/// What came back, as the `HttpResponse` the program reads.
181pub fn reply_value(reply: &Reply) -> Value {
182 let headers = reply.headers.iter().fold(PMap::new(), |m, (k, v)| {
183 m.insert(Value::str_(k), Value::str_(v))
184 });
185 Value::data(
186 Arc::from("HttpResponse"),
187 None,
188 Fields::from_iter([
189 (Arc::from("status"), Value::Int(reply.status)),
190 (Arc::from("headers"), Value::Map(headers)),
191 (Arc::from("body"), Value::str_(&reply.body)),
192 ]),
193 )
194}
195
196/// The seam's [`Failure`] as the `HttpError` the call raises.
197///
198/// The host is put back in here rather than carried through the failure, because the seam's
199/// implementation was told which host it was calling and there is no case where the two differ.
200pub fn failure_value(host: &str, f: &Failure) -> Value {
201 let (variant, fields): (&str, Vec<(Arc<str>, Value)>) = match f {
202 Failure::Unreachable(why) => (
203 "HttpUnreachable",
204 vec![
205 (Arc::from("host"), Value::str_(host)),
206 (Arc::from("why"), Value::str_(why)),
207 ],
208 ),
209 Failure::TimedOut(ms) => (
210 "HttpTimedOut",
211 vec![
212 (Arc::from("host"), Value::str_(host)),
213 (Arc::from("millis"), Value::Int(*ms)),
214 ],
215 ),
216 Failure::BadResponse(why) => (
217 "HttpBadResponse",
218 vec![(Arc::from("why"), Value::str_(why))],
219 ),
220 // The seam's fourth case, rendered as the third. `HttpError` is a published union and a
221 // program cannot observe this one — `beck-eval` turns a stopped fetch back into the
222 // cancellation it came from — so a fourth variant would be a wire change bought for
223 // nothing (`beck check --wire-compat`).
224 Failure::Stopped => (
225 "HttpUnreachable",
226 vec![
227 (Arc::from("host"), Value::str_(host)),
228 (
229 Arc::from("why"),
230 Value::str_("the caller stopped waiting for this reply"),
231 ),
232 ],
233 ),
234 };
235 Value::data(
236 Arc::from("HttpError"),
237 Some(Arc::from(variant)),
238 fields.into_iter().collect(),
239 )
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn minted_ids_are_v7_shaped_and_distinct() {
248 let a = uuid_v7();
249 let b = uuid_v7();
250 assert_ne!(a, b);
251 assert_eq!(a.len(), 36);
252 assert_eq!(a.as_bytes()[14], b'7', "version nibble: {a}");
253 assert!(
254 matches!(a.as_bytes()[19], b'8' | b'9' | b'a' | b'b'),
255 "variant: {a}"
256 );
257 }
258
259 /// The port rule is the one place this conversion can refuse, and it refuses by naming the
260 /// number rather than by defaulting quietly.
261 #[test]
262 fn a_port_no_call_can_use_is_refused_by_number() {
263 let v = Value::data(
264 Arc::from("HttpRequest"),
265 None,
266 Fields::from_iter([(Arc::from("port"), Value::Int(70_000))]),
267 );
268 let why = request_of("example.com", &v).expect_err("70000 is not a port");
269 assert!(why.contains("70000"), "{why}");
270 }
271
272 #[test]
273 fn an_absent_field_takes_the_default_the_library_writes() {
274 let v = Value::data(Arc::from("HttpRequest"), None, Fields::default());
275 let r = request_of("example.com", &v).expect("defaults");
276 assert_eq!(&*r.method, "GET");
277 assert_eq!(&*r.path, "/");
278 assert_eq!(r.port, 80);
279 assert!(!r.tls);
280 }
281}