1
//! Restricted render-only native surface for template evaluation.
2
//!
3
//! Templates are per-user nomiscript source that must NOT be able to mutate
4
//! data or read secrets — above all the per-user JWT private key reachable via
5
//! `get-config` (Slice B places it in the same per-user DB the eval channel
6
//! reads). The render surface is therefore a strict WHITELIST applied at BOTH
7
//! levels:
8
//!
9
//!   * [`render_compiler_specs`] — the compiler only knows the allowlisted
10
//!     read-only financial natives plus the draft natives, so a template that
11
//!     names anything else is a COMPILE error (it never reaches the linker).
12
//!   * [`link_render`] — the linker registers only the infra natives, the
13
//!     allowlisted read modules' fns, and the draft natives. The secret/auth
14
//!     modules (`config`, `user`, `ssh_key`) and every mutator are NOT linked
15
//!     at all, so even a hypothetical compiler-spec leak has nothing to call.
16
//!
17
//! Whitelist, not blacklist: a newly added native is excluded by default and
18
//! must be explicitly added here, so a future sensitive native cannot silently
19
//! become reachable from templates.
20

            
21
use nomiscript::HostFnSpec;
22
use wasmtime::Linker;
23

            
24
use crate::session::SessionData;
25

            
26
/// The only non-draft natives a template may call: read-only financial lookups,
27
/// pure value conversion, and the session-user-id accessor. Every entry is
28
/// verified read-only — no mutator, no config/secret reader, no auth surface.
29
/// `convert-commodity` only reads the prices table (verified: it issues
30
/// `SELECT … prices …`, never a write).
31
pub const RENDER_NATIVE_ALLOWLIST: &[&str] = &[
32
    // Infra / meta. NOTE: `get-version`/`get-build-date` are deliberately
33
    // excluded — they live in the `config` module alongside the catastrophic
34
    // `get-config`, and `link_render` never registers that module. Keeping them
35
    // off the allowlist means the entire config surface stays unlinked, so the
36
    // secret reader has nothing to bind to even if a spec leaked. Templates have
37
    // no use for build metadata anyway.
38
    "rpc-protocol-version",
39
    "rpc-session-user-id",
40
    // Account reads.
41
    "account-count",
42
    "list-accounts",
43
    "get-account",
44
    "get-balance",
45
    "get-account-commodities",
46
    "account-balance",
47
    "list-accounts-for-manage",
48
    "get-account-for-manage",
49
    // Commodity reads + pure conversion.
50
    "list-commodities",
51
    "get-commodity",
52
    "convert-commodity",
53
    // Transaction reads.
54
    "list-transactions",
55
    "get-transaction",
56
    "get-transaction-tag",
57
    // Split reads.
58
    "list-splits",
59
    "list-splits-by-transaction",
60
    "get-split-tag",
61
    // Reports (read-only).
62
    "balance-report",
63
    "activity-report",
64
    "category-breakdown",
65
    // Draft natives (render-only side effects into the accumulator).
66
    "set-draft-note",
67
    "set-draft-date",
68
    "draft-split",
69
    "draft-tag",
70
    "draft-split-tag",
71
];
72

            
73
/// Compiler specs the render surface exposes: the allowlisted subset of
74
/// [`all_compiler_specs`](super::all_compiler_specs). Filtering the single
75
/// canonical registry (rather than hand-maintaining a parallel list) keeps the
76
/// render surface in lock-step with the org source — a renamed native drops out
77
/// automatically rather than silently mismatching the linker.
78
#[must_use]
79
252
pub fn render_compiler_specs() -> Vec<HostFnSpec> {
80
252
    super::all_compiler_specs()
81
252
        .into_iter()
82
11088
        .filter(|spec| {
83
11088
            RENDER_NATIVE_ALLOWLIST.contains(&spec.nomi_name.to_ascii_lowercase().as_str())
84
11088
        })
85
252
        .collect()
86
252
}
87

            
88
/// Links ONLY the render-safe host fns onto `linker`: infra, the allowlisted
89
/// read fns, and the draft natives. The dual gate is FULL here — a dangerous
90
/// native is blocked at both levels. First, the `config`, `user`, and `ssh_key`
91
/// modules are never registered at all (the secret/credential/auth surface has
92
/// nothing to bind to). Second, the account/commodity/split/transaction
93
/// mutators are excluded by linking each module's `register_readonly` variant,
94
/// so `create-*` / `update-*` / `delete-*` / `set-*-tag` imports are never bound
95
/// either. So even a hypothetical compiler-spec leak finds no linked target.
96
/// `env.log` is bound as a silent no-op (the import must resolve, but template
97
/// output is discarded).
98
101
pub fn link_render(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
99
101
    super::meta::register(linker)?;
100
101
    super::env_io::register_silent(linker)?;
101
101
    super::raise::register(linker)?;
102
101
    super::catch_each::register(linker)?;
103
101
    super::account::register_readonly(linker)?;
104
101
    super::commodity::register_readonly(linker)?;
105
101
    super::report::register(linker)?;
106
101
    super::split::register_readonly(linker)?;
107
101
    super::transaction::register_readonly(linker)?;
108
101
    super::template::register(linker)?;
109
101
    Ok(())
110
101
}
111

            
112
#[cfg(test)]
113
mod tests {
114
    use crate::session::SessionData;
115
    use crate::wasm::{EngineOpts, build_engine};
116
    use wasmtime::Linker;
117

            
118
    /// Whether the render linker has a host fn bound at `nomi.<import>`.
119
    /// Independent of the compiler gate: proves the linker itself never binds a
120
    /// dangerous import, so even a spec leak finds no target.
121
11
    fn render_binds(import: &str) -> bool {
122
11
        let engine = build_engine(EngineOpts::baseline().with_fuel()).expect("engine");
123
11
        let mut linker: Linker<SessionData> = Linker::new(&engine);
124
11
        super::link_render(&mut linker).expect("link_render");
125
11
        linker
126
11
            .get(&mut dummy_store(&engine), "nomi", import)
127
11
            .is_ok()
128
11
    }
129

            
130
11
    fn dummy_store(engine: &wasmtime::Engine) -> wasmtime::Store<SessionData> {
131
        use crate::ctx::ScriptCtx;
132
        use std::sync::{Arc, Mutex};
133
        use uuid::Uuid;
134
11
        wasmtime::Store::new(
135
11
            engine,
136
11
            SessionData::for_render(
137
11
                ScriptCtx::new(Uuid::nil()),
138
11
                Arc::new(Mutex::new(String::new())),
139
            ),
140
        )
141
11
    }
142

            
143
    #[test]
144
1
    fn render_linker_does_not_bind_mutators_or_secrets() {
145
10
        for dangerous in [
146
1
            "transaction_create_transaction",
147
1
            "transaction_delete_transaction",
148
1
            "account_create_account",
149
1
            "account_set_account_tag",
150
1
            "commodity_create_commodity",
151
1
            "split_set_split_tag",
152
1
            "config_get_config",
153
1
            "config_set_config",
154
1
            "user_verify_user_password",
155
1
            "ssh_key_lookup_user_by_ssh_key",
156
1
        ] {
157
10
            assert!(
158
10
                !render_binds(dangerous),
159
                "render linker must NOT bind dangerous import '{dangerous}'"
160
            );
161
        }
162
1
    }
163

            
164
    #[test]
165
1
    fn render_linker_binds_allowlisted_reads() {
166
1
        assert!(
167
1
            render_binds("account_get_account"),
168
            "render linker must bind the read native 'account_get_account'"
169
        );
170
1
    }
171
}