1#[cfg(test)]
11use base64::Engine;
12#[cfg(test)]
13use base64::engine::general_purpose::STANDARD as BASE64;
14use scripting::runtime::{
15 alloc_entity_via_export, alloc_pair_chain, alloc_string_ref, read_string_arg,
16};
17#[cfg(test)]
18use server::command::ssh_key::SshKeyRecord;
19use server::command::ssh_key::{ListSshKeys, LookupUserBySshKey, RemoveSshKey, UserHasSshKey};
20use server::command::{CmdError, CmdResult};
21use uuid::Uuid;
22use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
23
24use crate::session::SessionData;
25
26pub const REGISTERED_COMMANDS: &[&str] = &[
27 "list-ssh-keys",
28 "remove-ssh-key",
29 "user-has-ssh-key",
30 "lookup-user-by-ssh-key",
31];
32
33pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
34 linker.func_wrap_async(
35 "nomi",
36 "ssh_key_list_ssh_keys",
37 |mut caller: Caller<'_, SessionData>,
38 ()|
39 -> Box<
40 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
41 > {
42 Box::new(async move {
43 let user_id = caller.data().ctx().user_id;
44 let result = ListSshKeys::new().user_id(user_id).run().await;
45 let entries = list_ssh_key_entries("list-ssh-keys", result)?;
46 alloc_ssh_key_chain(&mut caller, entries).await
47 })
48 },
49 )?;
50 linker.func_wrap_async(
51 "nomi",
52 "ssh_key_user_has_ssh_key",
53 |caller: Caller<'_, SessionData>,
54 ()|
55 -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
56 Box::new(async move {
57 let user_id = caller.data().ctx().user_id;
58 let result = UserHasSshKey::new().user_id(user_id).run().await;
59 bool_from_command_result("user-has-ssh-key", result)
60 })
61 },
62 )?;
63 linker.func_wrap_async(
64 "nomi",
65 "ssh_key_lookup_user_by_ssh_key",
66 |mut caller: Caller<'_, SessionData>,
67 (fp_arg,): (Option<Rooted<ArrayRef>>,)|
68 -> Box<
69 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
70 > {
71 Box::new(async move {
72 let fp = read_string_arg(&mut caller, fp_arg)?;
73 match run_lookup_user_by_ssh_key(fp).await? {
74 Some(id) => Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?)),
75 None => Ok(None),
76 }
77 })
78 },
79 )?;
80 linker.func_wrap_async(
81 "nomi",
82 "ssh_key_remove_ssh_key",
83 |mut caller: Caller<'_, SessionData>,
84 (fp_arg,): (Option<Rooted<ArrayRef>>,)|
85 -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
86 Box::new(async move {
87 let user_id = caller.data().ctx().user_id;
88 let fp = read_string_arg(&mut caller, fp_arg)?;
89 run_remove_ssh_key(user_id, fp).await
90 })
91 },
92 )?;
93 Ok(())
94}
95
96async fn run_remove_ssh_key(user_id: Uuid, fp_arg: Option<String>) -> wasmtime::Result<i32> {
102 let fingerprint = fp_arg
103 .filter(|s| !s.is_empty())
104 .ok_or_else(|| wasmtime::Error::msg("remove-ssh-key: missing or empty :fingerprint arg"))?;
105 let result = RemoveSshKey::new()
106 .user_id(user_id)
107 .fingerprint(fingerprint)
108 .run()
109 .await;
110 bool_from_command_result("remove-ssh-key", result)
111}
112
113async fn run_lookup_user_by_ssh_key(fp_arg: Option<String>) -> wasmtime::Result<Option<String>> {
119 let fingerprint = fp_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
120 wasmtime::Error::msg("lookup-user-by-ssh-key: missing or empty :fingerprint arg")
121 })?;
122 match LookupUserBySshKey::new()
123 .fingerprint(fingerprint)
124 .run()
125 .await
126 {
127 Ok(Some(CmdResult::Uuid(id))) => Ok(Some(id.to_string())),
128 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
129 "lookup-user-by-ssh-key: expected Uuid, got {other:?}"
130 ))),
131 Ok(None) => Ok(None),
132 Err(err) => Err(wasmtime::Error::msg(format!(
133 "lookup-user-by-ssh-key: {err}"
134 ))),
135 }
136}
137
138fn bool_from_command_result(
142 name: &str,
143 result: Result<Option<CmdResult>, CmdError>,
144) -> wasmtime::Result<i32> {
145 match result {
146 Ok(Some(CmdResult::Bool(b))) => Ok(i32::from(b)),
147 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
148 "{name}: expected Bool, got {other:?}"
149 ))),
150 Ok(None) => Ok(0),
151 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
152 }
153}
154
155fn list_ssh_key_entries(
161 name: &str,
162 result: Result<Option<CmdResult>, CmdError>,
163) -> wasmtime::Result<Vec<(String, String, String)>> {
164 match result {
165 Ok(Some(CmdResult::SshKeys(keys))) => Ok(keys
166 .into_iter()
167 .map(|k| (k.id.to_string(), k.fingerprint, k.annotation))
168 .collect()),
169 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
170 "{name}: expected SshKeys, got {other:?}"
171 ))),
172 Ok(None) => Ok(Vec::new()),
173 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
174 }
175}
176
177async fn alloc_ssh_key_entity(
178 caller: &mut Caller<'_, SessionData>,
179 id: &str,
180 fingerprint: &str,
181 annotation: &str,
182) -> wasmtime::Result<Rooted<StructRef>> {
183 let id_ref = alloc_string_ref(caller, id.as_bytes())?;
184 let fp_ref = alloc_string_ref(caller, fingerprint.as_bytes())?;
185 let name_ref = alloc_string_ref(caller, annotation.as_bytes())?;
186 let args = [
187 Val::AnyRef(Some(id_ref.to_anyref())),
188 Val::AnyRef(Some(fp_ref.to_anyref())),
189 Val::AnyRef(Some(name_ref.to_anyref())),
190 ];
191 alloc_entity_via_export(caller, "alloc_ssh_key", &args).await
192}
193
194async fn alloc_ssh_key_chain(
195 caller: &mut Caller<'_, SessionData>,
196 entries: Vec<(String, String, String)>,
197) -> wasmtime::Result<Option<Rooted<StructRef>>> {
198 let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
199 for (id, fingerprint, annotation) in entries {
200 let entity_ref = alloc_ssh_key_entity(caller, &id, &fingerprint, &annotation).await?;
201 anyrefs.push(entity_ref.to_anyref());
202 }
203 alloc_pair_chain(caller, anyrefs).await
204}
205
206#[cfg(test)]
207fn format_ssh_keys(keys: &[SshKeyRecord]) -> String {
208 let mut out = String::from("(:ssh-keys (");
213 for (idx, key) in keys.iter().enumerate() {
214 if idx > 0 {
215 out.push(' ');
216 }
217 out.push_str(&format!(
218 "(:id \"{}\" :key-type {} :fingerprint {} :annotation {} :created-at \"{}\" :last-used-at {} :blob #\"{}\")",
219 key.id,
220 quote_string(&key.key_type),
221 quote_string(&key.fingerprint),
222 quote_string(&key.annotation),
223 key.created_at.to_rfc3339(),
224 match key.last_used_at {
225 Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
226 None => "nil".to_string(),
227 },
228 BASE64.encode(&key.key_blob),
229 ));
230 }
231 out.push_str("))");
232 out
233}
234
235#[cfg(test)]
236fn quote_string(s: &str) -> String {
237 let mut q = String::with_capacity(s.len() + 2);
238 q.push('"');
239 for ch in s.chars() {
240 match ch {
241 '"' => q.push_str("\\\""),
242 '\\' => q.push_str("\\\\"),
243 other => q.push(other),
244 }
245 }
246 q.push('"');
247 q
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use chrono::TimeZone;
254 use uuid::Uuid;
255
256 fn record(annotation: &str, last_used: bool) -> SshKeyRecord {
257 let created = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
258 SshKeyRecord {
259 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
260 user_id: Uuid::nil(),
261 key_type: "ssh-ed25519".into(),
262 key_blob: vec![0xab, 0xcd, 0xef],
263 fingerprint: "SHA256:abc".into(),
264 annotation: annotation.into(),
265 created_at: created,
266 last_used_at: last_used
267 .then(|| chrono::Utc.with_ymd_and_hms(2026, 5, 5, 9, 30, 0).unwrap()),
268 }
269 }
270
271 #[test]
272 fn format_empty_list() {
273 assert_eq!(format_ssh_keys(&[]), "(:ssh-keys ())");
274 }
275
276 #[test]
277 fn format_single_key_with_last_used() {
278 let out = format_ssh_keys(&[record("laptop", true)]);
279 assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
280 assert!(out.contains(":key-type \"ssh-ed25519\""));
281 assert!(out.contains(":fingerprint \"SHA256:abc\""));
282 assert!(out.contains(":annotation \"laptop\""));
283 assert!(out.contains(":created-at \"2026-05-01T12:00:00+00:00\""));
284 assert!(out.contains(":last-used-at \"2026-05-05T09:30:00+00:00\""));
285 assert!(out.contains(":blob #\"q83v\""));
286 }
287
288 #[test]
289 fn format_unused_key_emits_nil_for_last_used() {
290 let out = format_ssh_keys(&[record("", false)]);
291 assert!(out.contains(":last-used-at nil"));
292 assert!(out.contains(":annotation \"\""));
293 }
294
295 #[test]
296 fn bool_from_command_result_maps_variants() {
297 assert_eq!(
298 bool_from_command_result("x", Ok(Some(CmdResult::Bool(true)))).unwrap(),
299 1
300 );
301 assert_eq!(
302 bool_from_command_result("x", Ok(Some(CmdResult::Bool(false)))).unwrap(),
303 0
304 );
305 assert_eq!(bool_from_command_result("x", Ok(None)).unwrap(), 0);
306 assert!(bool_from_command_result("x", Err(CmdError::Args("oops".into()))).is_err());
307 }
308
309 #[tokio::test]
310 async fn run_lookup_user_by_ssh_key_no_arg_emits_error() {
311 let err = run_lookup_user_by_ssh_key(None).await.unwrap_err();
312 assert!(err.to_string().contains("missing or empty"));
313 }
314
315 #[tokio::test]
316 async fn run_lookup_user_by_ssh_key_empty_arg_emits_error() {
317 let err = run_lookup_user_by_ssh_key(Some(String::new()))
318 .await
319 .unwrap_err();
320 assert!(err.to_string().contains("missing or empty"));
321 }
322
323 #[tokio::test]
324 async fn run_remove_ssh_key_no_arg_emits_error() {
325 let err = run_remove_ssh_key(Uuid::nil(), None).await.unwrap_err();
326 assert!(err.to_string().contains("missing or empty"));
327 }
328
329 #[tokio::test]
330 async fn run_remove_ssh_key_empty_arg_emits_error() {
331 let err = run_remove_ssh_key(Uuid::nil(), Some(String::new()))
332 .await
333 .unwrap_err();
334 assert!(err.to_string().contains("missing or empty"));
335 }
336
337 #[test]
338 fn format_quotes_embedded_specials_in_annotation() {
339 let key = SshKeyRecord {
340 annotation: r#"weird\"name"#.into(),
341 ..record("", false)
342 };
343 let out = format_ssh_keys(&[key]);
344 assert!(out.contains(r#":annotation "weird\\\"name""#), "got: {out}");
345 }
346}