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}