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
155struct SshKeyWire {
158 id: String,
159 fingerprint: String,
160 annotation: String,
161 key_type: String,
162 created_at: String,
163 last_used_at: String,
164}
165
166fn list_ssh_key_entries(
167 name: &str,
168 result: Result<Option<CmdResult>, CmdError>,
169) -> wasmtime::Result<Vec<SshKeyWire>> {
170 match result {
171 Ok(Some(CmdResult::SshKeys(keys))) => Ok(keys
172 .into_iter()
173 .map(|k| SshKeyWire {
174 id: k.id.to_string(),
175 fingerprint: k.fingerprint,
176 annotation: k.annotation,
177 key_type: k.key_type,
178 created_at: k.created_at.to_rfc3339(),
179 last_used_at: k.last_used_at.map_or_else(String::new, |t| t.to_rfc3339()),
180 })
181 .collect()),
182 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
183 "{name}: expected SshKeys, got {other:?}"
184 ))),
185 Ok(None) => Ok(Vec::new()),
186 Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
187 }
188}
189
190async fn alloc_ssh_key_entity(
191 caller: &mut Caller<'_, SessionData>,
192 key: &SshKeyWire,
193) -> wasmtime::Result<Rooted<StructRef>> {
194 let id_ref = alloc_string_ref(caller, key.id.as_bytes())?;
195 let fp_ref = alloc_string_ref(caller, key.fingerprint.as_bytes())?;
196 let name_ref = alloc_string_ref(caller, key.annotation.as_bytes())?;
197 let type_ref = alloc_string_ref(caller, key.key_type.as_bytes())?;
198 let created_ref = alloc_string_ref(caller, key.created_at.as_bytes())?;
199 let last_used_ref = alloc_string_ref(caller, key.last_used_at.as_bytes())?;
200 let args = [
201 Val::AnyRef(Some(id_ref.to_anyref())),
202 Val::AnyRef(Some(fp_ref.to_anyref())),
203 Val::AnyRef(Some(name_ref.to_anyref())),
204 Val::AnyRef(Some(type_ref.to_anyref())),
205 Val::AnyRef(Some(created_ref.to_anyref())),
206 Val::AnyRef(Some(last_used_ref.to_anyref())),
207 ];
208 alloc_entity_via_export(caller, "alloc_ssh_key", &args).await
209}
210
211async fn alloc_ssh_key_chain(
212 caller: &mut Caller<'_, SessionData>,
213 entries: Vec<SshKeyWire>,
214) -> wasmtime::Result<Option<Rooted<StructRef>>> {
215 let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
216 for entry in &entries {
217 let entity_ref = alloc_ssh_key_entity(caller, entry).await?;
218 anyrefs.push(entity_ref.to_anyref());
219 }
220 alloc_pair_chain(caller, anyrefs).await
221}
222
223#[cfg(test)]
224fn format_ssh_keys(keys: &[SshKeyRecord]) -> String {
225 let mut out = String::from("(:ssh-keys (");
230 for (idx, key) in keys.iter().enumerate() {
231 if idx > 0 {
232 out.push(' ');
233 }
234 out.push_str(&format!(
235 "(:id \"{}\" :key-type {} :fingerprint {} :annotation {} :created-at \"{}\" :last-used-at {} :blob #\"{}\")",
236 key.id,
237 quote_string(&key.key_type),
238 quote_string(&key.fingerprint),
239 quote_string(&key.annotation),
240 key.created_at.to_rfc3339(),
241 match key.last_used_at {
242 Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
243 None => "nil".to_string(),
244 },
245 BASE64.encode(&key.key_blob),
246 ));
247 }
248 out.push_str("))");
249 out
250}
251
252#[cfg(test)]
253fn quote_string(s: &str) -> String {
254 let mut q = String::with_capacity(s.len() + 2);
255 q.push('"');
256 for ch in s.chars() {
257 match ch {
258 '"' => q.push_str("\\\""),
259 '\\' => q.push_str("\\\\"),
260 other => q.push(other),
261 }
262 }
263 q.push('"');
264 q
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use chrono::TimeZone;
271 use uuid::Uuid;
272
273 fn record(annotation: &str, last_used: bool) -> SshKeyRecord {
274 let created = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
275 SshKeyRecord {
276 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
277 user_id: Uuid::nil(),
278 key_type: "ssh-ed25519".into(),
279 key_blob: vec![0xab, 0xcd, 0xef],
280 fingerprint: "SHA256:abc".into(),
281 annotation: annotation.into(),
282 created_at: created,
283 last_used_at: last_used
284 .then(|| chrono::Utc.with_ymd_and_hms(2026, 5, 5, 9, 30, 0).unwrap()),
285 }
286 }
287
288 #[test]
289 fn format_empty_list() {
290 assert_eq!(format_ssh_keys(&[]), "(:ssh-keys ())");
291 }
292
293 #[test]
294 fn format_single_key_with_last_used() {
295 let out = format_ssh_keys(&[record("laptop", true)]);
296 assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
297 assert!(out.contains(":key-type \"ssh-ed25519\""));
298 assert!(out.contains(":fingerprint \"SHA256:abc\""));
299 assert!(out.contains(":annotation \"laptop\""));
300 assert!(out.contains(":created-at \"2026-05-01T12:00:00+00:00\""));
301 assert!(out.contains(":last-used-at \"2026-05-05T09:30:00+00:00\""));
302 assert!(out.contains(":blob #\"q83v\""));
303 }
304
305 #[test]
306 fn format_unused_key_emits_nil_for_last_used() {
307 let out = format_ssh_keys(&[record("", false)]);
308 assert!(out.contains(":last-used-at nil"));
309 assert!(out.contains(":annotation \"\""));
310 }
311
312 #[test]
313 fn bool_from_command_result_maps_variants() {
314 assert_eq!(
315 bool_from_command_result("x", Ok(Some(CmdResult::Bool(true)))).unwrap(),
316 1
317 );
318 assert_eq!(
319 bool_from_command_result("x", Ok(Some(CmdResult::Bool(false)))).unwrap(),
320 0
321 );
322 assert_eq!(bool_from_command_result("x", Ok(None)).unwrap(), 0);
323 assert!(bool_from_command_result("x", Err(CmdError::Args("oops".into()))).is_err());
324 }
325
326 #[tokio::test]
327 async fn run_lookup_user_by_ssh_key_no_arg_emits_error() {
328 let err = run_lookup_user_by_ssh_key(None).await.unwrap_err();
329 assert!(err.to_string().contains("missing or empty"));
330 }
331
332 #[tokio::test]
333 async fn run_lookup_user_by_ssh_key_empty_arg_emits_error() {
334 let err = run_lookup_user_by_ssh_key(Some(String::new()))
335 .await
336 .unwrap_err();
337 assert!(err.to_string().contains("missing or empty"));
338 }
339
340 #[tokio::test]
341 async fn run_remove_ssh_key_no_arg_emits_error() {
342 let err = run_remove_ssh_key(Uuid::nil(), None).await.unwrap_err();
343 assert!(err.to_string().contains("missing or empty"));
344 }
345
346 #[tokio::test]
347 async fn run_remove_ssh_key_empty_arg_emits_error() {
348 let err = run_remove_ssh_key(Uuid::nil(), Some(String::new()))
349 .await
350 .unwrap_err();
351 assert!(err.to_string().contains("missing or empty"));
352 }
353
354 #[test]
355 fn format_quotes_embedded_specials_in_annotation() {
356 let key = SshKeyRecord {
357 annotation: r#"weird\"name"#.into(),
358 ..record("", false)
359 };
360 let out = format_ssh_keys(&[key]);
361 assert!(out.contains(r#":annotation "weird\\\"name""#), "got: {out}");
362 }
363}