1
//! Template render entry point.
2
//!
3
//! A template is per-user nomiscript source that pre-fills a transaction draft.
4
//! [`compile_template`] validates source against the restricted render compiler
5
//! surface (a template naming any non-allowlisted native is a compile error).
6
//! [`render_template`] runs it under [`link_render`](crate::natives::link_render)
7
//! — a linker that registers only the read-only financial natives and the draft
8
//! natives, never the config/user/ssh secret surface — and returns the
9
//! accumulated [`TransactionDraft`].
10
//!
11
//! Both gates (compiler whitelist + linker omission) are load-bearing for token
12
//! security: Slice B keeps the per-user JWT private key in the same per-user DB
13
//! the eval channel reads, so an escape here would let a template exfiltrate the
14
//! signing key. The whitelist test suite (`tests/`) is treated as a security
15
//! gate.
16

            
17
use std::sync::{Arc, Mutex};
18

            
19
use nomiscript::{Compiler, Program, Reader, SymbolTable};
20
use scripting::runtime::{EngineError, classify_runtime_error};
21
use thiserror::Error;
22
use wasmtime::{AnyRef, Linker, Rooted, Store, Val};
23

            
24
use crate::ctx::ScriptCtx;
25
use crate::draft::TransactionDraft;
26
use crate::session::SessionData;
27
use crate::wasm::{EngineOpts, build_engine};
28

            
29
const RENDER_EPOCH_TICKS: u64 = 1;
30

            
31
#[derive(Debug, Error)]
32
pub enum TemplateError {
33
    /// Source failed to parse or referenced a native outside the render
34
    /// whitelist (or any other compile error).
35
    #[error("template compile error: {0}")]
36
    Compile(String),
37
    /// The wasm engine / instantiation failed.
38
    #[error("template engine error: {0}")]
39
    Engine(String),
40
    /// A draft native trapped at runtime (bad arg, missing entity, …).
41
    #[error("template render error: {0}")]
42
    Runtime(String),
43
}
44

            
45
/// Compiles `source` against the restricted render surface, returning the wasm
46
/// bytes. A template that names a non-allowlisted native fails here with a
47
/// [`TemplateError::Compile`] — this is the first half of the security gate
48
/// (the compiler simply doesn't know the dangerous natives exist).
49
108
pub fn compile_template(source: &str) -> Result<Vec<u8>, TemplateError> {
50
108
    let (_, bytes) = compile_render_program(source)?;
51
18
    Ok(bytes)
52
108
}
53

            
54
108
fn compile_render_program(source: &str) -> Result<(Program, Vec<u8>), TemplateError> {
55
108
    let host_fns = crate::natives::render_compiler_specs();
56
108
    let mut symbols = SymbolTable::with_builtins();
57
108
    symbols.register_host_fns(&host_fns);
58
108
    crate::host_prelude::load(&mut symbols);
59
108
    let mut compiler = Compiler::with_host_fns(host_fns);
60
108
    let program = Reader::parse(source).map_err(|err| TemplateError::Compile(err.to_string()))?;
61
108
    let (bytes, _ty) = compiler
62
108
        .compile_eval_with_type(&program, &mut symbols)
63
108
        .map_err(|err| TemplateError::Compile(err.to_string()))?;
64
18
    Ok((program, bytes))
65
108
}
66

            
67
/// Renders `source` for `ctx`'s user, returning the accumulated draft. Runs the
68
/// program form-by-form under the render linker (read-only financial natives +
69
/// draft natives only). The final value of each form is ignored; only the draft
70
/// side effects matter.
71
90
pub async fn render_template(
72
90
    ctx: &ScriptCtx,
73
90
    source: &str,
74
90
) -> Result<TransactionDraft, TemplateError> {
75
90
    let host_fns = crate::natives::render_compiler_specs();
76
90
    let mut symbols = SymbolTable::with_builtins();
77
90
    symbols.register_host_fns(&host_fns);
78
90
    crate::host_prelude::load(&mut symbols);
79
90
    let mut compiler = Compiler::with_host_fns(host_fns);
80
90
    let program = Reader::parse(source).map_err(|err| TemplateError::Compile(err.to_string()))?;
81

            
82
90
    let engine = build_engine(EngineOpts::baseline().with_fuel())
83
90
        .map_err(|err| TemplateError::Engine(err.to_string()))?;
84
90
    let mut linker: Linker<SessionData> = Linker::new(&engine);
85
90
    crate::natives::link_render(&mut linker)
86
90
        .map_err(|err| TemplateError::Engine(err.to_string()))?;
87

            
88
90
    let output = Arc::new(Mutex::new(String::new()));
89
90
    let mut store: Store<SessionData> =
90
90
        Store::new(&engine, SessionData::for_render(ctx.clone(), output));
91
90
    store
92
90
        .set_fuel(ctx.limits.fuel)
93
90
        .map_err(|err| TemplateError::Engine(err.to_string()))?;
94
90
    store.set_epoch_deadline(RENDER_EPOCH_TICKS);
95

            
96
144
    for form in program.exprs {
97
144
        let single = Program::new(vec![form]);
98
144
        let (bytes, _ty) = compiler
99
144
            .compile_eval_with_type(&single, &mut symbols)
100
144
            .map_err(|err| TemplateError::Compile(err.to_string()))?;
101
144
        let module = scripting::runtime::compile_module(&engine, &bytes)
102
144
            .map_err(|err| TemplateError::Engine(err.to_string()))?;
103
144
        let instance = linker
104
144
            .instantiate_async(&mut store, &module)
105
144
            .await
106
144
            .map_err(|err| TemplateError::Engine(classify_engine(&err)))?;
107
144
        let func = instance.get_func(&mut store, "nomi-eval").ok_or_else(|| {
108
            TemplateError::Engine("render module missing nomi-eval export".to_string())
109
        })?;
110
144
        let mut results = [Val::AnyRef(None)];
111
144
        func.call_async(&mut store, &[], &mut results)
112
144
            .await
113
144
            .map_err(|err| TemplateError::Runtime(classify_engine(&err)))?;
114
108
        let _: Option<Rooted<AnyRef>> = match results[0] {
115
108
            Val::AnyRef(a) => a,
116
            _ => None,
117
        };
118
    }
119

            
120
54
    Ok(store.into_data().into_draft().unwrap_or_default())
121
90
}
122

            
123
36
fn classify_engine(err: &wasmtime::Error) -> String {
124
36
    match classify_runtime_error(err) {
125
        EngineError::ScriptRaised { code, message } => format!("{code}: {message}"),
126
36
        other => other.to_string(),
127
    }
128
36
}