cove_runtime/host.rs
1//! The Host API boundary.
2//!
3//! Cove code has no ambient authority. Files, network, clocks, processes, and
4//! databases are explicit capabilities with replaceable real, fake, filtered,
5//! or denied implementations. The runtime rejects Host API calls that were not
6//! granted.
7//!
8//! This module holds the boundary itself — [`HostApi`], [`Grants`], and
9//! [`HostRegistry`] — together with the three small modules that have nothing
10//! else to say: [`Console`], [`Env`], and [`Documents`]. A host with rules of
11//! its own gets a module of its own: [`crate::clock`], [`crate::files`],
12//! [`crate::http`], [`crate::process`], and [`crate::database`].
13//!
14//! Two things cross this boundary besides plain values. A [`ResourceHandle`]
15//! goes outwards: the host keeps a connection or a listening socket and
16//! Cove holds the name of it, so a later call names the resource the way a
17//! method names its receiver. A [`Reentry`] goes inwards: a host that was
18//! handed a Cove callback — a route's handler, a repeating timer's body, the
19//! work a timeout bounds — needs a way to run it, and this is the only one
20//! there is. Both are ADR 0013's.
21
22use std::collections::{BTreeMap, BTreeSet};
23use std::io::Write;
24use std::path::PathBuf;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29use cove_sema::Capability;
30
31use crate::budget::{Budget, Cancellation, Meter};
32use crate::error::RuntimeError;
33use crate::schema::{
34 Admits, Effect, Mismatch, ModuleSchema, OperationSchema, Part, ResourceSchema, TypeSchema,
35};
36use crate::trace::{HostOutcome, NullSink, RecordedValue, RunOutcome, TraceEvent, TraceSink};
37use crate::value::{Repr, Value};
38use crate::wallclock::Instant;
39
40/// One host-provided module, such as `console` or `env`.
41///
42/// A host is shared by every task of a run, so an operation is invoked
43/// through a shared reference and a host is `Send + Sync`. A host that needs
44/// mutable state of its own says so with a lock it owns, which is also what
45/// decides how much of it two tasks may do at once: `console` serializes its
46/// writes so a line is never torn, while `clock.sleep` holds nothing, so two
47/// tasks can wait at the same time instead of queueing behind each other.
48///
49/// # An operation that blocks
50///
51/// A host call is a hole in the run's safepoint chain. The interpreter checks
52/// fuel, the deadline, and cancellation at loop back edges, calls, and
53/// `await`, and a program sitting inside a host reaches none of the three;
54/// [`Budget::charge_host_call`] checks the deadline and the cancellation flag
55/// once more before dispatch, but that bounds when a call *starts*, not how
56/// long it runs. Nothing in the runtime can interrupt a host that is waiting
57/// in `accept` or `read`. So this is a contract the boundary states and each
58/// host keeps, rather than something the boundary can enforce.
59///
60/// An operation that waits must bound how long it waits. It polls in steps
61/// short enough that the run's controls are still responsive, asks the
62/// [`Reentry`] it was handed whether the run has been stopped
63/// ([`Reentry::is_cancelled`]) and how long it has left
64/// ([`Reentry::time_left`]) between steps, and holds no lock while it does —
65/// a host waiting under its own mutex blocks every other task that wants it.
66/// One total allowance covers a multi-part operation: a per-read timeout that
67/// starts again on every successful read bounds nothing, because a peer that
68/// makes slow progress can hold the call open forever. `http.Server.handle`
69/// is the worked example. It accepts by polling rather than blocking, gives
70/// the whole of one request a single deadline clamped by what the run has
71/// left, and answers "nothing more to serve" when the run is stopped, so the
72/// program's own loop ends and the budget reports the stop it owns.
73///
74/// An operation that genuinely cannot cooperate — a C library call with no
75/// timeout, a syscall that cannot be interrupted — must say so in its own
76/// documentation, so an embedder knows the run's deadline does not bound that
77/// call and can decide what to do about it. What is not acceptable is a host
78/// that blocks indefinitely and says nothing.
79///
80/// # Migrating from the five-accessor form
81///
82/// This trait used to ask a module to describe itself five times — `name()`,
83/// `capability()`, `schema()`, `types()`, and `resources()`. It asks once
84/// now, through [`HostApi::module_schema`], and the five are gone rather than
85/// defaulted: a defaulted accessor is an overridable one, and an overridable
86/// one is a second description of the module, which the checker and the
87/// boundary could then read differently. An implementation written against
88/// the old shape fails to compile with `not all trait items implemented`,
89/// which is the intended way to find out.
90///
91/// The migration is to delete all five and write the table they were reading
92/// from:
93///
94/// ```
95/// # use cove_runtime::error::RuntimeError;
96/// # use cove_runtime::host::HostApi;
97/// # use cove_runtime::schema::ModuleSchema;
98/// # use cove_runtime::value::Value;
99/// # struct Company;
100/// const COMPANY: ModuleSchema = ModuleSchema {
101/// name: "company",
102/// capability: "directory",
103/// operations: &[],
104/// types: &[],
105/// resources: &[],
106/// };
107///
108/// impl HostApi for Company {
109/// fn module_schema(&self) -> ModuleSchema {
110/// COMPANY
111/// }
112///
113/// fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
114/// # let _ = (op, args);
115/// // unchanged
116/// # Ok(Value::unit())
117/// }
118/// }
119/// ```
120///
121/// `name` is the string `name()` returned, `capability` is the string behind
122/// the `Capability` `capability()` returned, and the three slices are what
123/// `schema()`, `types()`, and `resources()` returned. `call`, `call_with`,
124/// and `call_resource` are untouched.
125///
126/// The one thing that gets harder is a schema assembled at run time. The old
127/// accessors handed back borrows of `self`, so a host could keep a `String`
128/// and a `Vec<OperationSchema>` and return references into itself;
129/// [`ModuleSchema`] is `Copy` with `'static` contents, so a module whose
130/// shape comes from configuration or a manifest builds its table once, leaks
131/// it, and hands out the same copy.
132///
133/// Putting a lifetime on [`ModuleSchema`] would not give the old form back.
134/// A module registered here is a `Box<dyn HostApi>`, which is
135/// `Box<dyn HostApi + 'static>` — a spawned task's thread holds the registry
136/// and `std::thread::Builder::spawn` takes a `'static` closure — so a host
137/// has nothing outside itself to borrow a schema from, and borrowing from a
138/// field beside another one of its own is a self-referential struct.
139/// [`ModuleSchema`]'s own documentation weighs that against the alternatives
140/// and spells the pattern out; `crates/cove-runtime/tests/embedding.rs` runs
141/// it end to end.
142pub trait HostApi: Send + Sync {
143 /// The whole of what this module declares about itself: the name Cove
144 /// source uses, the capability a host must grant for it, the operations
145 /// it exposes, the types it declares, and the kinds of resource it can
146 /// open.
147 ///
148 /// The schema is the module's declaration of itself: a host cannot
149 /// expose an operation without saying what it takes, what it produces,
150 /// what it costs the outside world, and whether its result may cross a
151 /// task boundary. The boundary holds every call to it, so an operation
152 /// arriving in `call` has already been checked against what is declared
153 /// here.
154 ///
155 /// One table rather than five methods, because this exact value is also
156 /// what the *checker* reads: `cove_sema::Compiler::with_host_schema`
157 /// takes a [`ModuleSchema`], so the description a run enforces and the
158 /// description `cove check` checked a call against are the same bytes
159 /// for an embedder's module as they already are for a shipped one. This
160 /// is the only way to ask a module what it declares — there is nothing
161 /// else on this trait to override instead, so a module cannot describe
162 /// itself one way to the boundary and another way to the checker.
163 fn module_schema(&self) -> ModuleSchema;
164
165 /// Invokes one operation.
166 ///
167 /// The default forwards to [`HostApi::call`], which is what a module that
168 /// never runs a Cove callback wants. A module that does — `clock.every`,
169 /// `http.Server.handle` — overrides this instead and leaves `call`
170 /// unreachable.
171 ///
172 /// `back` is the way into the program that made this call, and it is on
173 /// loan for the duration of the call and no longer. [`Reentry`] states
174 /// the whole of what may be done with it, and the parts an implementor is
175 /// most likely to get wrong are these: it may not be retained past this
176 /// return, it may be used as many times as the operation means, it may be
177 /// used from this thread only, and no lock this module owns may be held
178 /// while it is used, because the Cove code it runs may call this module
179 /// again.
180 fn call_with(
181 &self,
182 op: &str,
183 args: Vec<Value>,
184 back: &mut dyn Reentry,
185 ) -> Result<Value, RuntimeError> {
186 let _ = back;
187 self.call(op, args)
188 }
189
190 /// Invokes one operation.
191 fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError>;
192
193 /// Invokes one operation on a handle this module issued.
194 ///
195 /// A module that declares no resources can never be reached here, so the
196 /// default says so rather than inventing an answer.
197 ///
198 /// `back` is the same loan [`HostApi::call_with`] describes, under the
199 /// same rules, and the lock rule is sharper here than anywhere else: the
200 /// table of open resources is exactly the lock a module holds, and the
201 /// callback is exactly the code that may ask for a resource in it. Take
202 /// what the callback's work needs, release the guard, and reenter after.
203 fn call_resource(
204 &self,
205 handle: &ResourceHandle,
206 op: &str,
207 args: Vec<Value>,
208 back: &mut dyn Reentry,
209 ) -> Result<Value, RuntimeError> {
210 let _ = (args, back);
211 Err(RuntimeError::new(format!(
212 "host module `{}` issues no resource handles, so `{}.{op}` cannot be called",
213 self.module_schema().name,
214 handle.qualified_type()
215 )))
216 }
217}
218
219/// How a host runs a Cove callback it was handed.
220///
221/// A Host API call is a value in and a value out, which is enough until an
222/// operation is given work to do rather than data to act on. A route's
223/// handler, a repeating timer's body, and the block a timeout bounds are all
224/// Cove closures the host holds and has to run; without this they could be
225/// stored and never called.
226///
227/// The callback runs on the task that made the host call, on that task's own
228/// stack, charged to that task's budget. There is no second thread and no
229/// scheduler: a host that wants concurrency spawns nothing, because
230/// concurrency in Cove belongs to a task scope the program wrote.
231///
232/// It is also how a host asks about the run it is inside.
233/// [`Reentry::is_cancelled`] and [`Reentry::time_left`] answer the two
234/// questions an operation that waits has to keep asking, and they are the
235/// only way to ask them: a host holds no budget and no interpreter of its
236/// own. [`HostApi`] says what a host owes them.
237///
238/// And it is what the boundary itself asks one question of.
239/// [`Reentry::task`] says which task is calling, which nothing else at the
240/// boundary knows: a [`HostRegistry`] is shared by every thread of a run,
241/// while the way back borrows the interpreter of exactly one task. The answer
242/// goes on the call's trace event, so a trace of a run with concurrent tasks
243/// can be grouped by whose I/O each call was.
244///
245/// # For the current call, and no longer
246///
247/// A `&mut dyn Reentry` arrives with a lifetime of its own, shorter than the
248/// `&self` beside it, and `dyn Reentry` is neither `Send` nor `Sync`. So a
249/// host cannot put one in a field: a [`HostApi`] is `Send + Sync` and shared
250/// across the tasks of a run, and neither the lifetime nor the auto traits
251/// will let this through. Both refusals are deliberate rather than an
252/// accident of how the signature was written, and neither is worth working
253/// around with an `Arc` or a raw pointer. Behind the reference is a borrow of
254/// the interpreter running the task that made this call. It is valid while
255/// that call is on the stack, and once the call returns the interpreter has
256/// moved on: a retained one would name a stack frame the task has left, and
257/// would run Cove code on a task that is doing something else.
258///
259/// A host that wants work done later does not keep the way back. It keeps
260/// the callback — a [`Value`] is an ordinary owned value — and asks for
261/// another call, which is what `http.Server.handle` is and why the loop
262/// around it is written in Cove.
263///
264/// # As many times as the operation means
265///
266/// A host may call its callback none, once, or many times. `clock.every`
267/// calls it once a period until the timer's task is cancelled;
268/// `http.Server.handle` calls it once, and not at all when no request
269/// arrived. Nothing here counts invocations and nothing makes the second one
270/// cheaper than the first.
271///
272/// Each one is a call the run pays for in full. Fuel is charged at the
273/// callback's own safepoints, its calls count against the run's call-depth
274/// limit while it is on the stack, and the run's deadline and cancellation
275/// stop it wherever they would stop any other Cove code. A host that loops
276/// therefore does not have to police the run: a body that would overrun the
277/// budget stops of its own accord and the error comes back out of
278/// [`Reentry::call`]. What the host owes is to stop looping when it is told
279/// to — [`Reentry::is_cancelled`] between rounds — rather than to keep
280/// starting rounds that will all fail.
281///
282/// # Nested, up to a bound
283///
284/// A callback is Cove code, so it may call any host the run granted,
285/// including this one, and that host may in turn be handed work. The nesting
286/// is real: the second host call builds a second way back further down the
287/// same native stack, and it reenters the same interpreter, so the inner
288/// callback sees the same task, the same heap, and the same budget as the
289/// outer one.
290///
291/// How deep it may go is a runtime control, like recursion depth and for the
292/// same reason. Two bound it. The Cove frames a callback makes count against
293/// the run's call-depth limit, since they are ordinary calls. And the number
294/// of host calls running a callback that may be stacked on one thread is
295/// bounded separately and much lower, because between one callback's frame
296/// and the next sits however much native stack the host chose to use, which
297/// nothing can measure. Past that bound the next reentry is refused with a
298/// [`RuntimeError`]; the run stops, rather than the process. It is a bound
299/// and not a proof: a host that uses an enormous amount of stack before it
300/// reenters can still exhaust it at the first level, and that is the host's
301/// responsibility, not the boundary's.
302///
303/// # One at a time, on the calling thread
304///
305/// A host may not call back from a thread of its own, and cannot: there is
306/// exactly one `&mut dyn Reentry` per host call and it is neither `Send` nor
307/// `Sync`, so two threads cannot hold it and it cannot be moved to one. This
308/// is the design's answer and not a limitation waiting to be lifted.
309/// Concurrency in Cove belongs to a task scope the program wrote; a host that
310/// ran a program's code on threads the program never asked for would be
311/// deciding how much of that program runs at once, and would be doing it
312/// outside every control the run was given.
313///
314/// # No lock may be held across it
315///
316/// A host must not hold a resource mutex, or any other non-reentrant lock,
317/// while it calls a callback. The callback is Cove code and Cove code may
318/// call this same host again; a `std::sync::Mutex` is not reentrant, so the
319/// second call would deadlock the task on a lock the first call is holding
320/// three frames up its own stack. Nothing detects this, because from the
321/// lock's point of view nothing is wrong.
322///
323/// The shape that works is to take what the callback's work needs while the
324/// lock is held, release it, and then reenter. `http`'s `Server.handle` is
325/// the worked example: it takes the next request, or a clone of the listening
326/// socket, out from under the table of open listeners, drops the guard, and
327/// only then runs the route's handler — which is free to call `http.listen`
328/// again, or to serve on the same handle.
329///
330/// # Reentry is not task transfer
331///
332/// Nothing crosses a task boundary here, so the rules that govern one do not
333/// apply. A callback's arguments are handed from a host to the interpreter of
334/// the task that called it, and its result comes back the same way, both on
335/// one thread, both belonging to one task throughout. [`crate::task::Transfer`]
336/// is not consulted and cannot be: an argument that may not cross a task
337/// boundary — a `Vector`, a resource handle whose schema says
338/// `task_safe: false` — is a perfectly ordinary argument to a callback, and a
339/// callback may answer with one.
340///
341/// Two things nearby do belong to tasks, and it is worth saying which.
342/// [`ResourceSchema::task_safe`] still decides whether the handle an
343/// operation *returns* may later be captured by a `spawn`; that is a question
344/// about the value, asked at the boundary the value eventually crosses, and
345/// reentry is not that boundary. And a callback that is an `async fn` answers
346/// with a task, which the implementation settles before handing the value
347/// back — the host was given a callback rather than a task, so this is what
348/// `await` would have done at the call site the host is standing in for. That
349/// settle is a join, and a value coming back out of a *spawned* task does
350/// cross a boundary and is checked; a settled `async fn` body ran on this
351/// thread and crosses nothing.
352pub trait Reentry {
353 /// Calls `callee` with `args` and answers what it produced.
354 ///
355 /// The call runs to completion before this returns, on this thread. An
356 /// `Err` is what the callback failed with, or what stopped the run while
357 /// it was running — exhausted fuel, an expired deadline, a raised
358 /// cancellation, a depth limit — and a host that receives one has nothing
359 /// useful to add: pass it on, so the reason the run stopped reaches the
360 /// caller as the runtime wrote it.
361 ///
362 /// No lock the host owns may be held across this call. See the trait's
363 /// documentation for why, and for what a host may and may not do with
364 /// the way back it was handed.
365 fn call(&mut self, callee: &Value, args: Vec<Value>) -> Result<Value, RuntimeError>;
366
367 /// Calls `callee` with `args`, stopping it at its next safepoint if
368 /// `stop` is raised while it runs.
369 ///
370 /// This is how a timeout is a timeout rather than a measurement taken
371 /// afterwards: the body observes the flag exactly where it observes its
372 /// own task's cancellation, and stops there.
373 ///
374 /// `stop` bounds this call and everything inside it, including a further
375 /// host call the body makes and any callback that host runs in turn — a
376 /// bound that a nested call escaped would not be a bound. It adds to the
377 /// reasons the body may stop and replaces none of them: the run's own
378 /// cancellation and deadline still apply, and a body stopped by one of
379 /// those is not stopped by this. A caller that needs to tell the two
380 /// apart reads `stop` afterwards, which is what `clock.timeout` does to
381 /// decide whether to report its bound or the error it was given.
382 fn call_until(
383 &mut self,
384 callee: &Value,
385 args: Vec<Value>,
386 stop: &Cancellation,
387 ) -> Result<Value, RuntimeError>;
388
389 /// Whether the work that made this host call has been asked to stop.
390 ///
391 /// This is everything a safepoint in Cove code would answer to: the run's
392 /// own cancellation, the task's, and the flag of any bounded call this
393 /// one is nested inside — a blocking call made from the body of a
394 /// `clock.timeout` is inside that bound as much as any Cove statement is.
395 ///
396 /// A host that loops or waits reads this between rounds and gives up when
397 /// it is raised: `clock.every` ends the timer rather than leaving it
398 /// running with nobody waiting, and `http.Server.handle` stops waiting
399 /// for a connection nobody is going to make. Giving up means answering
400 /// whatever the operation's own "nothing happened" is. The stop belongs
401 /// to the runtime, which reports it at the next safepoint with the limit
402 /// that was configured; a host that raised an error of its own would be
403 /// answering a question it was not asked.
404 fn is_cancelled(&self) -> bool;
405
406 /// How long the run that made this host call has before its deadline
407 /// expires.
408 ///
409 /// `None` means the run has no deadline and nothing here bounds it.
410 /// `Some(Duration::ZERO)` means the deadline has passed, and is as much a
411 /// reason to stop as [`Reentry::is_cancelled`] answering true.
412 ///
413 /// A host that waits reads this for two things. It stops when the answer
414 /// reaches zero, the same way it stops when the run is cancelled. And it
415 /// clamps its own timeouts by it, so an operation willing to wait thirty
416 /// seconds for a peer does not sit there for thirty seconds on behalf of
417 /// a run that had two hundred milliseconds left: the shorter of the two
418 /// allowances is the one that is honest.
419 ///
420 /// The answer is a duration rather than an instant because that is what a
421 /// host does with it — pass it to a socket timeout, or compare it against
422 /// zero — and because a run's deadline is measured from when the run
423 /// started, which is the budget's business and not the host's.
424 fn time_left(&self) -> Option<Duration>;
425
426 /// Which task made this host call: the innermost spawned task's id, or
427 /// [`crate::runtime::ENTRY_TASK`] when the call came from the entry.
428 ///
429 /// Nothing else can answer it. A [`HostRegistry`] is shared by every
430 /// thread of a run and knows nothing about who is calling; the way back is
431 /// the one thing at the boundary that belongs to one task, because it
432 /// borrows that task's interpreter. So the boundary asks it, and writes
433 /// the answer on the call's trace event — which is what lets a trace of a
434 /// run with concurrent tasks be grouped by whose I/O each call was.
435 ///
436 /// A host is not expected to do anything with this. It is asked once per
437 /// call, before the operation is dispatched, and a host that reads it is
438 /// reading an identity rather than a capability: knowing which task is
439 /// calling grants nothing, and two calls from one task are as unrelated
440 /// as any other two.
441 fn task(&self) -> u64;
442}
443
444/// A [`Reentry`] for a caller that has no interpreter to reenter.
445///
446/// [`HostRegistry::call`] uses it, so a host that never runs a callback can
447/// still be driven from a test or a tool with no program behind it. An
448/// operation that does need one is told what is missing rather than being
449/// handed a closure it cannot run.
450pub struct NoReentry;
451
452impl Reentry for NoReentry {
453 fn call(&mut self, callee: &Value, _args: Vec<Value>) -> Result<Value, RuntimeError> {
454 Err(RuntimeError::new(format!(
455 "this host call cannot run {}, because it was not made from a running program",
456 callee.type_name()
457 )))
458 }
459
460 fn call_until(
461 &mut self,
462 callee: &Value,
463 args: Vec<Value>,
464 _stop: &Cancellation,
465 ) -> Result<Value, RuntimeError> {
466 self.call(callee, args)
467 }
468
469 fn is_cancelled(&self) -> bool {
470 false
471 }
472
473 /// A caller with no run behind it has no deadline to run out of, so a
474 /// host driven from a test or a tool waits for as long as its own limits
475 /// allow and no less.
476 fn time_left(&self) -> Option<Duration> {
477 None
478 }
479
480 /// A caller with no program behind it made the call the way an entry
481 /// makes one: outside any spawned task.
482 fn task(&self) -> u64 {
483 crate::runtime::ENTRY_TASK
484 }
485}
486
487/// The identity of one resource a host owns.
488///
489/// ADR 0013: a handle is a name. Every field here is part of the name and
490/// none of them is state — `module` and `type_name` say what kind of thing is
491/// named, `id` says which one, and `task_safe` is the schema's answer copied
492/// onto the handle so the task boundary can read it without a registry.
493///
494/// That is why a handle is [`Arc`]-shared and immutable: copying one copies a
495/// name, two tasks holding it name the same resource, and a trace that
496/// records it records something a replay can reproduce exactly.
497#[derive(Clone, Debug, PartialEq, Eq)]
498pub struct ResourceHandle {
499 /// The host module that issued this handle, such as `database`.
500 pub module: String,
501 /// The resource kind, such as `Connection`.
502 pub type_name: String,
503 /// Which resource of that kind, unique among the ones this host issued.
504 pub id: u64,
505 /// Whether this handle may cross a task boundary, copied from the
506 /// resource's [`ResourceSchema`] when the handle was issued.
507 pub task_safe: bool,
508}
509
510impl ResourceHandle {
511 /// Issues a handle for resource `id` of kind `resource` in `module`.
512 pub fn new(module: &str, resource: &ResourceSchema, id: u64) -> Arc<ResourceHandle> {
513 Arc::new(ResourceHandle {
514 module: module.to_string(),
515 type_name: resource.name.to_string(),
516 id,
517 task_safe: resource.task_safe,
518 })
519 }
520
521 /// The type as Cove source writes it: `database.Connection`.
522 pub fn qualified_type(&self) -> String {
523 format!("{}.{}", self.module, self.type_name)
524 }
525
526 /// Whether two handles name the same resource.
527 pub fn names_same(&self, other: &ResourceHandle) -> bool {
528 self.module == other.module && self.type_name == other.type_name && self.id == other.id
529 }
530}
531
532/// A handle shows as the name it is: the type, and which one.
533impl std::fmt::Display for ResourceHandle {
534 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535 write!(f, "{}#{}", self.qualified_type(), self.id)
536 }
537}
538
539/// The set of capabilities granted at the execution boundary.
540#[derive(Clone, Debug, Default)]
541pub struct Grants {
542 granted: BTreeSet<Capability>,
543}
544
545impl Grants {
546 pub fn new(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
547 Grants {
548 granted: names.into_iter().map(Capability::new).collect(),
549 }
550 }
551
552 pub fn allows(&self, capability: &Capability) -> bool {
553 self.granted.contains(capability)
554 }
555
556 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
557 self.granted.iter()
558 }
559}
560
561/// Where a run's grants came from, and so what a reader has to change to
562/// widen them.
563///
564/// A `cove run` reads `[run.<name>] allow` every time it starts, so editing
565/// that table is the answer. A binary `cove build` produced carries the grant
566/// set it was built with and reads no configuration at all, so the answer
567/// there is to change the table and build again — advice to edit a
568/// `cove.toml` would be advice that does nothing.
569#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
570pub enum GrantSource {
571 /// The `[run.<name>] allow` table this run read when it started.
572 #[default]
573 Config,
574 /// The grant set baked into a built binary.
575 Sealed,
576}
577
578/// Holds every host module available to a run, and the grants that gate them.
579///
580/// `HostRegistry::call` is the single choke point through which Cove code
581/// reaches external authority, so it is also the right place to observe that
582/// authority being exercised: an optional [`Budget`] charges every call
583/// against the run's host-call limit before dispatch, and an optional
584/// [`TraceSink`] records every call, granted or denied, with how long it
585/// took.
586pub struct HostRegistry {
587 modules: Vec<Box<dyn HostApi>>,
588 grants: Grants,
589 grant_source: GrantSource,
590 trace: Arc<dyn TraceSink>,
591 /// The run's budget, shared by every task: ADR 0008 draws a task's fuel
592 /// from the run's budget rather than giving each task one of its own, so
593 /// there is still exactly one authoritative count of what the run spent.
594 budget: Mutex<Option<Budget>>,
595 irreversible_writes: AtomicU64,
596}
597
598/// What one dispatch is addressed to: a module's operation, or a handle's.
599///
600/// The two differ only in how they are named, so this carries the naming and
601/// [`HostRegistry::dispatch`] carries the rules.
602struct Callee {
603 /// The host module, which is what a trace records and what a grant gates.
604 module: String,
605 /// The operation as the trace records it: `query` for a module's, and
606 /// `Connection.query` for a handle's.
607 op: String,
608 /// The handle the call was made on, for a handle's operation.
609 ///
610 /// A trace records it as the call's first argument, so a run holding two
611 /// connections records which one each query went to — and a replay can
612 /// tell them apart.
613 receiver: Option<Value>,
614 /// What has the operation, as a diagnostic names it.
615 owner: String,
616 /// Every operation the owner has, for the help when this one is not among
617 /// them.
618 known: Vec<&'static str>,
619}
620
621impl Callee {
622 /// The call as Cove source writes it: `database.query`, or
623 /// `database.Connection.query`.
624 fn shown(&self) -> String {
625 format!("{}.{}", self.module, self.op)
626 }
627
628 /// The operation's own name, without the resource that answers it.
629 fn bare_op(&self) -> &str {
630 match self.op.rsplit_once('.') {
631 Some((_, op)) => op,
632 None => &self.op,
633 }
634 }
635
636 /// The declared signature, qualified the way this callee is named.
637 fn signature(&self, schema: &OperationSchema) -> String {
638 match self.op.rsplit_once('.') {
639 Some((resource, _)) => format!("{resource}.{}", schema.signature()),
640 None => schema.signature(),
641 }
642 }
643}
644
645impl HostRegistry {
646 pub fn new(grants: Grants) -> Self {
647 HostRegistry {
648 modules: Vec::new(),
649 grants,
650 grant_source: GrantSource::default(),
651 trace: Arc::new(NullSink),
652 budget: Mutex::new(None),
653 irreversible_writes: AtomicU64::new(0),
654 }
655 }
656
657 pub fn register(&mut self, module: Box<dyn HostApi>) {
658 self.modules.push(module);
659 }
660
661 pub fn grants(&self) -> &Grants {
662 &self.grants
663 }
664
665 /// Records where these grants came from, which is what a refused call
666 /// tells the reader to change.
667 pub fn set_grant_source(&mut self, source: GrantSource) {
668 self.grant_source = source;
669 }
670
671 pub fn contains(&self, name: &str) -> bool {
672 self.modules.iter().any(|m| m.module_schema().name == name)
673 }
674
675 /// Installs where this registry's trace events go. Replaces any sink
676 /// installed earlier; the default is [`NullSink`], which discards
677 /// everything.
678 ///
679 /// This is the Host API boundary's own sink, and it carries exactly one
680 /// event: [`TraceEvent::HostCall`]. Everything else a run traces — task
681 /// lifecycle, a heap's summary, and the entry's own
682 /// [`TraceEvent::EntryEnter`], [`TraceEvent::EntryExit`] and
683 /// [`TraceEvent::RunEnded`] — goes through
684 /// [`Runtime::with_trace`](crate::Runtime::with_trace) instead, which
685 /// has a `NullSink` of its own to install into. An embedding that installs
686 /// only this one and only measures host calls will not notice; one that
687 /// expects a full tape from this alone gets an empty one for everything
688 /// but `HostCall`, with no error to say so.
689 pub fn set_trace(&mut self, sink: Arc<dyn TraceSink>) {
690 self.trace = sink;
691 }
692
693 /// Installs the budget every call is charged against. Replaces any
694 /// budget installed earlier; the default is no budget, which imposes no
695 /// host-call limit here (the interpreter's own safepoints still apply
696 /// its other limits).
697 ///
698 /// This arranges a registry before anything runs, and what it installs is
699 /// spent over every run the registry serves. That is exactly right for a
700 /// `cove run`, which is one run, and it is what `[run.<name>]`'s limits
701 /// come through. It is not what an embedding that invokes one compiled
702 /// program many times wants, because there the limits of every request
703 /// would add up over the life of the process:
704 /// [`Vm::invoke_within`](crate::Vm::invoke_within) and its three
705 /// siblings are how a single invocation is bounded instead.
706 pub fn set_budget(&mut self, budget: Budget) {
707 *self
708 .budget
709 .get_mut()
710 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(budget);
711 }
712
713 /// Installs `budget` for the run that is about to start, and starts its
714 /// deadline clock here.
715 ///
716 /// This is what makes a limit bound *one* invocation rather than the whole
717 /// life of a registry, which is issue #152. A `Budget` has to live where
718 /// every thread of a run can reach it — ADR 0008 draws a task's fuel from
719 /// the run's budget, and a task thread reaches this registry through the
720 /// `Arc<Runtime>` it holds — so it stays here; what changes is when it is
721 /// put here. [`HostRegistry::set_budget`] arranges a registry before
722 /// anything runs, and this replaces that arrangement for the duration of
723 /// one run and leaves what the run spent behind it, which is the same
724 /// state `cove run` reads its `--stats` out of.
725 ///
726 /// It takes `&self` where `set_budget` takes `&mut self`, and it is
727 /// `pub(crate)` because of it. ADR 0024 states each stop as a bound that
728 /// holds over a run, and a budget that could be swapped while the run it
729 /// bounds is executing would make every one of those bounds a claim about
730 /// a thing that had changed underneath it. So the only doors to this are
731 /// [`Vm::invoke_within`](crate::Vm::invoke_within) and its three
732 /// siblings, each of which takes `&mut self` on the backend: a backend
733 /// running an invocation is mutably borrowed for its whole duration, so a
734 /// second invocation on it cannot begin, and the shape rather than a rule
735 /// in a comment is what prevents the swap.
736 pub(crate) fn begin_run(&self, mut budget: Budget) {
737 budget.restart();
738 *self
739 .budget
740 .lock()
741 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(budget);
742 }
743
744 /// Runs `f` against the run's budget, if the host installed one.
745 ///
746 /// This is how a caller reads the counters after a run, how a host call
747 /// and a `spawn` are charged, and how a budget is looked at by anything
748 /// that has no [`Meter`] of its own. Every thread of a run reaches the one
749 /// budget through here, so the lock is held for the charge and nothing
750 /// else.
751 ///
752 /// It is *not* how a safepoint charges. That used to be exactly what this
753 /// was for, and issue #182 measured the mutex at 36% of `benches/call`
754 /// against the predecessor's `execute` at 46%, because every call and
755 /// every return is a safepoint. [`HostRegistry::budget_meter`] is what a
756 /// backend takes once per run instead, and [`Meter`] is where the
757 /// argument for it is.
758 pub fn with_budget<R>(&self, f: impl FnOnce(&Budget) -> R) -> Option<R> {
759 let budget = self
760 .budget
761 .lock()
762 .unwrap_or_else(|poisoned| poisoned.into_inner());
763 budget.as_ref().map(f)
764 }
765
766 /// The run's budget in the form a safepoint charges it, or `None` if the
767 /// host installed none.
768 ///
769 /// `None` means no budget at all, which is what an embedder that
770 /// installed none has, and what it has always meant here: no limit.
771 ///
772 /// This takes the lock once, and it is the last time a run touches it on
773 /// a per-instruction path: a [`Meter`] charges the same accounting over
774 /// atomics. A backend takes one where a run begins — see [`Meter`] for why
775 /// that is the only place it may be taken — and both of them do.
776 pub fn budget_meter(&self) -> Option<Meter> {
777 let budget = self
778 .budget
779 .lock()
780 .unwrap_or_else(|poisoned| poisoned.into_inner());
781 budget.as_ref().map(Budget::meter)
782 }
783
784 /// How many calls this run dispatched whose schema declares them
785 /// [`Effect::IrreversibleWrite`].
786 ///
787 /// This is what reads the `effect` an operation declares. Cove makes
788 /// irreversible operations require visible intent, so a run is able to
789 /// say how many of the things it did cannot be taken back; `cove run
790 /// --stats` prints the count. Whether each call actually reached the
791 /// outside world is the host's business rather than the registry's, so a
792 /// call the host answered with `Err` is still counted: the registry knows
793 /// only that a program asked for something irreversible.
794 pub fn irreversible_writes(&self) -> u64 {
795 self.irreversible_writes.load(Ordering::Relaxed)
796 }
797
798 /// The table every registered module declares itself with.
799 ///
800 /// This is the pairing the checker needs. An embedding registers its
801 /// hosts here and hands these same values to `cove_sema::Compiler`, so
802 /// the program is checked against the descriptions this registry is
803 /// about to enforce rather than against a second set written out beside
804 /// them:
805 ///
806 /// ```ignore
807 /// let program = Compiler::new()
808 /// .with_host_schemas(hosts.module_schemas())
809 /// .compile(&package)?;
810 /// ```
811 ///
812 /// A registry that has two modules registered under one name still
813 /// dispatches a call to only one of them: every lookup below that finds
814 /// a module by name — `contains`, `schema_for`, `host_type`, `call`,
815 /// `call_with`, `call_resource`, `module_for_operation` — takes the
816 /// first one registered. So this keeps only the first schema registered
817 /// under each name too, rather than handing the checker a second
818 /// description of a module the runtime will never reach through: the
819 /// list it hands back describes exactly what this registry dispatches
820 /// to, not everything that was ever registered.
821 pub fn module_schemas(&self) -> Vec<ModuleSchema> {
822 let mut seen = BTreeSet::new();
823 self.modules
824 .iter()
825 .map(|module| module.module_schema())
826 .filter(|schema| seen.insert(schema.name))
827 .collect()
828 }
829
830 /// Looks up which host module exposes `op`, for unqualified `use` imports.
831 pub fn module_for_operation(&self, op: &str) -> Option<&'static str> {
832 self.modules.iter().find_map(|m| {
833 let schema = m.module_schema();
834 schema
835 .operations
836 .iter()
837 .any(|entry| entry.name == op)
838 .then_some(schema.name)
839 })
840 }
841
842 /// The schema of one operation, if the module and the operation both
843 /// exist.
844 pub fn schema_for(&self, module: &str, op: &str) -> Option<&'static OperationSchema> {
845 self.modules
846 .iter()
847 .find(|m| m.module_schema().name == module)?
848 .module_schema()
849 .operations
850 .iter()
851 .find(|entry| entry.name == op)
852 }
853
854 /// The type `module.name` declares, if the module declares one.
855 ///
856 /// A [`TypeSchema`] is [`Copy`], so this hands back the entry itself
857 /// rather than a borrow of the registry: the interpreter that asks is
858 /// about to evaluate arguments, which it cannot do while holding one.
859 pub fn host_type(&self, module: &str, name: &str) -> Option<TypeSchema> {
860 self.modules
861 .iter()
862 .find(|m| m.module_schema().name == module)?
863 .module_schema()
864 .types
865 .iter()
866 .find(|declared| declared.name == name)
867 .copied()
868 }
869
870 /// Whether the value `module.op` produces may cross a task boundary, or
871 /// `None` when no such operation exists.
872 ///
873 /// The Language Card puts this decision in the schema rather than in the
874 /// value: "Host resources declare task-safety in their Host API schema."
875 pub fn result_is_task_safe(&self, module: &str, op: &str) -> Option<bool> {
876 Some(self.schema_for(module, op)?.result_is_task_safe)
877 }
878
879 /// Describes one call for a trace, or hands back nothing when no sink
880 /// will read it.
881 ///
882 /// The handle a resource operation was called on comes first, so a run
883 /// holding two connections records which one each query went to. A
884 /// [`RecordedValue`] is a copy, and one of a value no boundary may carry
885 /// is also a rendering of it, so an untraced run makes neither: an event
886 /// nothing keeps has nothing worth describing.
887 fn record_call(&self, callee: &Callee, args: &[Value]) -> Vec<RecordedValue> {
888 if !self.trace.is_recording() {
889 return Vec::new();
890 }
891 callee
892 .receiver
893 .iter()
894 .chain(args)
895 .map(RecordedValue::of)
896 .collect()
897 }
898
899 /// Dispatches a Host API call after checking the grant, the schema, and
900 /// the budget, tracing the outcome either way.
901 ///
902 /// This is the boundary's one choke point, and it takes no interpreter:
903 /// an operation that was handed a Cove callback cannot be reached through
904 /// it. [`HostRegistry::call_with`] is the same dispatch with a way back
905 /// into the program.
906 pub fn call(&self, module: &str, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
907 self.call_with(module, op, args, &mut NoReentry)
908 }
909
910 /// Dispatches a Host API call that may run a Cove callback it was given.
911 pub fn call_with(
912 &self,
913 module: &str,
914 op: &str,
915 args: Vec<Value>,
916 back: &mut dyn Reentry,
917 ) -> Result<Value, RuntimeError> {
918 let task = back.task();
919 let Some(entry) = self
920 .modules
921 .iter()
922 .find(|m| m.module_schema().name == module)
923 else {
924 return Err(RuntimeError::new(format!("unknown host module `{module}`"))
925 .with_outcome(RunOutcome::HostBoundary));
926 };
927 let schema = entry.module_schema();
928 let declared = schema
929 .operations
930 .iter()
931 .find(|entry| entry.name == op)
932 .copied();
933 // An operation declares the capability it needs; the module's own
934 // capability stands in for an operation that does not exist, so a
935 // call into an ungranted module is still reported as ungranted rather
936 // than as a misspelling.
937 let capability = match &declared {
938 Some(op_schema) => Capability::new(op_schema.capability),
939 None => Capability::new(schema.capability),
940 };
941 let callee = Callee {
942 module: module.to_string(),
943 op: op.to_string(),
944 receiver: None,
945 owner: format!("host module `{module}`"),
946 known: schema.operations.iter().map(|e| e.name).collect(),
947 };
948 self.dispatch(task, &callee, declared, capability, args, |args| {
949 entry.call_with(op, args, back)
950 })
951 }
952
953 /// Dispatches an operation on a resource handle, through the same gate
954 /// every other Host API call passes.
955 ///
956 /// A handle is a name, so nothing here trusts it: the module it names has
957 /// to exist, the resource kind has to be one that module declares, and
958 /// the operation has to be one that kind answers. A handle that outlived
959 /// what it named fails inside the host instead, which is where the only
960 /// record of what is still open lives.
961 pub fn call_resource(
962 &self,
963 handle: &ResourceHandle,
964 op: &str,
965 args: Vec<Value>,
966 back: &mut dyn Reentry,
967 ) -> Result<Value, RuntimeError> {
968 let task = back.task();
969 let qualified = handle.qualified_type();
970 let Some(entry) = self
971 .modules
972 .iter()
973 .find(|m| m.module_schema().name == handle.module)
974 else {
975 return Err(
976 RuntimeError::new(format!("unknown host module `{}`", handle.module))
977 .with_help(format!(
978 "`{qualified}` names a resource of a host module this run has none of"
979 ))
980 .with_outcome(RunOutcome::HostBoundary),
981 );
982 };
983 let schema = entry.module_schema();
984 let Some(resource) = schema
985 .resources
986 .iter()
987 .find(|resource| resource.name == handle.type_name)
988 else {
989 return Err(RuntimeError::new(format!(
990 "host module `{}` issues no `{}` handles",
991 handle.module, handle.type_name
992 ))
993 .with_outcome(RunOutcome::HostBoundary));
994 };
995 let declared = resource.operation(op).copied();
996 let capability = match &declared {
997 Some(op_schema) => Capability::new(op_schema.capability),
998 None => Capability::new(schema.capability),
999 };
1000 let callee = Callee {
1001 module: handle.module.clone(),
1002 // A resource operation is recorded under the name that says which
1003 // resource answered it, so a trace of a run holding two kinds of
1004 // handle does not read as one flat list of `query` calls.
1005 op: format!("{}.{op}", handle.type_name),
1006 receiver: Some(Value(Repr::Resource(Arc::new(handle.clone())))),
1007 owner: format!("`{qualified}`"),
1008 known: resource.operations.iter().map(|e| e.name).collect(),
1009 };
1010 self.dispatch(task, &callee, declared, capability, args, |args| {
1011 entry.call_resource(handle, op, args, back)
1012 })
1013 }
1014
1015 /// The grant check, the schema check on the way in, the budget charge,
1016 /// the trace, the dispatch itself, and the schema check on the way out —
1017 /// everything a Host API call passes through, whether it was addressed to
1018 /// a module or to a handle.
1019 ///
1020 /// Both schema checks read one declaration from both sides: the
1021 /// arguments must be what `params` says before the host is reached, and
1022 /// the result must be what `result` says before it is handed on.
1023 fn dispatch(
1024 &self,
1025 task: u64,
1026 callee: &Callee,
1027 declared: Option<OperationSchema>,
1028 capability: Capability,
1029 args: Vec<Value>,
1030 invoke: impl FnOnce(Vec<Value>) -> Result<Value, RuntimeError>,
1031 ) -> Result<Value, RuntimeError> {
1032 let shown = callee.shown();
1033 let refused = |args: Vec<RecordedValue>| TraceEvent::HostCall {
1034 task,
1035 module: callee.module.clone(),
1036 op: callee.op.clone(),
1037 capability: capability.to_string(),
1038 wait: std::time::Duration::ZERO,
1039 granted: false,
1040 args,
1041 outcome: None,
1042 };
1043 if !self.grants.allows(&capability) {
1044 self.trace.record(refused(self.record_call(callee, &args)));
1045 return Err(RuntimeError::new(format!(
1046 "`{shown}` requires the `{capability}` capability, which this run was not granted"
1047 ))
1048 .with_rule("Cove code has no ambient authority; the host grants capabilities at the execution boundary.")
1049 .with_help(match self.grant_source {
1050 GrantSource::Config => {
1051 format!("add `{capability}` to `allow` in the run's `cove.toml` table")
1052 }
1053 // Naming a `cove.toml` here would name a file this binary
1054 // never reads: its grants were fixed when it was built.
1055 GrantSource::Sealed => format!(
1056 "this binary carries the capabilities it was built with; add `{capability}` to `allow` in the run's `cove.toml` table and build it again"
1057 ),
1058 })
1059 .with_outcome(RunOutcome::HostBoundary)
1060 .with_denied_capability(capability.to_string()));
1061 }
1062 let Some(schema) = declared else {
1063 return Err(RuntimeError::new(format!(
1064 "{} has no operation `{}`",
1065 callee.owner,
1066 callee.bare_op()
1067 ))
1068 .with_help(format!(
1069 "{} exposes {}",
1070 callee.owner,
1071 if callee.known.is_empty() {
1072 "no operations".to_string()
1073 } else {
1074 callee
1075 .known
1076 .iter()
1077 .map(|name| format!("`{name}`"))
1078 .collect::<Vec<_>>()
1079 .join(", ")
1080 }
1081 ))
1082 .with_outcome(RunOutcome::HostBoundary));
1083 };
1084 if !schema.accepts(args.len()) {
1085 return Err(RuntimeError::new(format!(
1086 "`{shown}` takes {}, but {} were given",
1087 schema.expected_arity(),
1088 args.len()
1089 ))
1090 .with_help(format!(
1091 "the Host API schema declares `{}.{}`",
1092 callee.module,
1093 callee.signature(&schema)
1094 ))
1095 .with_outcome(RunOutcome::HostBoundary));
1096 }
1097 // Arity and types are the same check on the same declaration, so they
1098 // are made together and in the same place: before the host is
1099 // reached, before the budget is charged, and with nothing on the
1100 // trace, because a call refused here never happened.
1101 //
1102 // `cove check` makes this check too, at the call site, where the
1103 // mistake has a span to point at — but it can only make it for the
1104 // hosts it can see. An embedder's own module is registered at run
1105 // time and named in no table the compiler reads, so this is the only
1106 // thing standing between such a host and an argument its schema does
1107 // not admit.
1108 if let Some(mismatch) = undeclared_argument(&schema, &args) {
1109 return Err(RuntimeError::new(
1110 mismatch
1111 .what
1112 .describe(&shown, Part::Argument(mismatch.position)),
1113 )
1114 .with_rule(A_CALL_KEEPS_THE_SCHEMA)
1115 .with_help(format!(
1116 "the Host API schema declares `{}.{}`",
1117 callee.module,
1118 callee.signature(&schema)
1119 ))
1120 .with_outcome(RunOutcome::HostBoundary));
1121 }
1122
1123 if let Some(Err(error)) = self.with_budget(|budget| {
1124 budget
1125 .charge_host_call()
1126 .map_err(|stopped| budget.to_runtime_error(stopped))
1127 }) {
1128 self.trace.record(refused(self.record_call(callee, &args)));
1129 return Err(error);
1130 }
1131
1132 if schema.effect == Effect::IrreversibleWrite {
1133 self.irreversible_writes.fetch_add(1, Ordering::Relaxed);
1134 }
1135
1136 // A trace has to carry the arguments to be replayable, and the host
1137 // takes ownership of them, so they are recorded before dispatch
1138 // rather than reconstructed afterwards.
1139 let recorded_args = self.record_call(callee, &args);
1140 let started = Instant::now();
1141 let result = invoke(args);
1142 let wait = started.elapsed();
1143 // The schema decides whether the result is written down. An operation
1144 // that is not recordable has its call recorded and its result left
1145 // out: replaying `process.exit` by handing back a value would keep
1146 // running a program that had ended.
1147 if self.trace.is_recording() {
1148 let outcome = if schema.recordable {
1149 match &result {
1150 Ok(value) => HostOutcome::Value(RecordedValue::of(value)),
1151 Err(error) => HostOutcome::Error(error.message.clone()),
1152 }
1153 } else {
1154 HostOutcome::NotRecordable
1155 };
1156 self.trace.record(TraceEvent::HostCall {
1157 task,
1158 module: callee.module.clone(),
1159 op: callee.op.clone(),
1160 capability: capability.to_string(),
1161 wait,
1162 granted: true,
1163 args: recorded_args,
1164 outcome: Some(outcome),
1165 });
1166 }
1167
1168 // The last thing the boundary asks is the one thing it never used to:
1169 // that the host answered the type it declared. ADR 0001 makes the
1170 // schema one description shared by the compiler, runtime, and CLI,
1171 // and a description nothing enforces is a comment. The trace is
1172 // written first, so what the host actually did is on the record
1173 // either way; the value is what stops here.
1174 //
1175 // Only a value is checked. A host that answers `Err` has already
1176 // failed on its own terms, and the `Error` a schema declares is the
1177 // one inside a Cove `Result`, not this one.
1178 if let Ok(value) = &result {
1179 if let Err(mismatch) = schema.result.admits(value) {
1180 return Err(RuntimeError::new(mismatch.describe(&shown, Part::Result))
1181 .with_rule(HOST_KEEPS_ITS_SCHEMA)
1182 .with_help(format!(
1183 "the Host API schema declares `{}.{}`",
1184 callee.module,
1185 callee.signature(&schema)
1186 ))
1187 .with_outcome(RunOutcome::HostBoundary));
1188 }
1189 }
1190 result
1191 }
1192}
1193
1194/// The first argument the operation's declaration does not admit, if there is
1195/// one.
1196///
1197/// A loop of its own rather than one more paragraph inside
1198/// [`HostRegistry::dispatch`], which carries six checks already. A call that
1199/// keeps its declaration pays one walk of its own arguments and allocates
1200/// nothing: the description of a disagreement is built on the way out of a
1201/// failure.
1202fn undeclared_argument(schema: &OperationSchema, args: &[Value]) -> Option<UndeclaredArgument> {
1203 for (index, argument) in args.iter().enumerate() {
1204 // Arity was checked first, so an index past a fixed operation's
1205 // parameters cannot happen; a variadic one answers for the rest.
1206 let declared = schema.param(index)?;
1207 if let Err(what) = declared.admits(argument) {
1208 return Some(UndeclaredArgument {
1209 position: index + 1,
1210 what,
1211 });
1212 }
1213 }
1214 None
1215}
1216
1217/// One argument that is not what its operation declared, and which one it was.
1218struct UndeclaredArgument {
1219 /// Which argument, counted from one as a diagnostic counts.
1220 position: usize,
1221 /// Where it stopped agreeing with the declared type.
1222 what: Mismatch,
1223}
1224
1225/// The rule a host breaks by answering something its own declaration does not
1226/// admit.
1227///
1228/// This is a broken invariant on the host's side of the boundary rather than
1229/// an expected failure, so it stops the run instead of arriving in Cove code
1230/// as a value that program never asked for and cannot handle.
1231const HOST_KEEPS_ITS_SCHEMA: &str = "A host operation answers the type its Host API schema declares; the schema is one description shared by the compiler, runtime, and CLI.";
1232
1233/// The rule a call breaks by passing an argument the operation's own
1234/// declaration does not admit.
1235///
1236/// This is the program's mistake rather than the host's, and `cove check`
1237/// reports it at the call site before a run starts. It is stated again here
1238/// because the checker reads only the schema of the modules the toolchain
1239/// ships, and a host may be anyone's.
1240const A_CALL_KEEPS_THE_SCHEMA: &str = "A Host API call passes the argument types its operation's schema declares; the schema is one description shared by the compiler, runtime, and CLI.";
1241
1242/// The schema of every host module the toolchain ships.
1243///
1244/// `cove trace` and `cove replay` read a trace without a host to ask, and
1245/// both need what the schema says: which calls the trace recorded are
1246/// irreversible, which capability each one needs, and whether a result was
1247/// recordable. `cove-sema` needs the same table with no runtime to depend on
1248/// at all. So the table is [`cove_schema::hosts::SHIPPED`] and every module
1249/// below answers with its entry from it: not a copy that agrees, the same
1250/// bytes.
1251pub fn shipped_schema() -> &'static [ModuleSchema] {
1252 cove_schema::hosts::shipped()
1253}
1254
1255/// What `console` declares about itself.
1256///
1257/// The table is [`cove_schema::hosts::CONSOLE`], so the description the
1258/// compiler checks a call against and the one the boundary dispatches through
1259/// are the same bytes.
1260const CONSOLE_SCHEMA: ModuleSchema = cove_schema::hosts::CONSOLE;
1261
1262/// `console`: line-oriented output on two streams.
1263///
1264/// The two writers are the whole of the difference between the streams. What
1265/// a program produces goes to `out` through `println` and `print`; what it
1266/// says about what it produces goes to `err` through `eprintln` and `eprint`,
1267/// and a host that wants the two apart is a host that hands over two
1268/// different writers. `cove run` gives the process's stdout and stderr, which
1269/// is why a program's records can now be piped somewhere while its complaints
1270/// stay on the terminal.
1271///
1272/// The two streams are one capability. All four operations require
1273/// `console`, so a run that may print may complain, and this type is where
1274/// the difference between the streams is: an embedding that wants a program's
1275/// output captured while its complaints reach the terminal hands over a
1276/// buffer and `std::io::stderr()`, which is a wiring choice rather than an
1277/// authority one. Nothing here consults the grants at all — the boundary has
1278/// already decided by the time an operation arrives.
1279///
1280/// # Migrating from the one-writer form
1281///
1282/// `Console::new` took one writer and now takes two. The second is where
1283/// diagnostics go, and there is deliberately no default: a host that captured
1284/// a program's output before this existed would silently start capturing its
1285/// diagnostics too, which is exactly the mixing the second stream is for
1286/// undoing. `Console::new(w)` therefore fails to compile rather than changing
1287/// meaning. An embedding that genuinely wants one stream says so —
1288/// `Console::new(w.clone(), w)` for a shared writer, `Console::new(w,
1289/// std::io::sink())` to drop diagnostics — and one that wants the process's
1290/// streams writes `Console::new(std::io::stdout(), std::io::stderr())`.
1291pub struct Console<O: Write + Send, E: Write + Send> {
1292 /// Held under a lock so that one task's line is written whole: two tasks
1293 /// printing at once must interleave lines, never halves of a line.
1294 out: Mutex<O>,
1295 /// The diagnostic stream, under a lock of its own: a task writing a
1296 /// warning must not queue behind a task writing a record, since the two
1297 /// streams are usually two different files.
1298 err: Mutex<E>,
1299}
1300
1301impl<O: Write + Send, E: Write + Send> Console<O, E> {
1302 /// A console whose output goes to `out` and whose diagnostics go to
1303 /// `err`.
1304 pub fn new(out: O, err: E) -> Self {
1305 Console {
1306 out: Mutex::new(out),
1307 err: Mutex::new(err),
1308 }
1309 }
1310}
1311
1312impl<O: Write + Send, E: Write + Send> HostApi for Console<O, E> {
1313 fn module_schema(&self) -> ModuleSchema {
1314 CONSOLE_SCHEMA
1315 }
1316
1317 fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
1318 let text = args
1319 .iter()
1320 .map(|v| v.to_string())
1321 .collect::<Vec<_>>()
1322 .join(" ");
1323 // Each arm holds one stream's lock for the whole of one write, and
1324 // neither arm takes the other's: the two streams are independent, so
1325 // a diagnostic never waits on a record.
1326 let result = match op {
1327 "println" => write_line(&self.out, &text, true),
1328 "print" => write_line(&self.out, &text, false),
1329 "eprintln" => write_line(&self.err, &text, true),
1330 "eprint" => write_line(&self.err, &text, false),
1331 _ => unreachable!("checked by HostRegistry::call"),
1332 };
1333 match result {
1334 Ok(()) => Ok(Value::ok(Value(Repr::Unit))),
1335 Err(e) => Ok(Value::err(Value::error(format!("console: {e}")))),
1336 }
1337 }
1338}
1339
1340/// Writes `text` to one of a console's streams, with a newline after it when
1341/// `newline`, and flushes.
1342///
1343/// Both streams write the same way, and a stream that is written differently
1344/// from the other is a stream a program can tell apart by something other
1345/// than where it goes.
1346fn write_line<W: Write + Send>(
1347 stream: &Mutex<W>,
1348 text: &str,
1349 newline: bool,
1350) -> std::io::Result<()> {
1351 let mut stream = stream
1352 .lock()
1353 .unwrap_or_else(|poisoned| poisoned.into_inner());
1354 if newline {
1355 writeln!(stream, "{text}")?;
1356 } else {
1357 write!(stream, "{text}")?;
1358 }
1359 stream.flush()
1360}
1361
1362/// What `env` declares about itself.
1363const ENV_SCHEMA: ModuleSchema = cove_schema::hosts::ENV;
1364
1365/// `env`: read-only access to the environment the host supplies.
1366///
1367/// The map is given to the constructor rather than read from the process, so a
1368/// host decides exactly which variables a run can observe.
1369pub struct Env {
1370 vars: BTreeMap<String, String>,
1371}
1372
1373impl Env {
1374 /// Builds an environment from the variables the host chooses to expose.
1375 pub fn new(vars: BTreeMap<String, String>) -> Self {
1376 Env { vars }
1377 }
1378
1379 /// Snapshots the real process environment. Explicit by design: nothing
1380 /// else in the runtime reads `std::env`.
1381 pub fn from_process() -> Self {
1382 Env {
1383 vars: std::env::vars().collect(),
1384 }
1385 }
1386}
1387
1388impl HostApi for Env {
1389 fn module_schema(&self) -> ModuleSchema {
1390 ENV_SCHEMA
1391 }
1392
1393 fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
1394 match op {
1395 "get" => {
1396 let [Value(Repr::Str(name))] = args.as_slice() else {
1397 unreachable!("checked by HostRegistry::call")
1398 };
1399 Ok(match self.vars.get(&**name) {
1400 Some(value) => Value::some(Value(Repr::Str(value.as_str().into()))),
1401 None => Value::none(),
1402 })
1403 }
1404 _ => unreachable!("checked by HostRegistry::call"),
1405 }
1406 }
1407}
1408
1409/// What `documents` declares about itself.
1410const DOCUMENTS_SCHEMA: ModuleSchema = cove_schema::hosts::DOCUMENTS;
1411
1412/// `documents`: a filtered, read-only view over a fixed set of named text
1413/// documents.
1414///
1415/// Granting `documents` never grants filesystem access. A host names exactly
1416/// which documents exist; there is no way to reach a path this module was not
1417/// built to expose, so a grant of `documents` is narrow authority, never
1418/// ambient access to a directory.
1419pub struct Documents {
1420 source: DocumentsSource,
1421}
1422
1423enum DocumentsSource {
1424 InMemory(BTreeMap<String, String>),
1425 Rooted(PathBuf),
1426}
1427
1428impl Documents {
1429 /// A fake implementation backed by an in-memory map, for tests.
1430 pub fn in_memory(documents: BTreeMap<String, String>) -> Self {
1431 Documents {
1432 source: DocumentsSource::InMemory(documents),
1433 }
1434 }
1435
1436 /// Reads `<root>/<name>.txt` for a document named `name`.
1437 ///
1438 /// `name` must be a single plain path component: empty names, `.`, `..`,
1439 /// and names containing `/`, `\`, or a NUL byte are all rejected before
1440 /// the filesystem is touched. This keeps the capability narrow: a grant
1441 /// of `documents` can only ever reach the fixed set of `.txt` files under
1442 /// `root`, never an arbitrary path via traversal or an absolute path.
1443 pub fn rooted(root: PathBuf) -> Self {
1444 Documents {
1445 source: DocumentsSource::Rooted(root),
1446 }
1447 }
1448
1449 fn read(&self, name: &str) -> Result<String, String> {
1450 let missing = || format!("no document named `{name}`");
1451 match &self.source {
1452 DocumentsSource::InMemory(documents) => {
1453 documents.get(name).cloned().ok_or_else(missing)
1454 }
1455 DocumentsSource::Rooted(root) => {
1456 if !is_plain_document_name(name) {
1457 return Err(missing());
1458 }
1459 std::fs::read_to_string(root.join(format!("{name}.txt"))).map_err(|_| missing())
1460 }
1461 }
1462 }
1463}
1464
1465/// Whether `name` is safe to join onto a root: a single component, never a
1466/// path that could escape it.
1467fn is_plain_document_name(name: &str) -> bool {
1468 !name.is_empty()
1469 && name != "."
1470 && name != ".."
1471 && !name.contains('/')
1472 && !name.contains('\\')
1473 && !name.contains('\0')
1474}
1475
1476impl HostApi for Documents {
1477 fn module_schema(&self) -> ModuleSchema {
1478 DOCUMENTS_SCHEMA
1479 }
1480
1481 fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
1482 match op {
1483 "read" => {
1484 let [Value(Repr::Str(name))] = args.as_slice() else {
1485 unreachable!("checked by HostRegistry::call")
1486 };
1487 Ok(match self.read(name) {
1488 Ok(text) => Value::ok(Value(Repr::Str(text.into()))),
1489 Err(message) => Value::err(Value::error(message)),
1490 })
1491 }
1492 _ => unreachable!("checked by HostRegistry::call"),
1493 }
1494 }
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use super::*;
1500 use crate::schema::HostType;
1501 use std::path::Path;
1502
1503 /// A temporary directory, removed on drop.
1504 struct TempDir(PathBuf);
1505
1506 impl TempDir {
1507 fn new(name: &str) -> Self {
1508 let dir = std::env::temp_dir().join(format!(
1509 "cove-runtime-test-{name}-{}-{}",
1510 std::process::id(),
1511 nanos()
1512 ));
1513 std::fs::create_dir_all(&dir).unwrap();
1514 TempDir(dir)
1515 }
1516
1517 fn path(&self) -> &Path {
1518 &self.0
1519 }
1520 }
1521
1522 impl Drop for TempDir {
1523 fn drop(&mut self) {
1524 let _ = std::fs::remove_dir_all(&self.0);
1525 }
1526 }
1527
1528 fn nanos() -> u128 {
1529 std::time::SystemTime::now()
1530 .duration_since(std::time::UNIX_EPOCH)
1531 .unwrap()
1532 .as_nanos()
1533 }
1534
1535 fn ok_str(value: Value) -> String {
1536 match value.ok_payload() {
1537 Some(payload) => match payload.first() {
1538 Some(Value(Repr::Str(text))) => text.to_string(),
1539 other => panic!("expected `Ok(String)`, found {other:?}"),
1540 },
1541 None => panic!("expected `Ok(String)`, found {value}"),
1542 }
1543 }
1544
1545 fn err_message(value: Value) -> String {
1546 match value.err_payload() {
1547 Some(payload) => payload.first().map(ToString::to_string).unwrap_or_default(),
1548 None => panic!("expected `Err(...)`, found {value}"),
1549 }
1550 }
1551
1552 #[test]
1553 fn in_memory_read_hits_and_misses() {
1554 let documents = Documents::in_memory(BTreeMap::from([(
1555 "input".to_string(),
1556 "hello world".to_string(),
1557 )]));
1558
1559 let hit = documents
1560 .call("read", vec![Value(Repr::Str("input".into()))])
1561 .expect("no runtime error");
1562 assert_eq!(ok_str(hit), "hello world");
1563
1564 let miss = documents
1565 .call("read", vec![Value(Repr::Str("missing".into()))])
1566 .expect("no runtime error");
1567 assert_eq!(err_message(miss), "no document named `missing`");
1568 }
1569
1570 #[test]
1571 fn rooted_reads_a_real_file() {
1572 let dir = TempDir::new("rooted-read");
1573 std::fs::write(dir.path().join("input.txt"), "five little words here").unwrap();
1574 let documents = Documents::rooted(dir.path().to_path_buf());
1575
1576 let read = documents
1577 .call("read", vec![Value(Repr::Str("input".into()))])
1578 .expect("no runtime error");
1579 assert_eq!(ok_str(read), "five little words here");
1580 }
1581
1582 #[test]
1583 fn rooted_rejects_a_missing_document() {
1584 let dir = TempDir::new("rooted-missing");
1585 let documents = Documents::rooted(dir.path().to_path_buf());
1586
1587 let read = documents
1588 .call("read", vec![Value(Repr::Str("absent".into()))])
1589 .expect("no runtime error");
1590 assert_eq!(err_message(read), "no document named `absent`");
1591 }
1592
1593 #[test]
1594 fn rooted_rejects_path_traversal() {
1595 let dir = TempDir::new("rooted-traversal");
1596 let documents = Documents::rooted(dir.path().to_path_buf());
1597
1598 let read = documents
1599 .call("read", vec![Value(Repr::Str("..".into()))])
1600 .expect("no runtime error");
1601 assert_eq!(err_message(read), "no document named `..`");
1602 }
1603
1604 #[test]
1605 fn rooted_rejects_a_nested_path() {
1606 let dir = TempDir::new("rooted-nested");
1607 let documents = Documents::rooted(dir.path().to_path_buf());
1608
1609 let read = documents
1610 .call("read", vec![Value(Repr::Str("a/b".into()))])
1611 .expect("no runtime error");
1612 assert_eq!(err_message(read), "no document named `a/b`");
1613 }
1614
1615 #[test]
1616 fn rooted_rejects_an_empty_name() {
1617 let dir = TempDir::new("rooted-empty");
1618 let documents = Documents::rooted(dir.path().to_path_buf());
1619
1620 let read = documents
1621 .call("read", vec![Value(Repr::Str("".into()))])
1622 .expect("no runtime error");
1623 assert_eq!(err_message(read), "no document named ``");
1624 }
1625
1626 #[test]
1627 fn registry_without_the_documents_grant_rejects_the_call() {
1628 let mut hosts = HostRegistry::new(Grants::new(Vec::<String>::new()));
1629 hosts.register(Box::new(Documents::in_memory(BTreeMap::new())));
1630
1631 let error = hosts
1632 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
1633 .expect_err("the call should be rejected");
1634 assert!(error.message.contains("documents"), "{}", error.message);
1635 }
1636
1637 #[test]
1638 fn registry_with_the_documents_grant_allows_the_call() {
1639 let mut hosts = HostRegistry::new(Grants::new(["documents"]));
1640 hosts.register(Box::new(Documents::in_memory(BTreeMap::from([(
1641 "input".to_string(),
1642 "hello world".to_string(),
1643 )]))));
1644
1645 let value = hosts
1646 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
1647 .expect("the call should be allowed");
1648 assert_eq!(ok_str(value), "hello world");
1649 }
1650
1651 /// Collects every event recorded into it, for assertions.
1652 #[derive(Clone, Default)]
1653 struct RecordingSink(Arc<Mutex<Vec<TraceEvent>>>);
1654
1655 impl RecordingSink {
1656 fn events(&self) -> Vec<TraceEvent> {
1657 self.0
1658 .lock()
1659 .expect("no test panics while recording")
1660 .clone()
1661 }
1662 }
1663
1664 impl TraceSink for RecordingSink {
1665 fn record(&self, event: TraceEvent) {
1666 self.0
1667 .lock()
1668 .expect("no test panics while recording")
1669 .push(event);
1670 }
1671 }
1672
1673 /// What a recorded value shows as, which is the value it carried on the
1674 /// far side of the boundary the event crossed.
1675 fn shown(recorded: &RecordedValue) -> String {
1676 recorded_value(recorded).to_string()
1677 }
1678
1679 /// The value a recorded value carried.
1680 fn recorded_value(recorded: &RecordedValue) -> Value {
1681 match recorded {
1682 RecordedValue::Carried(transfer) => transfer.clone().into_value(),
1683 RecordedValue::Opaque { shown, .. } => Value(Repr::Str(shown.as_str().into())),
1684 }
1685 }
1686
1687 fn registry_with_documents() -> HostRegistry {
1688 let mut hosts = HostRegistry::new(Grants::new(["documents"]));
1689 hosts.register(Box::new(Documents::in_memory(BTreeMap::from([(
1690 "input".to_string(),
1691 "hello world".to_string(),
1692 )]))));
1693 hosts
1694 }
1695
1696 #[test]
1697 fn budget_stops_a_call_before_it_dispatches() {
1698 use crate::budget::{Budget, Limits};
1699
1700 let mut hosts = registry_with_documents();
1701 hosts.set_budget(Budget::new(Limits {
1702 max_host_calls: Some(0),
1703 ..Limits::default()
1704 }));
1705 let sink = RecordingSink::default();
1706 hosts.set_trace(Arc::new(sink.clone()));
1707
1708 let error = hosts
1709 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
1710 .expect_err("the call should be stopped by the budget");
1711 assert!(error.rule.is_some(), "{error:?}");
1712
1713 let events = sink.events();
1714 assert_eq!(events.len(), 1, "{events:?}");
1715 match &events[0] {
1716 TraceEvent::HostCall { granted, .. } => assert!(!granted),
1717 other => panic!("expected a HostCall event, found {other:?}"),
1718 }
1719 assert_eq!(hosts.with_budget(|budget| budget.host_calls()), Some(1));
1720 }
1721
1722 #[test]
1723 fn a_granted_call_produces_one_event_with_a_plausible_wait() {
1724 let mut hosts = registry_with_documents();
1725 let sink = RecordingSink::default();
1726 hosts.set_trace(Arc::new(sink.clone()));
1727
1728 hosts
1729 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
1730 .expect("the call should be allowed");
1731
1732 let events = sink.events();
1733 assert_eq!(events.len(), 1, "{events:?}");
1734 match &events[0] {
1735 TraceEvent::HostCall {
1736 task,
1737 module,
1738 op,
1739 capability,
1740 wait,
1741 granted,
1742 args,
1743 outcome,
1744 } => {
1745 // Nothing ran a program here, so the call belongs to the
1746 // entry's own id, which is what a call made outside a task
1747 // reports.
1748 assert_eq!(*task, crate::runtime::ENTRY_TASK);
1749 assert_eq!(module, "documents");
1750 assert_eq!(op, "read");
1751 assert_eq!(capability, "documents");
1752 assert!(*granted);
1753 assert!(*wait < std::time::Duration::from_secs(1), "{wait:?}");
1754 // A trace that says only that a call happened cannot replay
1755 // it, so the event carries the call's arguments and its
1756 // result too.
1757 assert_eq!(args.len(), 1);
1758 assert_eq!(shown(&args[0]), "input");
1759 match outcome {
1760 Some(HostOutcome::Value(value)) => {
1761 assert_eq!(ok_str(recorded_value(value)), "hello world")
1762 }
1763 other => panic!("expected a recorded value, found {other:?}"),
1764 }
1765 }
1766 other => panic!("expected a HostCall event, found {other:?}"),
1767 }
1768 }
1769
1770 /// The schema's `recordable` flag decides whether a result is written
1771 /// down, and `process.exit` is the one shipped operation it decides
1772 /// against: replaying it by handing back a value would keep running a
1773 /// program that had ended.
1774 #[test]
1775 fn a_result_is_recorded_only_when_the_schema_says_it_may_be() {
1776 let mut hosts = HostRegistry::new(Grants::new(["process"]));
1777 hosts.register(Box::new(crate::process::Process::recorded(
1778 vec!["one".to_string()],
1779 BTreeMap::new(),
1780 crate::process::ProcessLog::new(),
1781 )));
1782 let sink = RecordingSink::default();
1783 hosts.set_trace(Arc::new(sink.clone()));
1784
1785 hosts.call("process", "args", Vec::new()).expect("granted");
1786 hosts
1787 .call("process", "exit", vec![Value(Repr::Int(2))])
1788 .expect("granted");
1789
1790 let events = sink.events();
1791 assert_eq!(events.len(), 2, "{events:?}");
1792 match &events[0] {
1793 // `process.args` is recordable, so its result is recorded.
1794 TraceEvent::HostCall {
1795 outcome: Some(HostOutcome::Value(value)),
1796 ..
1797 } => assert_eq!(shown(value), "[one]"),
1798 other => panic!("expected a recorded value, found {other:?}"),
1799 }
1800 match &events[1] {
1801 TraceEvent::HostCall {
1802 op,
1803 args,
1804 outcome: Some(HostOutcome::NotRecordable),
1805 ..
1806 } => {
1807 assert_eq!(op, "exit");
1808 // The call itself is still recorded, arguments and all: what
1809 // the program asked for is exactly the part worth knowing.
1810 assert_eq!(args.len(), 1);
1811 assert_eq!(shown(&args[0]), "2");
1812 }
1813 other => panic!("expected `not recordable`, found {other:?}"),
1814 }
1815 }
1816
1817 /// A sink that reads nothing is asked for nothing: describing a call's
1818 /// values costs a copy of each, and an untraced run — whose sink is
1819 /// [`NullSink`] — should not pay for a description nobody keeps.
1820 #[test]
1821 fn a_sink_that_is_not_recording_is_given_no_events() {
1822 /// Records every event it is given, while saying it will not read
1823 /// them, exactly as `NullSink` does.
1824 #[derive(Clone, Default)]
1825 struct Deaf(RecordingSink);
1826
1827 impl TraceSink for Deaf {
1828 fn record(&self, event: TraceEvent) {
1829 self.0.record(event);
1830 }
1831
1832 fn is_recording(&self) -> bool {
1833 false
1834 }
1835 }
1836
1837 let mut hosts = registry_with_documents();
1838 let sink = Deaf::default();
1839 hosts.set_trace(Arc::new(sink.clone()));
1840
1841 hosts
1842 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
1843 .expect("the call should be allowed");
1844
1845 assert!(sink.0.events().is_empty(), "{:?}", sink.0.events());
1846 }
1847
1848 /// A call the run was not granted never reaches a host, so there is no
1849 /// result to record — but there is still a request worth recording.
1850 #[test]
1851 fn a_refused_call_records_its_arguments_and_no_result() {
1852 let mut hosts = HostRegistry::new(Grants::new(Vec::<String>::new()));
1853 hosts.register(Box::new(Documents::in_memory(BTreeMap::new())));
1854 let sink = RecordingSink::default();
1855 hosts.set_trace(Arc::new(sink.clone()));
1856
1857 hosts
1858 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
1859 .expect_err("the call should be rejected");
1860
1861 let events = sink.events();
1862 match &events[0] {
1863 TraceEvent::HostCall {
1864 granted,
1865 args,
1866 outcome,
1867 ..
1868 } => {
1869 assert!(!granted);
1870 assert_eq!(args.len(), 1);
1871 assert!(outcome.is_none(), "{outcome:?}");
1872 }
1873 other => panic!("expected a HostCall event, found {other:?}"),
1874 }
1875 }
1876
1877 #[test]
1878 fn an_unknown_operation_lists_the_operations_that_exist() {
1879 let hosts = registry_with_documents();
1880
1881 let error = hosts
1882 .call("documents", "write", Vec::new())
1883 .expect_err("`documents` has no `write`");
1884 assert_eq!(
1885 error.message,
1886 "host module `documents` has no operation `write`"
1887 );
1888 assert_eq!(
1889 error.help.as_deref(),
1890 Some("host module `documents` exposes `read`")
1891 );
1892 }
1893
1894 #[test]
1895 fn too_many_arguments_are_rejected_before_the_host_sees_them() {
1896 let hosts = registry_with_documents();
1897
1898 let error = hosts
1899 .call(
1900 "documents",
1901 "read",
1902 vec![
1903 Value(Repr::Str("input".into())),
1904 Value(Repr::Str("extra".into())),
1905 ],
1906 )
1907 .expect_err("`documents.read` takes one argument");
1908 assert_eq!(
1909 error.message,
1910 "`documents.read` takes 1 argument, but 2 were given"
1911 );
1912 assert_eq!(
1913 error.help.as_deref(),
1914 Some("the Host API schema declares `documents.read(String) -> Result<String, Error>`")
1915 );
1916 }
1917
1918 #[test]
1919 fn too_few_arguments_are_rejected_too() {
1920 let hosts = registry_with_documents();
1921
1922 let error = hosts
1923 .call("documents", "read", Vec::new())
1924 .expect_err("`documents.read` takes one argument");
1925 assert_eq!(
1926 error.message,
1927 "`documents.read` takes 1 argument, but 0 were given"
1928 );
1929 }
1930
1931 /// `console.println("a", "b")` is one line of two parts, so the arity
1932 /// check must not reject it.
1933 #[test]
1934 fn a_variadic_operation_accepts_any_number_of_arguments() {
1935 let mut hosts = HostRegistry::new(Grants::new(["console"]));
1936 hosts.register(Box::new(Console::new(Vec::new(), Vec::new())));
1937
1938 for arity in 0..3 {
1939 hosts
1940 .call(
1941 "console",
1942 "println",
1943 vec![Value(Repr::Str("part".into())); arity],
1944 )
1945 .unwrap_or_else(|e| panic!("arity {arity} should be accepted: {}", e.message));
1946 }
1947 }
1948
1949 /// A writer two owners can share: one of them is inside a [`Console`] and
1950 /// the other is the assertion.
1951 #[derive(Clone, Default)]
1952 struct Shared(Arc<Mutex<Vec<u8>>>);
1953
1954 impl Shared {
1955 fn written(&self) -> String {
1956 String::from_utf8(
1957 self.0
1958 .lock()
1959 .expect("nothing panicked while writing")
1960 .clone(),
1961 )
1962 .expect("a console writes what it was given, which was text")
1963 }
1964 }
1965
1966 impl Write for Shared {
1967 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1968 self.0
1969 .lock()
1970 .expect("nothing panicked while writing")
1971 .extend_from_slice(buf);
1972 Ok(buf.len())
1973 }
1974
1975 fn flush(&mut self) -> std::io::Result<()> {
1976 Ok(())
1977 }
1978 }
1979
1980 /// A registry holding one [`Console`] over the two writers given, granted
1981 /// the one capability both of its streams answer to.
1982 fn registry_with_console(out: Shared, err: Shared) -> HostRegistry {
1983 let mut hosts = HostRegistry::new(Grants::new(["console"]));
1984 hosts.register(Box::new(Console::new(out, err)));
1985 hosts
1986 }
1987
1988 /// The whole of what the second stream is for: what a program says about
1989 /// its output is not in its output.
1990 #[test]
1991 fn what_the_diagnostic_stream_is_given_stays_out_of_the_output_stream() {
1992 let out = Shared::default();
1993 let err = Shared::default();
1994 let hosts = registry_with_console(out.clone(), err.clone());
1995
1996 for (op, text) in [
1997 ("println", "id,name"),
1998 ("eprintln", "line 2 is malformed, skipping"),
1999 ("println", "1,ada"),
2000 ] {
2001 hosts
2002 .call("console", op, vec![Value(Repr::Str(text.into()))])
2003 .unwrap_or_else(|e| panic!("`console.{op}` should be allowed: {}", e.message));
2004 }
2005
2006 assert_eq!(out.written(), "id,name\n1,ada\n");
2007 assert_eq!(err.written(), "line 2 is malformed, skipping\n");
2008 }
2009
2010 /// `print` and `eprint` differ from `println` and `eprintln` in the same
2011 /// way on both streams: the newline, and nothing else.
2012 #[test]
2013 fn the_unterminated_form_writes_the_same_way_on_either_stream() {
2014 let out = Shared::default();
2015 let err = Shared::default();
2016 let hosts = registry_with_console(out.clone(), err.clone());
2017
2018 for op in ["print", "eprint"] {
2019 hosts
2020 .call(
2021 "console",
2022 op,
2023 vec![
2024 Value(Repr::Str("two".into())),
2025 Value(Repr::Str("parts".into())),
2026 ],
2027 )
2028 .unwrap_or_else(|e| panic!("`console.{op}` should be allowed: {}", e.message));
2029 }
2030
2031 assert_eq!(out.written(), "two parts");
2032 assert_eq!(err.written(), "two parts");
2033 }
2034
2035 /// One capability covers both streams. A run granted `console` may write
2036 /// a record and may complain about it, which is why a grant written
2037 /// before `eprintln` existed did not have to be read again when it
2038 /// arrived; a run granted nothing may do neither, and is refused under
2039 /// the one name either way.
2040 #[test]
2041 fn one_grant_covers_both_streams() {
2042 let out = Shared::default();
2043 let err = Shared::default();
2044 let granted = registry_with_console(out.clone(), err.clone());
2045
2046 granted
2047 .call(
2048 "console",
2049 "println",
2050 vec![Value(Repr::Str("record".into()))],
2051 )
2052 .expect("`console` grants the output stream");
2053 granted
2054 .call(
2055 "console",
2056 "eprintln",
2057 vec![Value(Repr::Str("warning".into()))],
2058 )
2059 .expect("`console` grants the diagnostic stream too");
2060 assert_eq!(out.written(), "record\n");
2061 assert_eq!(err.written(), "warning\n");
2062
2063 let mut ungranted = HostRegistry::new(Grants::new(Vec::<String>::new()));
2064 ungranted.register(Box::new(Console::new(Shared::default(), Shared::default())));
2065 for op in ["println", "print", "eprintln", "eprint"] {
2066 let refused = ungranted
2067 .call("console", op, vec![Value(Repr::Str("anything".into()))])
2068 .expect_err("a run granted nothing reaches neither stream");
2069 assert_eq!(
2070 refused.denied_capability.as_deref(),
2071 Some("console"),
2072 "`console.{op}`: {}",
2073 refused.message
2074 );
2075 }
2076 }
2077
2078 #[test]
2079 fn task_safety_of_a_host_result_comes_from_the_schema() {
2080 let hosts = registry_with_documents();
2081
2082 assert_eq!(hosts.result_is_task_safe("documents", "read"), Some(true));
2083 assert_eq!(hosts.result_is_task_safe("documents", "write"), None);
2084 assert_eq!(hosts.result_is_task_safe("network", "read"), None);
2085 }
2086
2087 /// A host used only to give two modules the same name, so
2088 /// [`module_schemas_describes_the_module_dispatch_will_actually_reach`]
2089 /// can tell which one a lookup actually reached: its answer to `ping` is
2090 /// baked in rather than computed, so the test reads it off the return
2091 /// value instead of having to ask the host anything more.
2092 struct DuplicateNamed {
2093 schema: ModuleSchema,
2094 answer: &'static str,
2095 }
2096
2097 impl HostApi for DuplicateNamed {
2098 fn module_schema(&self) -> ModuleSchema {
2099 self.schema
2100 }
2101
2102 fn call(&self, _op: &str, _args: Vec<Value>) -> Result<Value, RuntimeError> {
2103 Ok(Value(Repr::Str(self.answer.into())))
2104 }
2105 }
2106
2107 static DUPLICATE_FIRST_OPERATIONS: &[OperationSchema] = &[OperationSchema {
2108 name: "ping",
2109 params: &[],
2110 variadic: false,
2111 result: HostType::String,
2112 capability: "duplicate-first",
2113 effect: Effect::Read,
2114 cancellable: false,
2115 recordable: true,
2116 result_is_task_safe: true,
2117 }];
2118
2119 static DUPLICATE_FIRST: ModuleSchema = ModuleSchema {
2120 name: "duplicate",
2121 capability: "duplicate-first",
2122 operations: DUPLICATE_FIRST_OPERATIONS,
2123 types: &[],
2124 resources: &[],
2125 };
2126
2127 static DUPLICATE_SECOND_OPERATIONS: &[OperationSchema] = &[OperationSchema {
2128 name: "ping",
2129 params: &[],
2130 variadic: false,
2131 result: HostType::String,
2132 capability: "duplicate-second",
2133 effect: Effect::Read,
2134 cancellable: false,
2135 recordable: true,
2136 result_is_task_safe: true,
2137 }];
2138
2139 static DUPLICATE_SECOND: ModuleSchema = ModuleSchema {
2140 name: "duplicate",
2141 capability: "duplicate-second",
2142 operations: DUPLICATE_SECOND_OPERATIONS,
2143 types: &[],
2144 resources: &[],
2145 };
2146
2147 /// [`HostRegistry::module_schemas`] hands the checker one schema per
2148 /// name, and every dispatch method resolves a duplicate name by taking
2149 /// the first module registered under it — so the schema this hands out
2150 /// for a duplicated name had better be the first module's, the same one
2151 /// [`HostRegistry::call`] reaches, or the checker would be checking a
2152 /// call against a module the runtime never dispatches to.
2153 #[test]
2154 fn module_schemas_describes_the_module_dispatch_will_actually_reach() {
2155 let mut hosts = HostRegistry::new(Grants::new(["duplicate-first", "duplicate-second"]));
2156 hosts.register(Box::new(DuplicateNamed {
2157 schema: DUPLICATE_FIRST,
2158 answer: "first",
2159 }));
2160 hosts.register(Box::new(DuplicateNamed {
2161 schema: DUPLICATE_SECOND,
2162 answer: "second",
2163 }));
2164
2165 assert_eq!(hosts.module_schemas(), vec![DUPLICATE_FIRST]);
2166
2167 match hosts
2168 .call("duplicate", "ping", Vec::new())
2169 .expect("the call should be allowed")
2170 {
2171 Value(Repr::Str(text)) => assert_eq!(&*text, "first"),
2172 other => panic!("expected a Str, found {other:?}"),
2173 }
2174 }
2175
2176 /// Every module a run registers declares itself out of
2177 /// [`cove_schema::hosts::SHIPPED`] rather than out of a table of its own.
2178 ///
2179 /// This is what makes "one description shared by the compiler, runtime,
2180 /// and CLI" a fact rather than an intention. `cove-sema` reads that table
2181 /// and never sees a host, so a module answering with anything else would
2182 /// be a second description with nothing holding it against the first —
2183 /// which is exactly how `http` once came to be missing from the
2184 /// compiler's list of host modules with no diagnostic anywhere. A test
2185 /// used to compare the two lists; there is one list now, and this is what
2186 /// keeps it one.
2187 ///
2188 /// A module agreeing with the table in isolation is not the same claim
2189 /// as a registry holding every shipped module agreeing with it too, so
2190 /// this also registers all eight and checks the registry's own dispatch
2191 /// tables — `contains`, `schema_for`, `host_type` — against the same
2192 /// table, the way [`HostRegistry::call`] and [`HostRegistry::call_with`]
2193 /// read them.
2194 #[test]
2195 fn every_module_a_run_registers_declares_itself_out_of_the_shared_schema() {
2196 let modules: Vec<Box<dyn HostApi>> = vec![
2197 Box::new(Console::new(std::io::sink(), std::io::sink())),
2198 Box::new(Env::new(BTreeMap::new())),
2199 Box::new(Documents::in_memory(BTreeMap::new())),
2200 Box::new(crate::clock::Clock::real()),
2201 Box::new(crate::files::Files::in_memory(BTreeMap::new())),
2202 Box::new(crate::process::Process::real(Vec::new(), Vec::new())),
2203 Box::new(crate::database::Database::denied()),
2204 Box::new(crate::http::Http::real()),
2205 ];
2206
2207 assert_eq!(
2208 modules.len(),
2209 shipped_schema().len(),
2210 "a module the schema describes is one a run registers, and the reverse"
2211 );
2212 for (module, declared) in modules.iter().zip(shipped_schema()) {
2213 assert_eq!(&module.module_schema(), declared, "`{}`", declared.name);
2214 }
2215
2216 let mut hosts = HostRegistry::new(Grants::new(Vec::<String>::new()));
2217 for module in modules {
2218 hosts.register(module);
2219 }
2220 for declared in shipped_schema() {
2221 assert!(hosts.contains(declared.name), "`{}`", declared.name);
2222 for op in declared.operations {
2223 assert_eq!(
2224 hosts.schema_for(declared.name, op.name),
2225 Some(op),
2226 "`{}.{}`",
2227 declared.name,
2228 op.name
2229 );
2230 }
2231 for ty in declared.types {
2232 assert_eq!(
2233 hosts.host_type(declared.name, ty.name),
2234 Some(*ty),
2235 "`{}.{}`",
2236 declared.name,
2237 ty.name
2238 );
2239 }
2240 }
2241 }
2242
2243 /// The counter behind `cove run --stats`, and the one thing that reads
2244 /// an operation's declared `effect`.
2245 #[test]
2246 fn only_the_calls_the_schema_calls_irreversible_are_counted() {
2247 let mut hosts = HostRegistry::new(Grants::new(["console", "files"]));
2248 hosts.register(Box::new(Console::new(Vec::new(), Vec::new())));
2249 hosts.register(Box::new(crate::files::Files::in_memory(BTreeMap::new())));
2250 assert_eq!(hosts.irreversible_writes(), 0);
2251
2252 hosts
2253 .call("files", "exists", vec![Value(Repr::Str("a.txt".into()))])
2254 .expect("the call should be allowed");
2255 assert_eq!(hosts.irreversible_writes(), 0);
2256
2257 hosts
2258 .call(
2259 "files",
2260 "write",
2261 vec![
2262 Value(Repr::Str("a.txt".into())),
2263 Value(Repr::Str("x".into())),
2264 ],
2265 )
2266 .expect("the call should be allowed");
2267 hosts
2268 .call("console", "println", vec![Value(Repr::Str("a".into()))])
2269 .expect("the call should be allowed");
2270 assert_eq!(hosts.irreversible_writes(), 2);
2271 }
2272
2273 /// A call the run was not granted never reaches the host, so it never
2274 /// changed anything to count.
2275 #[test]
2276 fn an_ungranted_irreversible_call_is_not_counted() {
2277 let mut hosts = HostRegistry::new(Grants::new(Vec::<String>::new()));
2278 hosts.register(Box::new(crate::files::Files::in_memory(BTreeMap::new())));
2279
2280 hosts
2281 .call(
2282 "files",
2283 "write",
2284 vec![
2285 Value(Repr::Str("a.txt".into())),
2286 Value(Repr::Str("x".into())),
2287 ],
2288 )
2289 .expect_err("the call should be rejected");
2290 assert_eq!(hosts.irreversible_writes(), 0);
2291 }
2292
2293 #[test]
2294 fn a_denied_call_produces_an_event_with_granted_false() {
2295 let mut hosts = HostRegistry::new(Grants::new(Vec::<String>::new()));
2296 hosts.register(Box::new(Documents::in_memory(BTreeMap::new())));
2297 let sink = RecordingSink::default();
2298 hosts.set_trace(Arc::new(sink.clone()));
2299
2300 hosts
2301 .call("documents", "read", vec![Value(Repr::Str("input".into()))])
2302 .expect_err("the call should be rejected for the missing grant");
2303
2304 let events = sink.events();
2305 assert_eq!(events.len(), 1, "{events:?}");
2306 match &events[0] {
2307 TraceEvent::HostCall {
2308 capability,
2309 granted,
2310 ..
2311 } => {
2312 assert_eq!(capability, "documents");
2313 assert!(!granted);
2314 }
2315 other => panic!("expected a HostCall event, found {other:?}"),
2316 }
2317 }
2318
2319 // ----------------------------------------------- a host and its schema
2320 //
2321 // ADR 0001 makes the schema shared property: "A machine-readable Host API
2322 // schema is shared by the compiler, runtime, and CLI. Each operation
2323 // describes its argument, result, and error types". Every shipped host is
2324 // written to obey its own, which is what makes them useless for asking
2325 // what happens when one does not. These tests register a host that can be
2326 // told to disagree with its declaration, and pin which disagreements the
2327 // boundary catches.
2328
2329 /// The operation [`Wayward`] declares.
2330 static WAYWARD_SCHEMA: &[OperationSchema] = &[
2331 OperationSchema {
2332 name: "read",
2333 params: &[HostType::String],
2334 variadic: false,
2335 result: HostType::Result(&HostType::String, &HostType::Error),
2336 capability: "wayward",
2337 effect: Effect::Read,
2338 cancellable: false,
2339 recordable: true,
2340 result_is_task_safe: true,
2341 },
2342 OperationSchema {
2343 name: "open",
2344 params: &[],
2345 variadic: false,
2346 result: HostType::Named("wayward.Handle"),
2347 capability: "wayward",
2348 effect: Effect::Read,
2349 cancellable: false,
2350 recordable: true,
2351 result_is_task_safe: true,
2352 },
2353 // A result with something inside it, so a disagreement can be nested
2354 // rather than sitting at the top of the value.
2355 OperationSchema {
2356 name: "list",
2357 params: &[],
2358 variadic: false,
2359 result: HostType::Result(&HostType::Array(&HostType::String), &HostType::Error),
2360 capability: "wayward",
2361 effect: Effect::Read,
2362 cancellable: false,
2363 recordable: true,
2364 result_is_task_safe: true,
2365 },
2366 // An operation that declares nothing about what it produces, which is
2367 // what `clock.timeout` and `clock.every` declare about the work they
2368 // are given.
2369 OperationSchema {
2370 name: "anything",
2371 params: &[],
2372 variadic: false,
2373 result: HostType::Any,
2374 capability: "wayward",
2375 effect: Effect::Read,
2376 cancellable: false,
2377 recordable: true,
2378 result_is_task_safe: true,
2379 },
2380 // An argument with something inside it, so a disagreement can be
2381 // nested there too.
2382 OperationSchema {
2383 name: "send",
2384 params: &[HostType::Array(&HostType::String)],
2385 variadic: false,
2386 result: HostType::Result(&HostType::String, &HostType::Error),
2387 capability: "wayward",
2388 effect: Effect::Read,
2389 cancellable: false,
2390 recordable: true,
2391 result_is_task_safe: true,
2392 },
2393 // One declared parameter answering for as many arguments as the call
2394 // makes, which is what `console.println` declares.
2395 OperationSchema {
2396 name: "say",
2397 params: &[HostType::String],
2398 variadic: true,
2399 result: HostType::Result(&HostType::String, &HostType::Error),
2400 capability: "wayward",
2401 effect: Effect::Read,
2402 cancellable: false,
2403 recordable: true,
2404 result_is_task_safe: true,
2405 },
2406 // An operation that declares nothing about what it is *given*, which
2407 // is what `clock.timeout` declares of the work it bounds.
2408 OperationSchema {
2409 name: "bound",
2410 params: &[HostType::Any],
2411 variadic: false,
2412 result: HostType::Result(&HostType::String, &HostType::Error),
2413 capability: "wayward",
2414 effect: Effect::Read,
2415 cancellable: false,
2416 recordable: true,
2417 result_is_task_safe: true,
2418 },
2419 ];
2420
2421 /// The one kind of resource [`Wayward`] declares it can open.
2422 static WAYWARD_RESOURCES: &[ResourceSchema] = &[ResourceSchema {
2423 name: "Handle",
2424 task_safe: true,
2425 operations: &[OperationSchema {
2426 name: "close",
2427 params: &[],
2428 variadic: false,
2429 result: HostType::Result(&HostType::Unit, &HostType::Error),
2430 capability: "wayward",
2431 effect: Effect::ReversibleWrite,
2432 cancellable: false,
2433 recordable: true,
2434 result_is_task_safe: true,
2435 }],
2436 }];
2437
2438 /// A kind of resource [`Wayward`] does *not* declare, which it mints a
2439 /// handle for anyway.
2440 static UNDECLARED_RESOURCE: ResourceSchema = ResourceSchema {
2441 name: "Ghost",
2442 task_safe: true,
2443 operations: &[],
2444 };
2445
2446 /// What [`Wayward`] answers with.
2447 ///
2448 /// A host holds data and builds its answer at the call, because a
2449 /// [`Value`] is reference-counted and belongs to the thread that built it
2450 /// while a host is shared by every task of a run. So this says which
2451 /// answer to build rather than holding one.
2452 #[derive(Clone, Copy)]
2453 enum Answer {
2454 /// What each operation declares: `Ok("what was declared")`, an
2455 /// `Ok` of an array of strings, and a handle of the one resource
2456 /// kind this module says it can open.
2457 Declared,
2458 /// The same operations, each answering something its own declaration
2459 /// does not admit: an `Int` where a `Result` was declared, an array
2460 /// with an `Int` among its strings, and a handle naming a resource
2461 /// kind this module never declared.
2462 Undeclared,
2463 }
2464
2465 /// A host whose behaviour can be made to disagree with its schema.
2466 ///
2467 /// It counts how often it was reached, so a test can tell a call the
2468 /// registry refused from one it dispatched: "before the host sees them"
2469 /// is a claim about where the check happens, not only about what it says.
2470 struct Wayward {
2471 answer: Answer,
2472 calls: Arc<AtomicU64>,
2473 }
2474
2475 impl Wayward {
2476 fn answering(answer: Answer) -> (Wayward, Arc<AtomicU64>) {
2477 let calls = Arc::new(AtomicU64::new(0));
2478 (
2479 Wayward {
2480 answer,
2481 calls: Arc::clone(&calls),
2482 },
2483 calls,
2484 )
2485 }
2486 }
2487
2488 /// The module this test host declares itself with, which is the one
2489 /// the registry holds it to.
2490 const WAYWARD: ModuleSchema = ModuleSchema {
2491 name: "wayward",
2492 capability: "wayward",
2493 operations: WAYWARD_SCHEMA,
2494 types: &[],
2495 resources: WAYWARD_RESOURCES,
2496 };
2497
2498 impl HostApi for Wayward {
2499 fn module_schema(&self) -> ModuleSchema {
2500 WAYWARD
2501 }
2502
2503 fn call(&self, op: &str, _args: Vec<Value>) -> Result<Value, RuntimeError> {
2504 self.calls.fetch_add(1, Ordering::Relaxed);
2505 let declared = matches!(self.answer, Answer::Declared);
2506 Ok(match op {
2507 // A handle naming a resource this module's schema does not
2508 // declare: the value is well formed and the name is a lie.
2509 "open" => Value(Repr::Resource(ResourceHandle::new(
2510 "wayward",
2511 if declared {
2512 &WAYWARD_RESOURCES[0]
2513 } else {
2514 &UNDECLARED_RESOURCE
2515 },
2516 1,
2517 ))),
2518 "list" => Value::ok(Value(Repr::Array(if declared {
2519 vec![Value(Repr::Str("what was declared".into()))].into()
2520 } else {
2521 vec![
2522 Value(Repr::Str("what was declared".into())),
2523 Value(Repr::Int(3)),
2524 ]
2525 .into()
2526 }))),
2527 // Declared as `Any`, so this one cannot disagree with itself.
2528 "anything" => Value(Repr::Int(3)),
2529 _ if declared => Value::ok(Value(Repr::Str("what was declared".into()))),
2530 _ => Value(Repr::Int(3)),
2531 })
2532 }
2533
2534 fn call_resource(
2535 &self,
2536 _handle: &ResourceHandle,
2537 _op: &str,
2538 _args: Vec<Value>,
2539 _back: &mut dyn Reentry,
2540 ) -> Result<Value, RuntimeError> {
2541 self.calls.fetch_add(1, Ordering::Relaxed);
2542 Ok(match self.answer {
2543 Answer::Declared => Value::ok(Value(Repr::Unit)),
2544 Answer::Undeclared => Value::ok(Value(Repr::Str("not the declared `Unit`".into()))),
2545 })
2546 }
2547 }
2548
2549 /// A registry holding one [`Wayward`], with its capability granted and
2550 /// its trace recorded.
2551 fn registry_with_wayward(answer: Answer) -> (HostRegistry, Arc<AtomicU64>, RecordingSink) {
2552 let (host, calls) = Wayward::answering(answer);
2553 let mut hosts = HostRegistry::new(Grants::new(["wayward"]));
2554 hosts.register(Box::new(host));
2555 let sink = RecordingSink::default();
2556 hosts.set_trace(Arc::new(sink.clone()));
2557 (hosts, calls, sink)
2558 }
2559
2560 /// The success half: a host that answers what it declared is dispatched,
2561 /// its value reaches the caller unchanged, and the trace records it.
2562 #[test]
2563 fn a_host_that_answers_what_its_schema_declares_is_dispatched_and_recorded() {
2564 let (hosts, calls, sink) = registry_with_wayward(Answer::Declared);
2565
2566 let value = hosts
2567 .call("wayward", "read", vec![Value(Repr::Str("input".into()))])
2568 .expect("a conforming call is dispatched");
2569
2570 assert_eq!(ok_str(value), "what was declared");
2571 assert_eq!(calls.load(Ordering::Relaxed), 1);
2572 let events = sink.events();
2573 assert_eq!(events.len(), 1, "{events:?}");
2574 match &events[0] {
2575 TraceEvent::HostCall {
2576 op,
2577 granted,
2578 outcome,
2579 ..
2580 } => {
2581 assert_eq!(op, "read");
2582 assert!(granted);
2583 match outcome {
2584 Some(HostOutcome::Value(recorded)) => {
2585 assert_eq!(shown(recorded), "Ok(what was declared)")
2586 }
2587 other => panic!("expected a recorded value, found {other:?}"),
2588 }
2589 }
2590 other => panic!("expected a HostCall event, found {other:?}"),
2591 }
2592 }
2593
2594 /// A handle is a name, and the boundary trusts no name it is given: a
2595 /// `wayward.Ghost` names a resource kind the module's own `resources()`
2596 /// does not declare, so an operation on it is refused without the host
2597 /// being asked.
2598 #[test]
2599 fn a_handle_naming_a_resource_the_module_never_declared_is_refused() {
2600 let (hosts, calls, _) = registry_with_wayward(Answer::Declared);
2601 let ghost = ResourceHandle::new("wayward", &UNDECLARED_RESOURCE, 1);
2602
2603 let error = hosts
2604 .call_resource(&ghost, "close", Vec::new(), &mut NoReentry)
2605 .expect_err("a handle the schema does not declare is refused");
2606
2607 assert_eq!(
2608 error.message,
2609 "host module `wayward` issues no `Ghost` handles"
2610 );
2611 assert_eq!(
2612 calls.load(Ordering::Relaxed),
2613 0,
2614 "the host was not asked to act on a handle its schema disowns"
2615 );
2616 }
2617
2618 /// The declared kind of handle passes, and the operations it declares can
2619 /// then be called on it: the check refuses a name, not handles.
2620 #[test]
2621 fn a_handle_of_the_kind_the_operation_declared_is_admitted() {
2622 let (hosts, _, _) = registry_with_wayward(Answer::Declared);
2623
2624 let opened = hosts
2625 .call("wayward", "open", Vec::new())
2626 .expect("a handle of the declared kind is admitted");
2627 let Value(Repr::Resource(handle)) = opened else {
2628 panic!("expected a resource handle, found {opened}");
2629 };
2630 assert_eq!(handle.qualified_type(), "wayward.Handle");
2631
2632 let closed = hosts
2633 .call_resource(&handle, "close", Vec::new(), &mut NoReentry)
2634 .expect("the handle answers the operation its kind declares");
2635 assert!(matches!(closed, Value(Repr::Enum(_))), "{closed}");
2636 }
2637
2638 /// An operation the schema does not declare is refused at the boundary,
2639 /// and the diagnostic lists what the module does declare rather than
2640 /// leaving the caller to guess.
2641 #[test]
2642 fn an_operation_the_schema_does_not_declare_is_refused_before_the_host_sees_it() {
2643 let (hosts, calls, _) = registry_with_wayward(Answer::Declared);
2644
2645 let error = hosts
2646 .call("wayward", "write", vec![Value(Repr::Str("input".into()))])
2647 .expect_err("an undeclared operation is refused");
2648
2649 assert_eq!(
2650 error.message,
2651 "host module `wayward` has no operation `write`"
2652 );
2653 let help = error.help.expect("the diagnostic lists what does exist");
2654 assert!(help.contains("`read`"), "{help}");
2655 assert!(help.contains("`open`"), "{help}");
2656 assert_eq!(calls.load(Ordering::Relaxed), 0);
2657 }
2658
2659 /// Arity is the first part of an operation's declared shape the boundary
2660 /// enforces, and it enforces it before the host is reached.
2661 #[test]
2662 fn arguments_the_schema_does_not_accept_are_refused_before_the_host_sees_them() {
2663 let (hosts, calls, _) = registry_with_wayward(Answer::Declared);
2664
2665 let error = hosts
2666 .call("wayward", "read", Vec::new())
2667 .expect_err("a call with too few arguments is refused");
2668
2669 assert_eq!(
2670 error.message,
2671 "`wayward.read` takes 1 argument, but 0 were given"
2672 );
2673 let help = error.help.expect("the diagnostic quotes the schema");
2674 assert!(
2675 help.contains("wayward.read(String) -> Result<String, Error>"),
2676 "{help}"
2677 );
2678 assert_eq!(calls.load(Ordering::Relaxed), 0);
2679 }
2680
2681 /// An argument the operation's own declaration does not admit is refused
2682 /// before the host sees it, and nothing of the call is recorded: a call
2683 /// stopped here never happened.
2684 ///
2685 /// This is the same table as the result check, read from the other side.
2686 /// `cove check` reports this mistake at the call site, where it has a
2687 /// span; the boundary reports it for the hosts the checker cannot see —
2688 /// which is every host an embedder writes.
2689 #[test]
2690 fn an_argument_the_schema_does_not_admit_is_refused_before_the_host_sees_it() {
2691 let (hosts, calls, sink) = registry_with_wayward(Answer::Declared);
2692
2693 let error = hosts
2694 .call("wayward", "read", vec![Value(Repr::Int(3))])
2695 .expect_err("an `Int` where a `String` was declared is refused");
2696
2697 assert_eq!(
2698 error.message,
2699 "`wayward.read` was given `Int` as argument 1, but its schema declares `String` there"
2700 );
2701 let help = error.help.expect("the diagnostic quotes the schema");
2702 assert!(
2703 help.contains("wayward.read(String) -> Result<String, Error>"),
2704 "{help}"
2705 );
2706 assert_eq!(calls.load(Ordering::Relaxed), 0);
2707 assert!(
2708 sink.events().is_empty(),
2709 "a call the boundary refused never reached the host to be recorded"
2710 );
2711 }
2712
2713 /// An argument is followed as far down as a result is, so an `Int` among
2714 /// the strings of a declared `Array<String>` is caught and the diagnostic
2715 /// says which element.
2716 #[test]
2717 fn a_violation_inside_an_argument_says_where_it_is() {
2718 let (hosts, calls, _) = registry_with_wayward(Answer::Declared);
2719
2720 let error = hosts
2721 .call(
2722 "wayward",
2723 "send",
2724 vec![Value(Repr::Array(
2725 vec![Value(Repr::Str("one".into())), Value(Repr::Int(2))].into(),
2726 ))],
2727 )
2728 .expect_err("an `Int` among the declared strings is refused");
2729
2730 assert_eq!(
2731 error.message,
2732 "`wayward.send` was given `Int` at `[1]` of argument 1, but its schema declares `String` there"
2733 );
2734 assert_eq!(calls.load(Ordering::Relaxed), 0);
2735 }
2736
2737 /// A variadic operation's one declared parameter answers for every
2738 /// argument from its own position onwards, so the fourth `say` is checked
2739 /// against the same `String` the first one was.
2740 #[test]
2741 fn a_variadic_operation_checks_every_argument_against_its_declared_type() {
2742 let (hosts, _, _) = registry_with_wayward(Answer::Declared);
2743
2744 hosts
2745 .call(
2746 "wayward",
2747 "say",
2748 vec![Value(Repr::Str("a".into())), Value(Repr::Str("b".into()))],
2749 )
2750 .expect("strings all the way along are admitted");
2751
2752 let error = hosts
2753 .call(
2754 "wayward",
2755 "say",
2756 vec![Value(Repr::Str("a".into())), Value(Repr::Int(2))],
2757 )
2758 .expect_err("an `Int` among the declared strings is refused");
2759 assert_eq!(
2760 error.message,
2761 "`wayward.say` was given `Int` as argument 2, but its schema declares `String` there"
2762 );
2763 }
2764
2765 /// `Any` admits whatever it is given on the way in for the same reason it
2766 /// does on the way out: it is the type of an operation whose meaning does
2767 /// not depend on which value it was handed.
2768 #[test]
2769 fn an_argument_declared_any_admits_whatever_it_is_given() {
2770 let (hosts, calls, _) = registry_with_wayward(Answer::Declared);
2771
2772 for argument in [
2773 Value(Repr::Int(3)),
2774 Value(Repr::Unit),
2775 Value(Repr::Str("text".into())),
2776 ] {
2777 hosts
2778 .call("wayward", "bound", vec![argument])
2779 .expect("`Any` admits everything");
2780 }
2781 assert_eq!(calls.load(Ordering::Relaxed), 3);
2782 }
2783
2784 /// A host whose *result* violates its declared type is refused, and the
2785 /// diagnostic names the host that broke its word rather than the Cove
2786 /// code that would have received the value.
2787 ///
2788 /// `wayward.read` declares `Result<String, Error>` and answers `3`. ADR
2789 /// 0001 asks the schema to describe "argument, result, and error types"
2790 /// and to be "shared by the compiler, runtime, and CLI", and a
2791 /// description nothing enforces is a comment. The trace still records
2792 /// what the host did: the check refuses the value, not the fact.
2793 #[test]
2794 fn a_result_that_violates_its_declared_type_is_refused() {
2795 let (hosts, calls, sink) = registry_with_wayward(Answer::Undeclared);
2796
2797 let error = hosts
2798 .call("wayward", "read", vec![Value(Repr::Str("input".into()))])
2799 .expect_err("a host that breaks its own schema is refused");
2800
2801 assert_eq!(
2802 error.message,
2803 "`wayward.read` answered `Int`, but its schema declares `Result<String, Error>`"
2804 );
2805 let help = error.help.expect("the diagnostic quotes the schema");
2806 assert!(
2807 help.contains("wayward.read(String) -> Result<String, Error>"),
2808 "{help}"
2809 );
2810 assert_eq!(
2811 calls.load(Ordering::Relaxed),
2812 1,
2813 "the host was reached: this is a check on what it answered"
2814 );
2815 let events = sink.events();
2816 assert_eq!(events.len(), 1, "{events:?}");
2817 match &events[0] {
2818 TraceEvent::HostCall {
2819 granted, outcome, ..
2820 } => {
2821 assert!(granted);
2822 match outcome {
2823 Some(HostOutcome::Value(recorded)) => assert_eq!(shown(recorded), "3"),
2824 other => panic!("expected the answer on the record, found {other:?}"),
2825 }
2826 }
2827 other => panic!("expected a HostCall event, found {other:?}"),
2828 }
2829 }
2830
2831 /// The check follows the declared type's own recursion, so an array of
2832 /// the declared shape holding one element that is not is caught, and the
2833 /// diagnostic says which element.
2834 #[test]
2835 fn a_violation_inside_a_declared_result_says_where_it_is() {
2836 let (hosts, _, _) = registry_with_wayward(Answer::Undeclared);
2837
2838 let error = hosts
2839 .call("wayward", "list", Vec::new())
2840 .expect_err("an `Int` among the declared strings is refused");
2841
2842 assert_eq!(
2843 error.message,
2844 "`wayward.list` answered `Int` at `Ok(_)[1]` of its result, but its schema declares `String` there"
2845 );
2846 }
2847
2848 /// `Any` is not a missing type but the type of an operation whose meaning
2849 /// does not depend on which value it was given, so it admits whatever the
2850 /// host answers — including the `Int` every other declaration here
2851 /// refuses.
2852 #[test]
2853 fn a_result_declared_any_admits_whatever_the_host_answers() {
2854 for answer in [Answer::Declared, Answer::Undeclared] {
2855 let (hosts, _, _) = registry_with_wayward(answer);
2856
2857 let value = hosts
2858 .call("wayward", "anything", Vec::new())
2859 .expect("`Any` admits everything");
2860
2861 assert!(matches!(value, Value(Repr::Int(3))), "{value}");
2862 }
2863 }
2864
2865 /// A `Named` result is checked by the name the value carries, and a
2866 /// handle carries the module and kind it was issued for. So the lie the
2867 /// boundary used to catch only at the *next* call — a handle naming a
2868 /// resource kind the module never declared — is caught where it is told.
2869 #[test]
2870 fn a_handle_naming_a_kind_the_operation_did_not_declare_is_refused() {
2871 let (hosts, _, _) = registry_with_wayward(Answer::Undeclared);
2872
2873 let error = hosts
2874 .call("wayward", "open", Vec::new())
2875 .expect_err("a handle of an undeclared kind is refused where it is answered");
2876
2877 assert_eq!(
2878 error.message,
2879 "`wayward.open` answered `wayward.Ghost`, but its schema declares `wayward.Handle`"
2880 );
2881 }
2882
2883 /// A resource operation passes through the same choke point as a module
2884 /// operation, so it is held to its declaration the same way, and the
2885 /// diagnostic names it the way Cove source does.
2886 #[test]
2887 fn a_resource_operation_is_held_to_its_declaration_too() {
2888 let (hosts, _, _) = registry_with_wayward(Answer::Undeclared);
2889 let handle = ResourceHandle::new("wayward", &WAYWARD_RESOURCES[0], 1);
2890
2891 let error = hosts
2892 .call_resource(&handle, "close", Vec::new(), &mut NoReentry)
2893 .expect_err("a handle's operation that breaks its own schema is refused");
2894
2895 assert_eq!(
2896 error.message,
2897 "`wayward.Handle.close` answered `String` at `Ok(_)` of its result, but its schema declares `Unit` there"
2898 );
2899 let help = error.help.expect("the diagnostic quotes the schema");
2900 assert!(
2901 help.contains("wayward.Handle.close() -> Result<Unit, Error>"),
2902 "{help}"
2903 );
2904 }
2905}