1mod server;
2mod transport;
3
4use rpi_plugin_sdk::{
5 register_entrypoint, EventTag, FreeStringFn, PluginApi, RuntimeActionFn, RuntimeActionId,
6 StablePluginEvent, StableToolSchema, StbString, StbStringRef, StepHandle, StepResult,
7 ToolPartialCb,
8};
9use serde_json::{json, Value};
10use server::{generate_token, launch_args, run_server, TcpServerHandle};
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
13use std::sync::mpsc;
14use std::sync::{Arc, Mutex, OnceLock};
15use std::thread;
16use std::time::{Duration, Instant};
17use transport::{ManagedConnection, TcpTransport};
18
19const DEFAULT_TIMEOUT: u64 = 30;
20
21static SERVERS: OnceLock<Mutex<HashMap<String, Arc<TcpServerHandle>>>> = OnceLock::new();
24
25fn servers() -> &'static Mutex<HashMap<String, Arc<TcpServerHandle>>> {
26 SERVERS.get_or_init(|| Mutex::new(HashMap::new()))
27}
28
29static RUNTIME_ACTION: OnceLock<RuntimeActionFn> = OnceLock::new();
35static RUNTIME_USER_DATA: AtomicPtr<std::ffi::c_void> = AtomicPtr::new(std::ptr::null_mut());
36static HOST_FREE_STRING: OnceLock<FreeStringFn> = OnceLock::new();
38
39fn get_cli_flag(name: &str) -> Option<Value> {
41 let runtime_action = *RUNTIME_ACTION.get()?;
42 let user_data = RUNTIME_USER_DATA.load(Ordering::Acquire);
43 if user_data.is_null() {
44 return None;
45 }
46
47 let args = json!({"name": name});
48 let args_str = args.to_string();
49 let args_ref = StbStringRef::from_str(&args_str);
50 let mut out = StbString::empty();
51
52 let rc = runtime_action(
53 RuntimeActionId::GetCliFlag as u32,
54 args_ref,
55 &mut out,
56 user_data,
57 );
58
59 let result_str = if rc == 0 {
62 Some(out.to_string_lossy())
63 } else {
64 None
65 };
66 if let Some(free) = HOST_FREE_STRING.get() {
67 free(out);
68 }
69
70 let result: Value = serde_json::from_str(&result_str?).ok()?;
71 result.get("value").cloned()
72}
73
74struct Drive {
75 receiver: mpsc::Receiver<Result<String, String>>,
76 cancelled: Arc<AtomicBool>,
77 done: bool,
78}
79
80fn string_param(params: &Value, key: &str) -> Result<String, String> {
83 params
84 .get(key)
85 .and_then(Value::as_str)
86 .map(str::to_owned)
87 .filter(|s| !s.trim().is_empty())
88 .ok_or_else(|| format!("{key} is required"))
89}
90
91fn optional_string(params: &Value, key: &str) -> Result<Option<String>, String> {
92 match params.get(key) {
93 None | Some(Value::Null) => Ok(None),
94 Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
95 Some(Value::String(_)) => Err(format!("{key} must not be empty")),
96 Some(_) => Err(format!("{key} must be a string")),
97 }
98}
99
100fn timeout_param(params: &Value) -> Result<Duration, String> {
101 match params.get("timeoutSeconds") {
102 None | Some(Value::Null) => Ok(Duration::from_secs(DEFAULT_TIMEOUT)),
103 Some(v) => v
104 .as_u64()
105 .filter(|v| (1..=300).contains(v))
106 .map(Duration::from_secs)
107 .ok_or_else(|| "timeoutSeconds must be an integer from 1 to 300".into()),
108 }
109}
110
111fn do_start(params: &Value) -> Result<String, String> {
114 let bind_addr = optional_string(params, "bind")?.unwrap_or_else(|| "127.0.0.1".into());
115 let port: u16 = match params.get("port") {
116 Some(Value::Number(n)) => n
117 .as_u64()
118 .and_then(|n| u16::try_from(n).ok())
119 .ok_or("port must be an integer 0–65535")?,
120 None => 9800,
121 Some(_) => return Err("port must be an integer".into()),
122 };
123
124 let (executable, args) = launch_args(params)?;
125 let no_token = params
126 .get("noToken")
127 .and_then(Value::as_bool)
128 .unwrap_or(false);
129 let token = if no_token {
130 String::new()
132 } else {
133 params
134 .get("token")
135 .and_then(Value::as_str)
136 .map(str::to_owned)
137 .filter(|t| !t.is_empty())
138 .unwrap_or_else(generate_token)
139 };
140
141 let exec_clone = executable.clone();
142 let args_clone = args.clone();
143 let token_clone = token.clone();
144 let bind_clone = bind_addr.clone();
145
146 let (ready_tx, ready_rx) = mpsc::channel();
147 thread::spawn(move || {
148 let runtime = match tokio::runtime::Builder::new_multi_thread()
149 .enable_all()
150 .worker_threads(2)
151 .build()
152 {
153 Ok(r) => r,
154 Err(e) => {
155 let _ = ready_tx.send(Err(format!("failed to create runtime: {e}")));
156 return;
157 }
158 };
159
160 let result = runtime.block_on(async {
161 let transport = Arc::new(TcpTransport::new());
162 run_server(
163 transport,
164 &bind_clone,
165 port,
166 token_clone,
167 exec_clone,
168 args_clone,
169 )
170 .await
171 });
172
173 match result {
174 Ok(server) => {
175 if let Ok(mut registry) = servers().lock() {
176 registry.insert(server.id.clone(), server.clone());
177 }
178 let _ = ready_tx.send(Ok(server));
179 }
180 Err(e) => {
181 let _ = ready_tx.send(Err(e));
182 }
183 }
184
185 runtime.block_on(async {
186 loop {
187 tokio::time::sleep(Duration::from_secs(3600)).await;
188 }
189 });
190 });
191
192 let server = match ready_rx.recv_timeout(Duration::from_secs(10)) {
193 Ok(Ok(s)) => s,
194 Ok(Err(e)) => return Err(e),
195 Err(_) => return Err("timed out waiting for server startup".into()),
196 };
197
198 Ok(json!({
199 "id": server.id,
200 "address": server.address,
201 "token": token,
202 "state": "running",
203 "protocol": "jsonl",
204 "transport": "tcp",
205 })
206 .to_string())
207}
208
209fn do_stop(id: &str) -> Result<String, String> {
210 let server = servers()
211 .lock()
212 .map_err(|_| "server registry poisoned")?
213 .remove(id)
214 .ok_or("RPC server not found")?;
215
216 let (tx, rx) = mpsc::channel();
217 let server_clone = Arc::clone(&server);
218 thread::spawn(move || {
219 let runtime = tokio::runtime::Builder::new_current_thread()
220 .enable_all()
221 .build()
222 .unwrap();
223 let result = runtime.block_on(server_clone.stop());
224 let _ = tx.send(result);
225 });
226
227 match rx.recv_timeout(Duration::from_secs(5)) {
228 Ok(Ok(())) => Ok(json!({"id": id, "state": "stopped"}).to_string()),
229 Ok(Err(e)) => Err(e),
230 Err(_) => Err("timed out waiting for server stop".into()),
231 }
232}
233
234async fn server_status(server: &Arc<TcpServerHandle>) -> Result<Value, String> {
235 let info = server.info().await;
236 Ok(json!({
237 "id": info.id,
238 "address": info.address,
239 "token": info.token,
240 "state": info.state,
241 "protocol": "jsonl",
242 "transport": "tcp",
243 "sessions": info.sessions,
244 }))
245}
246
247async fn do_status(id: &str) -> Result<String, String> {
248 let server = servers()
249 .lock()
250 .map_err(|_| "server registry poisoned")?
251 .get(id)
252 .cloned()
253 .ok_or("RPC server not found")?;
254 Ok(server_status(&server).await?.to_string())
255}
256
257async fn do_list() -> Result<String, String> {
258 let registry = servers().lock().map_err(|_| "server registry poisoned")?;
259 let mut list: Vec<Value> = Vec::new();
260 for server in registry.values() {
261 list.push(server_status(server).await?);
262 }
263 Ok(json!(list).to_string())
264}
265
266async fn server_tool(params: &Value) -> Result<String, String> {
267 match params
268 .get("action")
269 .and_then(Value::as_str)
270 .unwrap_or("start")
271 {
272 "start" => do_start(params),
273 "stop" => {
274 let id = string_param(params, "id")?;
275 do_stop(&id)
276 }
277 "status" => {
278 let id = string_param(params, "id")?;
279 do_status(&id).await
280 }
281 "list" => do_list().await,
282 other => Err(format!(
283 "action must be one of: start, stop, status, list (got {other})"
284 )),
285 }
286}
287
288fn stop_all_servers() {
289 let ids: Vec<String> = match servers().lock() {
290 Ok(registry) => registry.keys().cloned().collect(),
291 Err(_) => return,
292 };
293 for id in ids {
294 let _ = do_stop(&id);
295 }
296}
297
298extern "C" fn on_session_shutdown(event: StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
299 if event.tag == EventTag::SessionShutdown {
300 stop_all_servers();
301 }
302 0
303}
304
305fn flag_is_on(value: Option<&Value>) -> bool {
308 match value {
309 Some(Value::Bool(b)) => *b,
310 Some(Value::String(s)) => !s.is_empty() && !s.eq_ignore_ascii_case("false"),
311 _ => false,
312 }
313}
314
315extern "C" fn on_session_start(event: StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
317 if event.tag != EventTag::SessionStart {
318 return 0;
319 }
320
321 if !flag_is_on(get_cli_flag("server").as_ref()) {
325 return 0;
326 }
327
328 let port: u16 = match get_cli_flag("port") {
330 Some(Value::Number(n)) => n
331 .as_u64()
332 .and_then(|n| u16::try_from(n).ok())
333 .unwrap_or(9800),
334 Some(Value::String(s)) => s.parse().unwrap_or(9800),
335 _ => 9800,
336 };
337
338 let bind = match get_cli_flag("bind") {
340 Some(Value::String(s)) if !s.is_empty() => s,
341 _ => "127.0.0.1".to_string(),
342 };
343
344 eprintln!("[rpi-server] --server detected, starting TCP server on {bind}:{port}");
345
346 let token = match get_cli_flag("token") {
347 Some(Value::String(s)) if !s.is_empty() => Some(s),
348 _ => None,
349 };
350 let no_token = flag_is_on(get_cli_flag("no-token").as_ref());
351
352 let params = json!({
353 "bind": bind,
354 "port": port,
355 "token": token,
356 "noToken": no_token,
357 });
358
359 match do_start(¶ms) {
360 Ok(info) => eprintln!("[rpi-server] TCP server started: {info}"),
361 Err(e) => eprintln!("[rpi-server] failed to start: {e}"),
362 }
363
364 0
365}
366
367fn client_tool(params: &Value, cancelled: &AtomicBool) -> Result<String, String> {
370 let server_id = string_param(params, "serverId")?;
371 let method = string_param(params, "method")?;
372 let rpc_params = params.get("params").cloned().unwrap_or(json!({}));
373
374 let server = servers()
375 .lock()
376 .map_err(|_| "server registry poisoned")?
377 .get(&server_id)
378 .cloned()
379 .ok_or("RPC server not found")?;
380
381 if !server.is_running() {
382 return Err("RPC server is not running".into());
383 }
384
385 let wait = timeout_param(params)?;
386 let subscribe = params
387 .get("subscribe")
388 .and_then(Value::as_bool)
389 .unwrap_or(false);
390
391 let (tx, rx) = mpsc::channel();
392 let cancelled_clone = Arc::new(AtomicBool::new(cancelled.load(Ordering::Acquire)));
393 let cancelled_inner = Arc::clone(&cancelled_clone);
394
395 thread::spawn(move || {
396 let runtime = tokio::runtime::Builder::new_current_thread()
397 .enable_all()
398 .build()
399 .unwrap();
400
401 let result = runtime.block_on(async {
402 let transport = Arc::new(TcpTransport::new());
403 let managed = ManagedConnection::new(transport);
404
405 managed
407 .connect_with_retry(&server.address)
408 .await
409 .map_err(|e| format!("failed to connect: {e}"))?;
410
411 if !server.token.is_empty() {
413 let auth = json!({
414 "jsonrpc": "2.0",
415 "id": 0,
416 "method": "authenticate",
417 "params": {"token": server.token},
418 });
419 managed
420 .send(auth)
421 .await
422 .map_err(|e| format!("auth send failed: {e}"))?;
423 match tokio::time::timeout(Duration::from_secs(5), managed.recv()).await {
424 Ok(Ok(Some(response))) => {
425 if let Some(error) = response.get("error") {
426 return Err(format!("authentication failed: {error}"));
427 }
428 }
429 Ok(Ok(None)) => return Err("authentication failed: connection closed".into()),
430 Ok(Err(e)) => return Err(format!("authentication failed: {e}")),
431 Err(_) => return Err("authentication failed: timed out".into()),
432 }
433 }
434
435 let request = json!({
437 "jsonrpc": "2.0",
438 "method": method,
439 "params": rpc_params,
440 "id": 1,
441 });
442 managed
443 .send(request)
444 .await
445 .map_err(|e| format!("send failed: {e}"))?;
446
447 if subscribe {
448 let mut events = Vec::new();
450 let deadline = Instant::now() + wait;
451
452 loop {
453 if cancelled_inner.load(Ordering::Acquire) {
454 break;
455 }
456 if Instant::now() >= deadline {
457 break;
458 }
459
460 match tokio::time::timeout(Duration::from_millis(100), managed.recv()).await {
461 Ok(Ok(Some(event))) => {
462 let is_end =
464 event.get("type").and_then(Value::as_str) == Some("session_end");
465 events.push(event);
466 if is_end {
467 break;
468 }
469 }
470 Ok(Ok(None)) => break,
471 Ok(Err(e)) => return Err(format!("recv error: {e}")),
472 Err(_) => continue, }
474 }
475
476 Ok(json!({
477 "serverId": server_id,
478 "method": method,
479 "events": events,
480 "eventCount": events.len(),
481 })
482 .to_string())
483 } else {
484 let deadline = Instant::now() + wait;
486 loop {
487 if cancelled_inner.load(Ordering::Acquire) {
488 return Err("request cancelled".into());
489 }
490 if Instant::now() >= deadline {
491 return Err("request timed out".into());
492 }
493
494 match tokio::time::timeout(Duration::from_millis(100), managed.recv()).await {
495 Ok(Ok(Some(response))) => {
496 return Ok(json!({
497 "serverId": server_id,
498 "method": method,
499 "result": response,
500 })
501 .to_string());
502 }
503 Ok(Ok(None)) => return Err("connection closed".into()),
504 Ok(Err(e)) => return Err(format!("recv error: {e}")),
505 Err(_) => continue, }
507 }
508 }
509 });
510
511 let _ = tx.send(result);
512 });
513
514 let deadline = Instant::now() + wait;
515 loop {
516 if cancelled.load(Ordering::Acquire) {
517 return Err("request cancelled".into());
518 }
519 if Instant::now() >= deadline {
520 return Err("request timed out".into());
521 }
522 match rx.recv_timeout(Duration::from_millis(100)) {
523 Ok(result) => return result,
524 Err(mpsc::RecvTimeoutError::Timeout) => continue,
525 Err(mpsc::RecvTimeoutError::Disconnected) => {
526 return Err("worker stopped without result".into());
527 }
528 }
529 }
530}
531
532extern "C" fn execute_server(
535 tool_call_id: StbStringRef,
536 params: StbString,
537 free: Option<FreeStringFn>,
538) -> StepHandle {
539 let _ = tool_call_id;
540 let text = params.to_string_lossy();
541 params.free_with(free);
542 let cancelled = Arc::new(AtomicBool::new(false));
543 let (tx, rx) = mpsc::channel();
544 thread::spawn(move || {
545 let result = serde_json::from_str::<Value>(&text)
546 .map_err(|e| format!("invalid parameters: {e}"))
547 .and_then(|p| {
548 let runtime = tokio::runtime::Builder::new_current_thread()
549 .enable_all()
550 .build()
551 .unwrap();
552 runtime.block_on(async { server_tool(&p).await })
553 });
554 let _ = tx.send(result);
555 });
556 Box::into_raw(Box::new(Drive {
557 receiver: rx,
558 cancelled,
559 done: false,
560 })) as StepHandle
561}
562
563extern "C" fn execute_client(
564 tool_call_id: StbStringRef,
565 params: StbString,
566 free: Option<FreeStringFn>,
567) -> StepHandle {
568 let _ = tool_call_id;
569 let text = params.to_string_lossy();
570 params.free_with(free);
571 let cancelled = Arc::new(AtomicBool::new(false));
572 let cancelled_clone = Arc::clone(&cancelled);
573 let (tx, rx) = mpsc::channel();
574 thread::spawn(move || {
575 let result = serde_json::from_str::<Value>(&text)
576 .map_err(|e| format!("invalid parameters: {e}"))
577 .and_then(|p| client_tool(&p, &cancelled_clone));
578 let _ = tx.send(result);
579 });
580 Box::into_raw(Box::new(Drive {
581 receiver: rx,
582 cancelled,
583 done: false,
584 })) as StepHandle
585}
586
587extern "C" fn poll(
588 handle: StepHandle,
589 _: Option<ToolPartialCb>,
590 _: *mut std::ffi::c_void,
591) -> StepResult {
592 if handle.is_null() {
593 return StepResult::err(StbString::from_string("null handle".into()));
594 }
595 let drive = unsafe { &mut *(handle as *mut Drive) };
596 if drive.done {
597 return StepResult::err(StbString::from_string(
598 "handle polled after completion".into(),
599 ));
600 }
601 if drive.cancelled.load(Ordering::Acquire) {
602 drive.done = true;
603 return StepResult::err(StbString::from_string("request cancelled".into()));
604 }
605 match drive.receiver.try_recv() {
606 Ok(Ok(text)) => {
607 drive.done = true;
608 StepResult::done(StbString::from_string(
609 json!({"content": [{"type": "text", "text": text}]}).to_string(),
610 ))
611 }
612 Ok(Err(e)) => {
613 drive.done = true;
614 StepResult::err(StbString::from_string(e))
615 }
616 Err(mpsc::TryRecvError::Empty) => StepResult::pending(StbString::empty()),
617 Err(mpsc::TryRecvError::Disconnected) => {
618 drive.done = true;
619 StepResult::err(StbString::from_string(
620 "worker stopped without result".into(),
621 ))
622 }
623 }
624}
625
626extern "C" fn cancel(handle: StepHandle) {
627 if !handle.is_null() {
628 unsafe {
629 (&*(handle as *mut Drive))
630 .cancelled
631 .store(true, Ordering::Release);
632 }
633 }
634}
635
636extern "C" fn destroy(handle: StepHandle) {
637 if !handle.is_null() {
638 unsafe {
639 let drive = Box::from_raw(handle as *mut Drive);
640 drive.cancelled.store(true, Ordering::Release);
641 }
642 }
643}
644
645extern "C" fn free_string(s: StbString) {
646 if !s.is_empty() && !s.ptr.is_null() {
647 unsafe {
648 let bytes = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
649 let _ = Box::from_raw(bytes as *const [u8] as *mut [u8]);
650 }
651 }
652}
653
654const SERVER_PARAMETERS: &str = r#"{
657 "type":"object",
658 "properties":{
659 "action":{"type":"string","enum":["start","stop","status","list"]},
660 "id":{"type":"string"},
661 "bind":{"type":"string"},
662 "port":{"type":"integer","minimum":0,"maximum":65535},
663 "token":{"type":"string"},
664 "provider":{"type":"string"},
665 "model":{"type":"string"},
666 "baseUrl":{"type":"string"},
667 "systemPrompt":{"type":"string"},
668 "appendSystemPrompt":{"type":"array","items":{"type":"string"}},
669 "thinking":{"type":"string","enum":["off","minimal","low","medium","high","xhigh","max"]},
670 "name":{"type":"string"},
671 "session":{"type":"string"},
672 "sessionId":{"type":"string"},
673 "sessionDir":{"type":"string"},
674 "noSession":{"type":"boolean"},
675 "tools":{"type":"array","items":{"type":"string"}},
676 "excludeTools":{"type":"array","items":{"type":"string"}},
677 "noTools":{"type":"boolean"},
678 "noBuiltinTools":{"type":"boolean"},
679 "noSkills":{"type":"boolean"},
680 "noPromptTemplates":{"type":"boolean"},
681 "noContextFiles":{"type":"boolean"},
682 "noExtensions":{"type":"boolean"},
683 "enablePiPackages":{"type":"boolean"},
684 "extensionsDir":{"type":"array","items":{"type":"string"}},
685 "extension":{"type":"array","items":{"type":"string"}},
686 "skill":{"type":"array","items":{"type":"string"}},
687 "promptTemplate":{"type":"array","items":{"type":"string"}},
688 "offline":{"type":"boolean"},
689 "approve":{"type":"boolean"}
690 }
691}"#;
692
693const CLIENT_PARAMETERS: &str = r#"{
694 "type":"object",
695 "properties":{
696 "serverId":{"type":"string"},
697 "method":{"type":"string"},
698 "params":{"type":"object"},
699 "subscribe":{"type":"boolean","description":"Enable streaming subscription"},
700 "timeoutSeconds":{"type":"integer","minimum":1,"maximum":300}
701 },
702 "required":["serverId","method"]
703}"#;
704
705#[no_mangle]
708pub extern "C" fn rpi_plugin_register(api: *const PluginApi) -> i32 {
709 unsafe {
710 register_entrypoint(api, |api| {
711 let _ = RUNTIME_ACTION.set(api.runtime_action);
714 RUNTIME_USER_DATA.store(api.user_data, Ordering::Release);
715 let _ = HOST_FREE_STRING.set(api.free_string);
716 let Some(register) = api.register_tool else {
717 return 1;
718 };
719
720 let server_schema = Box::new(StableToolSchema {
721 name: StbString::from_string("rpc_server".into()),
722 description: StbString::from_string(
723 "Start and manage a TCP JSONL server with streaming subscriptions.".into(),
724 ),
725 parameters: StbString::from_string(SERVER_PARAMETERS.into()),
726 });
727
728 let client_schema = Box::new(StableToolSchema {
729 name: StbString::from_string("rpc_client".into()),
730 description: StbString::from_string(
731 "Send a JSON-RPC request to a running TCP server. Set subscribe=true for streaming responses.".into(),
732 ),
733 parameters: StbString::from_string(CLIENT_PARAMETERS.into()),
734 });
735
736 let first = register(
737 &*server_schema,
738 execute_server,
739 poll,
740 cancel,
741 destroy,
742 free_string,
743 );
744
745 let second = if first == 0 {
746 register(
747 &*client_schema,
748 execute_client,
749 poll,
750 cancel,
751 destroy,
752 free_string,
753 )
754 } else {
755 first
756 };
757
758 let third = if second == 0 {
759 api.register_event_handler
760 .map(|register| {
761 register(
762 EventTag::SessionShutdown,
763 on_session_shutdown,
764 std::ptr::null_mut(),
765 )
766 })
767 .unwrap_or(0)
768 } else {
769 second
770 };
771
772 let fourth = if third == 0 {
774 api.register_event_handler
775 .map(|register| {
776 register(
777 EventTag::SessionStart,
778 on_session_start,
779 std::ptr::null_mut(),
780 )
781 })
782 .unwrap_or(0)
783 } else {
784 third
785 };
786
787 drop(server_schema);
788 drop(client_schema);
789 fourth
790 })
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn launch_args_basic() {
800 let (_, args) = launch_args(&json!({
801 "provider": "anthropic",
802 "model": "claude-3",
803 "noSession": true,
804 }))
805 .unwrap();
806 assert_eq!(&args[0..2], ["--mode", "rpc"]);
807 assert!(args.windows(2).any(|w| w == ["--provider", "anthropic"]));
808 assert!(args.windows(2).any(|w| w == ["--model", "claude-3"]));
809 assert!(args.contains(&"--no-session".to_string()));
810 }
811
812 #[test]
813 fn schemas_are_valid_json() {
814 assert!(serde_json::from_str::<Value>(SERVER_PARAMETERS).is_ok());
815 assert!(serde_json::from_str::<Value>(CLIENT_PARAMETERS).is_ok());
816 }
817
818 #[test]
819 fn generate_token_is_32_hex_chars() {
820 let t = generate_token();
821 assert_eq!(t.len(), 32);
822 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
823 }
824}