Skip to main content

rpc/natives/
config.rs

1//! Config-domain natives. Wraps `server::command::{GetConfig, GetVersion,
2//! GetBuildDate, SetConfig}`.
3//!
4//! `get-version` and `get-build-date` are the first server-command bindings
5//! the rpc layer ships. Both return a `CmdResult::String(...)` from a body
6//! that just reads `env!`-baked constants — no DB, no user_id, no real async
7//! work — so they exercise the marshalling shape without yet needing a
8//! tokio runtime or pool. The remaining commands wait until DB-touching
9//! infrastructure (ScriptCtx::pool, sqlx::test fixtures) lands.
10//!
11//! `select-column` is deliberately NOT exposed here: it runs caller-supplied
12//! `field`/`table` against the global admin connection, so a script-callable
13//! native would let any authenticated session read outside its user boundary.
14//! It stays a CLI-only escape hatch (`sql selcol`, direct dispatch).
15
16use base64::Engine;
17use base64::engine::general_purpose::STANDARD as BASE64;
18use scripting::runtime::{alloc_string_ref, read_string_arg};
19use server::command::{
20    CmdError, CmdResult,
21    config::{GetBuildDate, GetConfig, GetVersion, SetConfig},
22};
23use wasmtime::{ArrayRef, Caller, Linker, Rooted};
24
25use crate::session::SessionData;
26
27pub const REGISTERED_COMMANDS: &[&str] =
28    &["get-config", "get-version", "get-build-date", "set-config"];
29
30pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
31    linker.func_wrap_async(
32        "nomi",
33        "config_get_version",
34        |mut caller: Caller<'_, SessionData>,
35         ()|
36         -> Box<
37            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
38        > {
39            Box::new(async move {
40                let bytes = command_string("get-version", GetVersion::new().run().await)?;
41                Ok(Some(alloc_string_ref(&mut caller, bytes.as_bytes())?))
42            })
43        },
44    )?;
45    linker.func_wrap_async(
46        "nomi",
47        "config_get_build_date",
48        |mut caller: Caller<'_, SessionData>,
49         ()|
50         -> Box<
51            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
52        > {
53            Box::new(async move {
54                let bytes = command_string("get-build-date", GetBuildDate::new().run().await)?;
55                Ok(Some(alloc_string_ref(&mut caller, bytes.as_bytes())?))
56            })
57        },
58    )?;
59    linker.func_wrap_async(
60        "nomi",
61        "config_get_config",
62        |mut caller: Caller<'_, SessionData>,
63         (name_arg,): (Option<Rooted<ArrayRef>>,)|
64         -> Box<
65            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
66        > {
67            Box::new(async move {
68                let user_id = caller.data().ctx().user_id;
69                let name = read_string_arg(&mut caller, name_arg)?;
70                let formatted = run_get_config(user_id, name).await?;
71                Ok(Some(alloc_string_ref(&mut caller, formatted.as_bytes())?))
72            })
73        },
74    )?;
75    linker.func_wrap_async(
76        "nomi",
77        "config_set_config",
78        |mut caller: Caller<'_, SessionData>,
79         (name_arg, value_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
80         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
81            Box::new(async move {
82                let user_id = caller.data().ctx().user_id;
83                let name = read_string_arg(&mut caller, name_arg)?;
84                let value = read_string_arg(&mut caller, value_arg)?;
85                run_set_config(user_id, name, value).await
86            })
87        },
88    )?;
89    Ok(())
90}
91
92async fn run_set_config(
93    user_id: uuid::Uuid,
94    name_arg: Option<String>,
95    value_arg: Option<String>,
96) -> wasmtime::Result<i32> {
97    let name = match name_arg {
98        Some(s) if !s.is_empty() => s,
99        _ => {
100            return Err(wasmtime::Error::msg(
101                "set-config: missing or empty :name arg",
102            ));
103        }
104    };
105    let value = match value_arg {
106        Some(s) => s,
107        _ => return Err(wasmtime::Error::msg("set-config: missing :value arg")),
108    };
109    SetConfig::new()
110        .user_id(user_id)
111        .name(name)
112        .value(value)
113        .run()
114        .await
115        .map(|_| 1)
116        .map_err(|err| wasmtime::Error::msg(format!("set-config: {err}")))
117}
118
119/// Returns `(:config-value <val>)` on success, or propagates a
120/// `wasmtime::Error` on any validation/command failure. The rpc envelope
121/// layer turns traps into `(:id N :error ...)` responses.
122async fn run_get_config(user_id: uuid::Uuid, name_arg: Option<String>) -> wasmtime::Result<String> {
123    let name = match name_arg {
124        Some(s) if !s.is_empty() => s,
125        _ => {
126            return Err(wasmtime::Error::msg(
127                "get-config: missing or empty :name arg",
128            ));
129        }
130    };
131    match GetConfig::new().user_id(user_id).name(name).run().await {
132        Ok(Some(CmdResult::String(s))) => Ok(format!("(:config-value {})", quote_string(&s))),
133        Ok(Some(CmdResult::Data(bytes))) => {
134            Ok(format!("(:config-value #\"{}\")", BASE64.encode(&bytes)))
135        }
136        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
137            "get-config: expected String/Data, got {other:?}"
138        ))),
139        Ok(None) => Ok("(:config-value nil)".to_string()),
140        Err(err) => Err(wasmtime::Error::msg(format!("get-config: {err}"))),
141    }
142}
143
144fn quote_string(s: &str) -> String {
145    let mut q = String::with_capacity(s.len() + 2);
146    q.push('"');
147    for ch in s.chars() {
148        match ch {
149            '"' => q.push_str("\\\""),
150            '\\' => q.push_str("\\\\"),
151            other => q.push(other),
152        }
153    }
154    q.push('"');
155    q
156}
157
158/// Unwraps a `CmdResult::String(_)` result and surfaces every other shape
159/// (Data/None/Err) as a `wasmtime::Error`. The rpc envelope layer catches
160/// the trap and renders the `:error` form; callers compose the natives
161/// expecting a typed `StringRef`, so anything else is a hard failure at
162/// the point of use.
163fn command_string(
164    name: &str,
165    result: Result<Option<CmdResult>, CmdError>,
166) -> wasmtime::Result<String> {
167    match result {
168        Ok(Some(CmdResult::String(s))) => Ok(s),
169        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
170            "{name}: expected String result, got {other:?}"
171        ))),
172        Ok(None) => Err(wasmtime::Error::msg(format!(
173            "{name}: command returned no result"
174        ))),
175        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    // Arg-validation runs before any DB/user lookup, so a placeholder user id
184    // is fine here.
185    const TEST_USER: uuid::Uuid = uuid::Uuid::nil();
186
187    #[tokio::test]
188    async fn run_get_config_no_arg_returns_error() {
189        let err = run_get_config(TEST_USER, None).await.unwrap_err();
190        assert!(err.to_string().contains("missing or empty"));
191    }
192
193    #[tokio::test]
194    async fn run_get_config_empty_arg_returns_error() {
195        let err = run_get_config(TEST_USER, Some(String::new()))
196            .await
197            .unwrap_err();
198        assert!(err.to_string().contains("missing or empty"));
199    }
200
201    #[tokio::test]
202    async fn run_set_config_no_name_emits_error() {
203        let err = run_set_config(TEST_USER, None, Some("v".into()))
204            .await
205            .unwrap_err();
206        assert!(err.to_string().contains(":name"));
207    }
208
209    #[tokio::test]
210    async fn run_set_config_no_value_emits_error() {
211        let err = run_set_config(TEST_USER, Some("k".into()), None)
212            .await
213            .unwrap_err();
214        assert!(err.to_string().contains(":value"));
215    }
216
217    #[test]
218    fn quote_string_escapes_quotes_and_backslashes() {
219        assert_eq!(quote_string("plain"), "\"plain\"");
220        assert_eq!(quote_string("a\"b"), "\"a\\\"b\"");
221        assert_eq!(quote_string("c\\d"), "\"c\\\\d\"");
222    }
223}