Skip to main content

rpc/natives/
mod.rs

1//! Canonical host-fn registry for the 32 server commands.
2//!
3//! Each domain owns a submodule that mirrors the `server::command::*` shape and
4//! holds one file per server command. Commands register their host fn through a
5//! per-domain `register` entry point; the top-level `register_all` aggregates
6//! every domain so a wasmtime [`Linker`](wasmtime::Linker) gains the full
7//! canonical surface in one call.
8//!
9//! Subsequent commits fill each domain. This commit lays down the directory
10//! shape so per-command commits stay focused on one server command at a time.
11
12use wasmtime::{ArrayRef, Linker, Rooted};
13
14use crate::session::SessionData;
15
16/// One stringly-typed wasm host-fn argument as it lands on the
17/// `linker.func_wrap_async` closure: nullable because the arg can be
18/// absent, `Rooted<ArrayRef>` because the wasm side encodes strings as
19/// `(ref null $i8_array)`.
20pub(crate) type StringArg = Option<Rooted<ArrayRef>>;
21/// Three-arg tuple shape used by `account_set_account_tag` and other
22/// host fns that take three string args.
23pub(crate) type StringArgTriple = (StringArg, StringArg, StringArg);
24
25pub mod account;
26pub mod catch_each;
27pub mod commodity;
28pub mod config;
29pub mod env_io;
30mod generated_specs;
31pub mod meta;
32pub mod raise;
33mod render;
34pub mod report;
35pub mod split;
36pub mod ssh_key;
37pub mod template;
38pub mod transaction;
39
40pub use generated_specs::all_compiler_specs;
41pub use render::{RENDER_NATIVE_ALLOWLIST, link_render, render_compiler_specs};
42
43/// Aggregator: registers every domain's host fns on `linker` in one call.
44/// Bound to [`SessionData`] now that `meta` natives consult `ScriptCtx`
45/// (user_id) — generic-T register fns from the empty per-domain modules
46/// still satisfy this concrete type because they don't touch the Store data.
47pub fn link(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
48    meta::register(linker)?;
49    env_io::register(linker)?;
50    raise::register(linker)?;
51    catch_each::register(linker)?;
52    account::register(linker)?;
53    commodity::register(linker)?;
54    config::register(linker)?;
55    report::register(linker)?;
56    split::register(linker)?;
57    ssh_key::register(linker)?;
58    template::register(linker)?;
59    transaction::register(linker)?;
60    Ok(())
61}
62
63// `all_compiler_specs` lives in `generated_specs` (tangled from
64// `doc/scripting/native_reference.org` by `rpc/build.rs`) — see the
65// `pub use` import above. Adding or modifying a native means editing
66// the org table; cargo build regenerates the Rust.
67
68#[must_use]
69pub fn all_registered_commands() -> Vec<&'static str> {
70    [
71        account::REGISTERED_COMMANDS,
72        commodity::REGISTERED_COMMANDS,
73        config::REGISTERED_COMMANDS,
74        report::REGISTERED_COMMANDS,
75        split::REGISTERED_COMMANDS,
76        ssh_key::REGISTERED_COMMANDS,
77        transaction::REGISTERED_COMMANDS,
78    ]
79    .iter()
80    .flat_map(|d| d.iter().copied())
81    .collect()
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn registry_lists_every_planned_command() {
90        let names = all_registered_commands();
91        assert_eq!(names.len(), 39, "command count drifted: {names:?}");
92    }
93
94    #[test]
95    fn registry_uses_kebab_case_only() {
96        for name in all_registered_commands() {
97            assert!(
98                name.chars()
99                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
100                "non-kebab-case name: {name}"
101            );
102            assert!(!name.starts_with('-'), "leading dash: {name}");
103            assert!(!name.ends_with('-'), "trailing dash: {name}");
104        }
105    }
106
107    #[test]
108    fn registry_has_no_duplicates() {
109        let names = all_registered_commands();
110        let mut sorted = names.clone();
111        sorted.sort_unstable();
112        sorted.dedup();
113        assert_eq!(
114            names.len(),
115            sorted.len(),
116            "duplicate command names: {names:?}"
117        );
118    }
119
120    #[test]
121    fn registry_excludes_verify_user_password_by_design() {
122        let names = all_registered_commands();
123        assert!(
124            !names.contains(&"verify-user-password"),
125            "verify-user-password took an arbitrary email and password and \
126             returned whether they matched — an unthrottled credential oracle \
127             against every account, callable by any verified user over the eval \
128             channel. It had no consumer. Do not re-add it without binding it to \
129             the calling user."
130        );
131    }
132
133    #[test]
134    fn registry_excludes_add_ssh_key_by_design() {
135        let names = all_registered_commands();
136        assert!(
137            !names.contains(&"add-ssh-key"),
138            "add-ssh-key must not be exposed via the eval channel; pubkey upload uses the dedicated ssh-copy-id exec path"
139        );
140    }
141
142    #[test]
143    fn each_domain_has_at_least_one_command() {
144        assert!(!account::REGISTERED_COMMANDS.is_empty());
145        assert!(!commodity::REGISTERED_COMMANDS.is_empty());
146        assert!(!config::REGISTERED_COMMANDS.is_empty());
147        assert!(!report::REGISTERED_COMMANDS.is_empty());
148        assert!(!split::REGISTERED_COMMANDS.is_empty());
149        assert!(!ssh_key::REGISTERED_COMMANDS.is_empty());
150        assert!(!transaction::REGISTERED_COMMANDS.is_empty());
151    }
152
153    #[test]
154    fn link_succeeds_against_session_data_linker() {
155        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
156            .expect("engine");
157        let mut linker: wasmtime::Linker<crate::session::SessionData> =
158            wasmtime::Linker::new(&engine);
159        link(&mut linker).expect("link must succeed");
160    }
161
162    #[test]
163    fn each_empty_server_domain_register_succeeds_in_isolation() {
164        // Domains that haven't shipped real natives yet keep their `register`
165        // fns generic over T so empty composition is cheap to verify. As
166        // domains gain real natives (each one becoming SessionData-specific),
167        // they migrate out of this loop into dedicated tests.
168        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
169            .expect("engine");
170        // All domains now have SessionData-specific natives. Nothing left
171        // in the generic-T empty-domain loop, but keep the test placeholder
172        // so the next stub-domain (if any) has an obvious home to slot
173        // back into.
174        let _engine = engine;
175    }
176
177    #[test]
178    fn account_register_succeeds_against_session_data() {
179        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
180            .expect("engine");
181        let mut linker: wasmtime::Linker<crate::session::SessionData> =
182            wasmtime::Linker::new(&engine);
183        account::register(&mut linker).expect("account::register must succeed");
184    }
185
186    #[test]
187    fn commodity_register_succeeds_against_session_data() {
188        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
189            .expect("engine");
190        let mut linker: wasmtime::Linker<crate::session::SessionData> =
191            wasmtime::Linker::new(&engine);
192        commodity::register(&mut linker).expect("commodity::register must succeed");
193    }
194
195    #[test]
196    fn transaction_register_succeeds_against_session_data() {
197        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
198            .expect("engine");
199        let mut linker: wasmtime::Linker<crate::session::SessionData> =
200            wasmtime::Linker::new(&engine);
201        transaction::register(&mut linker).expect("transaction::register must succeed");
202    }
203
204    #[test]
205    fn report_register_succeeds_against_session_data() {
206        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
207            .expect("engine");
208        let mut linker: wasmtime::Linker<crate::session::SessionData> =
209            wasmtime::Linker::new(&engine);
210        report::register(&mut linker).expect("report::register must succeed");
211    }
212
213    #[test]
214    fn split_register_succeeds_against_session_data() {
215        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
216            .expect("engine");
217        let mut linker: wasmtime::Linker<crate::session::SessionData> =
218            wasmtime::Linker::new(&engine);
219        split::register(&mut linker).expect("split::register must succeed");
220    }
221
222    #[test]
223    fn ssh_key_register_succeeds_against_session_data() {
224        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
225            .expect("engine");
226        let mut linker: wasmtime::Linker<crate::session::SessionData> =
227            wasmtime::Linker::new(&engine);
228        ssh_key::register(&mut linker).expect("ssh_key::register must succeed");
229    }
230
231    #[test]
232    fn config_register_succeeds_against_session_data() {
233        // config now ships server-backed natives so its register fn is
234        // SessionData-specific. Verified in isolation alongside the empty
235        // domains. Async engine required because the host fns are
236        // registered via func_wrap_async.
237        let engine = crate::wasm::build_engine(crate::wasm::EngineOpts::baseline().with_fuel())
238            .expect("engine");
239        let mut linker: wasmtime::Linker<crate::session::SessionData> =
240            wasmtime::Linker::new(&engine);
241        config::register(&mut linker).expect("config::register must succeed");
242    }
243}