Skip to main content

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}