cove_runtime/embed.rs
1//! Running a Cove program that a native executable carries inside itself.
2//!
3//! `cove build` writes a small Rust crate whose `main` hands the package's
4//! sources, its `[run.<name>]` table, and the backend it was built for to
5//! [`Embedded::main`]. That crate links this one, so the executable it
6//! produces embeds a backend rather than compiling Cove to machine code; see
7//! ADR 0009, and ADR 0022 for which backend that now is.
8//!
9//! # Why the binary lowers, and why `cove build` lowers too
10//!
11//! The IR is not a serialization format, so a built binary cannot carry one:
12//! it carries its sources, resolves them, and lowers them when it starts.
13//! That is a few hundred microseconds against a run that lasts as long as the
14//! program does.
15//!
16//! What it must not do is discover at that moment that it cannot run. So
17//! `cove build` lowers the same entry at build time and refuses to write a
18//! binary it would refuse to start -- because the person who can act on the
19//! refusal is the one holding the source, and they are not the one holding
20//! the binary. ADR 0034 makes such a refusal a bug in the lowering rather
21//! than a construct the backend declines, which is why what comes back is a
22//! diagnostic and not a named construct.
23//!
24//! [`register_hosts`] is the one place the host implementations a run gets
25//! are chosen. `cove run` and a built binary both call it, so a built binary
26//! cannot drift into registering a different boundary than the run it was
27//! built from.
28
29use std::collections::BTreeMap;
30use std::path::PathBuf;
31use std::process::ExitCode;
32use std::rc::Rc;
33use std::sync::Arc;
34use std::time::Duration;
35
36use cove_diag::{render, Severity, SourceMap};
37use cove_sema::config::{Config, RunConfig};
38use cove_sema::package::{Module, Package, Unit};
39
40use crate::clock::Clock;
41use crate::database::Database;
42use crate::files::Files;
43use crate::host::{Console, Documents, Env, GrantSource, Grants, HostRegistry};
44use crate::http::Http;
45use crate::interp::Interpreter;
46use crate::process::Process;
47use crate::runtime::Runtime;
48use crate::trace::create_trace_file;
49use crate::value::Repr;
50use crate::Vm;
51use crate::{
52 Budget, Cancellation, JsonlSink, Limits, NullSink, RecordingBackend, TraceHeader, TraceSink,
53 Value, ValueCapture,
54};
55
56/// The hosts a run is given, and the directories they are confined to.
57pub struct HostSetup {
58 /// The capabilities `[run.<name>] allow` granted.
59 pub grants: Vec<String>,
60 /// The one directory the `documents` host may read.
61 pub documents_root: PathBuf,
62 /// The one directory the `files` host may reach.
63 pub files_root: PathBuf,
64 /// The arguments the program itself receives, which `process.args`
65 /// reports and nothing else sees.
66 pub program_args: Vec<String>,
67 /// The executables `process.run` may start. Empty allows none.
68 pub allow_exec: Vec<PathBuf>,
69}
70
71/// Registers every host implementation a run may reach, granting exactly
72/// `setup.grants`.
73///
74/// Registering a module does not grant it: `HostRegistry::call` rejects every
75/// call whose capability is missing from the grant set, so registering the
76/// full set here and granting a subset is what makes an ungranted call a
77/// reported refusal rather than an unknown module.
78pub fn register_hosts(setup: HostSetup) -> HostRegistry {
79 let mut hosts = HostRegistry::new(Grants::new(setup.grants));
80 // The program's output goes where the process's output goes and its
81 // diagnostics go where the process's diagnostics go, so `cove run`'s own
82 // errors and a program's complaints arrive on the same stream and a
83 // pipe on stdout carries the records alone.
84 hosts.register(Box::new(Console::new(std::io::stdout(), std::io::stderr())));
85 hosts.register(Box::new(Env::from_process()));
86 hosts.register(Box::new(Documents::rooted(setup.documents_root)));
87 hosts.register(Box::new(Clock::real()));
88 // Granting `files` must not hand over the machine, so this host picks one
89 // directory and the runtime refuses every path outside it.
90 hosts.register(Box::new(Files::rooted(setup.files_root)));
91 // A program that can start any other program has every authority the
92 // machine has, so `process.run` is filtered, not merely granted. This
93 // host knows nothing about what a package is entitled to start, so it
94 // allows nothing until the caller names an executable. `process.args`
95 // passes on exactly the arguments the entry function receives, and
96 // nothing of the launching command line.
97 hosts.register(Box::new(Process::real(
98 setup.program_args,
99 setup.allow_exec,
100 )));
101 // There is no real `database`: connecting to one means speaking a wire
102 // protocol, and this toolchain depends on nothing but the standard
103 // library. A denied implementation is one of the four the Language Card
104 // names, and it tells a run what is missing instead of telling it that
105 // `database` is not a host module.
106 hosts.register(Box::new(Database::denied()));
107 // `http` is real, and narrow in one direction: `fetch` reaches whatever
108 // the URL names, but `listen` binds loopback only. Granting a run the
109 // network should not publish a port on every interface the machine has,
110 // and a host that wanted that would say so by installing a different one.
111 hosts.register(Box::new(Http::real()));
112 hosts
113}
114
115/// One `.cove` file a built binary carries.
116pub struct EmbeddedSource {
117 /// The file's path relative to the package root it was built from, such
118 /// as `hello/main.cove`. It names the module the file belongs to and is
119 /// what a diagnostic reports, so a built binary's errors carry no path
120 /// from the machine that built it.
121 pub path: &'static str,
122 /// The file's text, exactly as it was checked at build time.
123 pub text: &'static str,
124}
125
126/// The `[run.<name>]` table a built binary carries.
127///
128/// Every field was fixed when the binary was built. Nothing reads a
129/// `cove.toml` at run time, so a file placed beside the binary can neither
130/// widen its grants nor raise its limits.
131pub struct EmbeddedRun {
132 /// The `[run.<name>]` table this binary was built from.
133 pub name: &'static str,
134 /// The fully qualified entry function, such as `hello.main`.
135 pub entry: &'static str,
136 /// The capabilities this binary was granted.
137 pub allow: &'static [&'static str],
138 /// The total fuel this binary may spend.
139 pub fuel: Option<u64>,
140 /// The wall-clock deadline this binary may take, in nanoseconds.
141 pub deadline_nanos: Option<u64>,
142 /// The total number of host calls this binary may make.
143 pub max_host_calls: Option<u64>,
144 /// The tasks this binary may hold alive at once, across the whole run.
145 pub max_tasks: Option<u64>,
146 /// A path to write a JSONL trace to, or `-` for stderr.
147 pub trace: Option<&'static str>,
148 /// The one directory the `files` host may reach, as an absolute path
149 /// chosen at build time. Without one, the binary uses `files/` in its
150 /// working directory.
151 pub files_root: Option<&'static str>,
152 /// The executables `process.run` may start.
153 pub allow_exec: &'static [&'static str],
154}
155
156/// Which backend a built binary runs its program on.
157///
158/// Fixed at build time like everything else the binary carries, and for the
159/// same reason: a flag that chose it would be a flag, and a built binary
160/// honours none. `cove build --backend ast` is where the choice is made.
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162pub enum EmbeddedBackend {
163 /// The tree-walking interpreter, which ADR 0034 keeps as the semantic
164 /// oracle.
165 Ast,
166 /// The linear-memory backend of ADR 0034, which is what a run runs on
167 /// unless the build asked otherwise.
168 Vm,
169}
170
171/// A whole program: the sources a built binary carries, the run it carries
172/// them for, and the backend it runs them on.
173pub struct Embedded {
174 /// Every `.cove` file of the package, which together are the whole
175 /// program: the binary reads no source from disk.
176 pub sources: &'static [EmbeddedSource],
177 /// The run the sources were built for.
178 pub run: EmbeddedRun,
179 /// The backend `cove build` chose, which is the linear-memory backend
180 /// unless the build asked otherwise.
181 pub backend: EmbeddedBackend,
182}
183
184impl Embedded {
185 /// Runs the embedded program, and is the whole of a built binary's
186 /// `main`.
187 ///
188 /// Every process argument is the program's own: a built binary parses no
189 /// flags of its own, because a flag it honoured would be a way to ask it
190 /// for something its `[run.<name>]` table did not.
191 pub fn main(&self) -> ExitCode {
192 // On a thread this runtime sized, for the reason `on_cove_stack`
193 // gives: a built binary's `main` runs on whatever stack the platform
194 // gave the process, and a backend's depth limit is calibrated
195 // against a stack the runtime chose.
196 match crate::on_cove_stack(|| self.run_and_report()) {
197 Ok(code) => code,
198 Err(error) => {
199 eprintln!("error: this program could not start the thread it runs on: {error}");
200 ExitCode::FAILURE
201 }
202 }
203 }
204
205 /// The run itself, and how its outcome is reported.
206 fn run_and_report(&self) -> ExitCode {
207 let args: Vec<String> = std::env::args().skip(1).collect();
208 match self.run(args) {
209 Ok(()) => ExitCode::SUCCESS,
210 Err(Failure::Message(message)) => {
211 eprintln!("error: {message}");
212 ExitCode::FAILURE
213 }
214 Err(Failure::Diagnostics { sources, items }) => {
215 for item in &items {
216 eprint!("{}", render(&sources, item));
217 }
218 let errors = items
219 .iter()
220 .filter(|d| d.severity == Severity::Error)
221 .count();
222 if errors > 0 {
223 eprintln!("{errors} error(s)");
224 ExitCode::FAILURE
225 } else {
226 ExitCode::SUCCESS
227 }
228 }
229 }
230 }
231
232 fn run(&self, program_args: Vec<String>) -> Result<(), Failure> {
233 let mut sources = SourceMap::new();
234 let parsed = self.package(&mut sources);
235 // Shared, because a task running on another thread points a
236 // diagnostic into the same source map this thread reports from.
237 let sources = Arc::new(sources);
238 let package = parsed.map_err(|items| Failure::Diagnostics {
239 sources: sources.clone(),
240 items,
241 })?;
242 // The program was checked when it was built, so an interpreted
243 // binary resolves and does not check: the type checker's answer
244 // cannot have changed for sources that cannot have changed.
245 //
246 // A lowered binary checks anyway, and not because the answer might
247 // differ. The lowering *reads* the checker's answers rather than
248 // recomputing them -- what a reference denotes, what a receiver's
249 // type is, where a field sits -- and resolution does not produce
250 // them. So the check here is how those answers get made, not a
251 // second opinion about whether the program is well formed. Its
252 // diagnostics are unreachable for a binary `cove build` wrote,
253 // because that command refused to write one for a package that did
254 // not check.
255 //
256 // This is the startup cost ADR 0022 names: proportional to the size
257 // of the program, paid once, before anything runs. Serializing the
258 // IR would remove it, and the IR is not a format.
259 let program = match self.backend {
260 EmbeddedBackend::Ast => {
261 cove_sema::resolve::resolve(&package).map_err(|items| Failure::Diagnostics {
262 sources: sources.clone(),
263 items,
264 })?
265 }
266 EmbeddedBackend::Vm => {
267 cove_sema::Compiler::new()
268 .compile(&package)
269 .map_err(|items| Failure::Diagnostics {
270 sources: sources.clone(),
271 items,
272 })?
273 }
274 };
275
276 let (module, entry) = self.run.entry.rsplit_once('.').ok_or_else(|| {
277 Failure::Message(format!("`{}` is not a qualified entry", self.run.entry))
278 })?;
279
280 // Before a host is registered and before anything the program could
281 // be observed by, exactly as `cove run` lowers: a run either finishes
282 // on the backend it was built for or fails before any side effect,
283 // and a binary is a run.
284 //
285 // Reaching the failure here means `cove build` did not reach it,
286 // which it cannot for a binary built from these sources -- so this
287 // arm is the one that would catch a lowering that stopped agreeing
288 // with itself between the two, rather than a program anybody wrote.
289 let lowered = match self.backend {
290 EmbeddedBackend::Ast => None,
291 EmbeddedBackend::Vm => {
292 // The shipped schemas and no others, which is the set the
293 // `Compiler::new()` above checked this package against.
294 let ir = cove_ir::lower_entry(
295 &program,
296 &sources,
297 &cove_sema::HostSchemas::new(),
298 module,
299 entry,
300 )
301 .map_err(|items| Failure::Diagnostics {
302 sources: sources.clone(),
303 items,
304 })?;
305 Some(Arc::new(ir))
306 }
307 };
308
309 let working_dir = std::env::current_dir()
310 .map_err(|e| Failure::Message(format!("cannot read the current directory: {e}")))?;
311 let mut hosts = register_hosts(HostSetup {
312 grants: self.run.allow.iter().map(|s| (*s).to_string()).collect(),
313 // A built binary has no package root, so the data a host may
314 // reach is named relative to where the binary is run. That is
315 // what lets an executable and its `documents/` and `files/`
316 // directories be copied somewhere else together.
317 documents_root: working_dir.join("documents"),
318 files_root: match self.run.files_root {
319 Some(root) => PathBuf::from(root),
320 None => working_dir.join("files"),
321 },
322 program_args: program_args.clone(),
323 allow_exec: self.run.allow_exec.iter().map(PathBuf::from).collect(),
324 });
325 // So that a refused call does not send the reader to a `cove.toml`
326 // this binary will never read.
327 hosts.set_grant_source(GrantSource::Sealed);
328
329 let limits = Limits {
330 fuel: self.run.fuel,
331 deadline: self.run.deadline_nanos.map(Duration::from_nanos),
332 max_host_calls: self.run.max_host_calls,
333 max_call_depth: None,
334 max_tasks: self.run.max_tasks,
335 };
336 hosts.set_budget(Budget::with_cancellation(limits, Cancellation::new()));
337
338 // `HostRegistry::call` and the task and entry events the interpreter
339 // traces reach the one destination the run named, from whichever
340 // thread produced them. A binary built for a run that asked for no
341 // trace installs `NullSink`, which is what tells the registry that
342 // nothing will read a description of the values a call carried.
343 let sink = self.sink(&program_args)?;
344 hosts.set_trace(sink.clone());
345
346 let args: Vec<Rc<str>> = program_args.iter().map(|a| a.as_str().into()).collect();
347 let runtime =
348 Runtime::new(Arc::new(program), sources.clone(), Arc::new(hosts)).with_trace(sink);
349 let outcome = match &lowered {
350 Some(ir) => Vm::new(&runtime, runtime.hosts(), ir).run_entry(module, entry, args),
351 None => Interpreter::new(&runtime).run_entry(module, entry, args),
352 };
353 match outcome {
354 Ok(value) => report_exit(&value).map_err(Failure::Message),
355 Err(error) => Err(Failure::Diagnostics {
356 sources,
357 items: vec![error.to_diagnostic()],
358 }),
359 }
360 }
361
362 /// Rebuilds the package the binary was built from, in memory.
363 ///
364 /// A directory is a module and its name follows its path, so the module
365 /// each embedded file belongs to is derived from that file's recorded
366 /// relative path exactly as `cove_sema::package::load` derives it from
367 /// the directory on disk.
368 fn package(&self, sources: &mut SourceMap) -> Result<Package, Vec<cove_diag::Diagnostic>> {
369 let mut modules: BTreeMap<String, Module> = BTreeMap::new();
370 let mut diagnostics = Vec::new();
371 for source in self.sources {
372 let path = PathBuf::from(source.path);
373 let Some(dir) = path.parent() else {
374 continue;
375 };
376 let name = dir
377 .components()
378 .map(|c| c.as_os_str().to_string_lossy().into_owned())
379 .collect::<Vec<_>>()
380 .join(".");
381 let file = sources.add(path.clone(), source.text);
382 match cove_syntax::parse_file(sources, file) {
383 Ok(ast) => {
384 modules
385 .entry(name.clone())
386 .or_insert_with(|| Module {
387 name,
388 dir: dir.to_path_buf(),
389 units: Vec::new(),
390 })
391 .units
392 .push(Unit { file, path, ast });
393 }
394 Err(items) => diagnostics.extend(items),
395 }
396 }
397 if !diagnostics.is_empty() {
398 return Err(diagnostics);
399 }
400 // The binary carries the sources `cove build` embedded and nothing
401 // else — `cove_sema::stdlib`'s module is not among them, because
402 // `crate::build::plan` does not embed it; see that function for why.
403 // So this is attached the same way `cove_sema::package::load` always
404 // attaches it, and for the same reason: a builtin method whose body
405 // moved into the standard library has to find that body wherever a
406 // `Package` is assembled, including one rebuilt from a binary's own
407 // embedded sources.
408 cove_sema::stdlib::install(sources, &mut modules)?;
409
410 let mut runs = BTreeMap::new();
411 runs.insert(
412 self.run.name.to_string(),
413 RunConfig {
414 entry: self.run.entry.to_string(),
415 allow: self.run.allow.iter().map(|s| (*s).to_string()).collect(),
416 fuel: self.run.fuel,
417 deadline: self.run.deadline_nanos.map(Duration::from_nanos),
418 max_host_calls: self.run.max_host_calls,
419 max_tasks: self.run.max_tasks,
420 trace: self.run.trace.map(str::to_string),
421 // A built binary never generates: its whole point is a run
422 // `cove build` already refused to build if it set
423 // `generates`, so the rebuilt config here carries none.
424 generates: None,
425 },
426 );
427 Ok(Package {
428 root: PathBuf::new(),
429 config: Config {
430 runs,
431 ..Config::default()
432 },
433 modules,
434 })
435 }
436
437 /// Opens the trace destination the `[run.<name>]` table named.
438 fn sink(&self, program_args: &[String]) -> Result<Arc<dyn TraceSink>, Failure> {
439 let header = TraceHeader {
440 // The backend `cove build` chose, which is the backend this
441 // binary is about to run on: a built binary embeds one evaluator
442 // and has no flag to change it, so what it records is what it
443 // ran.
444 backend: match self.backend {
445 EmbeddedBackend::Ast => RecordingBackend::Ast,
446 EmbeddedBackend::Vm => RecordingBackend::Vm,
447 },
448 values: ValueCapture::Full,
449 entry: self.run.entry.to_string(),
450 args: program_args.to_vec(),
451 };
452 match self.run.trace {
453 None => Ok(Arc::new(NullSink)),
454 Some("-") => Ok(Arc::new(JsonlSink::new(std::io::stderr(), header))),
455 Some(path) => {
456 let file = create_trace_file(std::path::Path::new(path)).map_err(|e| {
457 Failure::Message(format!("cannot create trace file `{path}`: {e}"))
458 })?;
459 // A trace a program can be asked to share should not surprise
460 // the person sharing it, so the run says so once, here.
461 eprintln!(
462 "note: `{path}` will record the arguments and result of every host call, which may include secrets"
463 );
464 Ok(Arc::new(JsonlSink::new(file, header)))
465 }
466 }
467 }
468}
469
470/// An entry returning `Err(...)` fails the run and prints the error, exactly
471/// as it does under `cove run`.
472fn report_exit(value: &Value) -> Result<(), String> {
473 if let Value(Repr::Enum(result)) = value {
474 if value.is_err() {
475 return Err(result
476 .payload
477 .first()
478 .map(ToString::to_string)
479 .unwrap_or_default());
480 }
481 }
482 Ok(())
483}
484
485/// Why a built binary stopped.
486enum Failure {
487 Message(String),
488 Diagnostics {
489 sources: Arc<SourceMap>,
490 items: Vec<cove_diag::Diagnostic>,
491 },
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 /// A program that reaches for a capability its run was not granted.
499 static REFUSED: &[EmbeddedSource] = &[EmbeddedSource {
500 path: "app/main.cove",
501 text: "\
502use files
503
504/// Reads a file this run was never granted.
505export fn main() -> Result<Unit, Error> {
506 let notes = files.read(\"notes.txt\")?
507 Ok(())
508}
509",
510 }];
511
512 /// A program in a nested module, which only its recorded path names.
513 static NESTED: &[EmbeddedSource] = &[EmbeddedSource {
514 path: "a/b/main.cove",
515 text: "\
516/// Answers without needing any capability.
517export fn main() -> Result<Unit, Error> {
518 Ok(())
519}
520",
521 }];
522
523 /// A binary built for `entry`, on the backend `cove build` would have
524 /// chosen for it.
525 fn embedded(sources: &'static [EmbeddedSource], entry: &'static str) -> Embedded {
526 embedded_on(sources, entry, EmbeddedBackend::Vm)
527 }
528
529 fn embedded_on(
530 sources: &'static [EmbeddedSource],
531 entry: &'static str,
532 backend: EmbeddedBackend,
533 ) -> Embedded {
534 Embedded {
535 backend,
536 sources,
537 run: EmbeddedRun {
538 name: "app",
539 entry,
540 allow: &[],
541 fuel: None,
542 deadline_nanos: None,
543 max_host_calls: None,
544 max_tasks: None,
545 trace: None,
546 files_root: None,
547 allow_exec: &[],
548 },
549 }
550 }
551
552 #[test]
553 fn a_module_name_follows_the_path_the_binary_recorded() {
554 let embedded = embedded(NESTED, "a.b.main");
555 assert!(
556 embedded.run(Vec::new()).is_ok(),
557 "a directory is a module and its name follows its path, embedded or not"
558 );
559 }
560
561 #[test]
562 fn a_trace_the_run_table_asked_for_is_written_where_it_named() {
563 let path: &'static str = Box::leak(
564 std::env::temp_dir()
565 .join(format!("cove-embed-trace-{}.jsonl", std::process::id()))
566 .display()
567 .to_string()
568 .into_boxed_str(),
569 );
570 let mut embedded = embedded(NESTED, "a.b.main");
571 embedded.run.trace = Some(path);
572 assert!(embedded.run(Vec::new()).is_ok());
573
574 let trace = std::fs::read_to_string(path).expect("the trace file was created");
575 let _ = std::fs::remove_file(path);
576 assert!(
577 trace
578 .lines()
579 .next()
580 .is_some_and(|line| line.contains("\"event\":\"trace_header\"")
581 && line.contains("\"entry\":\"a.b.main\"")),
582 "{trace}"
583 );
584 // A sealed binary is a run like any other, so its trace ends the way
585 // every other run's does: with how the run came out.
586 assert!(
587 trace.lines().last().is_some_and(
588 |line| line == r#"{"event":"run_ended","outcome":"success","message":null}"#
589 ),
590 "{trace}"
591 );
592 }
593
594 /// The other half of that: a sealed binary that fails records what
595 /// stopped it, which for a capability it was not built with is the Host
596 /// API boundary refusing rather than the program failing.
597 #[test]
598 fn a_sealed_binary_that_was_refused_records_what_refused_it() {
599 let path: &'static str = Box::leak(
600 std::env::temp_dir()
601 .join(format!("cove-embed-refused-{}.jsonl", std::process::id()))
602 .display()
603 .to_string()
604 .into_boxed_str(),
605 );
606 let mut embedded = embedded(REFUSED, "app.main");
607 embedded.run.trace = Some(path);
608 assert!(embedded.run(Vec::new()).is_err());
609
610 let trace = std::fs::read_to_string(path).expect("the trace file was created");
611 let _ = std::fs::remove_file(path);
612 let last = trace.lines().last().expect("a terminal line");
613 assert!(
614 last.contains(r#""event":"run_ended","outcome":"host_boundary""#)
615 && last.contains("requires the `files` capability"),
616 "{trace}"
617 );
618 }
619
620 #[test]
621 fn a_capability_the_binary_was_not_built_with_is_refused() {
622 let embedded = embedded(REFUSED, "app.main");
623 let Err(Failure::Diagnostics { sources, items }) = embedded.run(Vec::new()) else {
624 panic!("an ungranted call must be refused");
625 };
626 let rendered = render(&sources, &items[0]);
627 assert!(
628 rendered.contains(
629 "`files.read` requires the `files` capability, which this run was not granted"
630 ),
631 "{rendered}"
632 );
633 // The reported path is the one the package had, not one from the
634 // machine that built the binary.
635 assert!(rendered.contains("--> app/main.cove:5:15"), "{rendered}");
636 // Editing a `cove.toml` beside the binary would do nothing, so the
637 // help does not suggest it on its own.
638 assert!(
639 rendered.contains("help: this binary carries the capabilities it was built with"),
640 "{rendered}"
641 );
642 }
643}