Skip to main content

futu_net/
client.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
3use std::time::Duration;
4
5use bytes::Bytes;
6use dashmap::DashMap;
7use tokio::sync::{mpsc, oneshot};
8
9use futu_codec::frame::FutuFrame;
10use futu_core::error::{FutuError, Result};
11use futu_core::proto_id;
12
13use crate::connection::Connection;
14use crate::encrypt;
15use crate::reconnect::ReconnectPolicy;
16
17// Rust local request guard: backend calls should fail loud instead of waiting
18// forever when no caller-specific timeout is supplied.
19const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
20// Server keep_alive is dynamic; these bounds protect Tokio interval creation
21// and keep pathological backend values from creating hot loops or long stalls.
22const DEFAULT_KEEP_ALIVE_INTERVAL_SECS: u64 = 10;
23const MIN_KEEP_ALIVE_INTERVAL_SECS: u64 = 1;
24const MAX_KEEP_ALIVE_INTERVAL_SECS: u64 = 60;
25// Rust local transport guard: consecutive heartbeat send failures mean the TCP
26// writer is no longer healthy enough for request/push ordering.
27const MAX_HEARTBEAT_SEND_FAILURES: u32 = 3;
28// Rust local backpressure guard for push fan-out. Push overflow is dropped with
29// a warning instead of allowing unbounded memory growth.
30pub(crate) const PUSH_CHANNEL_CAPACITY: usize = 4096;
31
32fn sanitize_keep_alive_interval_secs(raw: i32) -> u64 {
33    if raw <= 0 {
34        return DEFAULT_KEEP_ALIVE_INTERVAL_SECS;
35    }
36    (raw as u64).clamp(MIN_KEEP_ALIVE_INTERVAL_SECS, MAX_KEEP_ALIVE_INTERVAL_SECS)
37}
38
39fn heartbeat_send_failure_should_exit(consecutive_failures: &mut u32, result: &Result<()>) -> bool {
40    if result.is_ok() {
41        *consecutive_failures = 0;
42        return false;
43    }
44
45    *consecutive_failures = consecutive_failures.saturating_add(1);
46    *consecutive_failures >= MAX_HEARTBEAT_SEND_FAILURES
47}
48
49fn init_connect_server_err(ret_type: i32, ret_msg: Option<String>) -> FutuError {
50    let msg = match ret_msg {
51        Some(msg) if !msg.is_empty() => msg,
52        _ => "<missing ret_msg>".to_string(),
53    };
54    FutuError::ServerError { ret_type, msg }
55}
56
57/// 客户端配置
58#[derive(Debug, Clone)]
59pub struct ClientConfig {
60    pub addr: String,
61    pub client_ver: String,
62    pub client_id: String,
63    pub recv_notify: bool,
64    /// RSA 私钥(PEM 格式)。若提供则启用加密:
65    /// - InitConnect 使用 RSA 加解密
66    /// - 后续请求使用 AES-128 ECB 加解密
67    /// - 若不提供则全程明文通信
68    pub rsa_key: Option<String>,
69}
70
71/// 推送消息
72#[derive(Debug, Clone)]
73pub struct PushMessage {
74    pub proto_id: u32,
75    pub body: Bytes,
76}
77
78type PushSender = mpsc::Sender<PushMessage>;
79pub type PushReceiver = mpsc::Receiver<PushMessage>;
80
81/// 请求上下文(等待响应的 oneshot sender)
82struct PendingRequest {
83    tx: oneshot::Sender<FutuFrame>,
84}
85
86/// FutuOpenD 高层客户端
87///
88/// 功能:
89/// - InitConnect 握手
90/// - 自动心跳 KeepAlive
91/// - 请求-响应匹配(serial number)
92/// - 推送消息分发
93/// - 断线重连
94pub struct FutuClient {
95    config: ClientConfig,
96    serial_no: AtomicU32,
97    pending: Arc<DashMap<u32, PendingRequest>>,
98    push_tx: PushSender,
99    cmd_tx: Option<mpsc::Sender<ClientCommand>>,
100    conn_aes_key: parking_lot::Mutex<Option<[u8; 16]>>,
101    conn_id: AtomicU64,
102    init_ai_type: Option<i32>,
103}
104
105enum ClientCommand {
106    Send(FutuFrame, oneshot::Sender<()>),
107}
108
109fn decode_client_inbound_body(frame: &FutuFrame, aes_key: Option<&[u8; 16]>) -> Result<Bytes> {
110    let body = match aes_key {
111        Some(key) if !frame.body.is_empty() => {
112            encrypt::aes_ecb_decrypt(key, &frame.body).map(Bytes::from)
113        }
114        _ => Ok(frame.body.clone()),
115    }?;
116    verify_plaintext_body_sha1(frame, body)
117}
118
119fn verify_plaintext_body_sha1(frame: &FutuFrame, plaintext: Bytes) -> Result<Bytes> {
120    let plaintext_frame = FutuFrame {
121        header: frame.header.clone(),
122        body: plaintext.clone(),
123    };
124    if plaintext_frame.verify_sha1() {
125        Ok(plaintext)
126    } else {
127        Err(FutuError::Sha1Mismatch)
128    }
129}
130
131fn build_init_connect_request(
132    config: &ClientConfig,
133    ai_type: Option<i32>,
134) -> futu_proto::init_connect::Request {
135    futu_proto::init_connect::Request {
136        c2s: futu_proto::init_connect::C2s {
137            client_ver: 100,
138            client_id: config.client_id.clone(),
139            recv_notify: Some(config.recv_notify),
140            packet_enc_algo: Some(0),
141            push_proto_fmt: Some(0),
142            programming_language: Some(String::new()),
143            ai_type,
144        },
145    }
146}
147
148fn parse_conn_aes_key(conn_aes_key: &str) -> Result<[u8; 16]> {
149    let key_bytes = conn_aes_key.as_bytes();
150    match key_bytes.len() {
151        16 => {
152            let mut key = [0u8; 16];
153            key.copy_from_slice(key_bytes);
154            Ok(key)
155        }
156        32 => hex_decode_16(key_bytes)
157            .ok_or_else(|| FutuError::Codec("invalid hex InitConnect AES key".into())),
158        len => Err(FutuError::Codec(format!(
159            "unexpected AES key length: {len}"
160        ))),
161    }
162}
163
164fn release_pending_on_inbound_decode_error(
165    frame: &FutuFrame,
166    pending: &DashMap<u32, PendingRequest>,
167) {
168    if !proto_id::is_push_proto(frame.header.proto_id) {
169        pending.remove(&frame.header.serial_no);
170    }
171}
172
173fn push_channel() -> (PushSender, PushReceiver) {
174    mpsc::channel(PUSH_CHANNEL_CAPACITY)
175}
176
177fn send_push_message(push_tx: &PushSender, message: PushMessage) -> bool {
178    let proto_id = message.proto_id;
179    match push_tx.try_send(message) {
180        Ok(()) => true,
181        Err(mpsc::error::TrySendError::Full(_)) => {
182            tracing::warn!(
183                proto_id,
184                capacity = PUSH_CHANNEL_CAPACITY,
185                "push receiver is slow; dropping push message from bounded client queue"
186            );
187            false
188        }
189        Err(mpsc::error::TrySendError::Closed(_)) => {
190            tracing::debug!(proto_id, "push receiver dropped before delivery");
191            false
192        }
193    }
194}
195
196fn send_response_frame(tx: oneshot::Sender<FutuFrame>, frame: FutuFrame) -> bool {
197    let serial = frame.header.serial_no;
198    let proto_id = frame.header.proto_id;
199    match tx.send(frame) {
200        Ok(()) => true,
201        Err(_) => {
202            tracing::debug!(
203                serial,
204                proto_id,
205                "response receiver dropped before delivery"
206            );
207            false
208        }
209    }
210}
211
212fn send_command_ack(ack: oneshot::Sender<()>) -> bool {
213    match ack.send(()) {
214        Ok(()) => true,
215        Err(_) => {
216            tracing::debug!("command ack receiver dropped before delivery");
217            false
218        }
219    }
220}
221
222impl FutuClient {
223    /// 创建客户端(未连接状态)
224    ///
225    /// 返回 (client, push_rx),其中 push_rx 用于接收服务端推送。
226    pub fn new(config: ClientConfig) -> (Self, PushReceiver) {
227        let (push_tx, push_rx) = push_channel();
228
229        let client = Self {
230            config,
231            serial_no: AtomicU32::new(0),
232            pending: Arc::new(DashMap::new()),
233            push_tx,
234            cmd_tx: None,
235            conn_aes_key: parking_lot::Mutex::new(None),
236            conn_id: AtomicU64::new(0),
237            init_ai_type: None,
238        };
239
240        (client, push_rx)
241    }
242
243    /// Set C++ 10.7 `InitConnect.C2S.aiType` for the next connection.
244    ///
245    /// `None` preserves the legacy wire shape. C++ treats an absent value as
246    /// `0` (non-AI); `Some(1)` marks a skills/AI caller.
247    pub fn with_init_ai_type(mut self, ai_type: Option<i32>) -> Self {
248        self.init_ai_type = ai_type;
249        self
250    }
251
252    /// 连接到 OpenD 网关并完成 InitConnect 握手
253    pub async fn connect(&mut self) -> Result<InitConnectInfo> {
254        let mut conn = Connection::connect(&self.config.addr).await?;
255
256        // 发送 InitConnect 请求
257        let serial = self.next_serial();
258        let req = build_init_connect_request(&self.config, self.init_ai_type);
259        let raw_body = prost::Message::encode_to_vec(&req);
260
261        // 如果有 RSA 密钥,用 RSA 公钥加密 InitConnect 请求 body
262        let send_body = if let Some(ref rsa_key) = self.config.rsa_key {
263            tracing::debug!("encrypting InitConnect with RSA");
264            encrypt::rsa_public_encrypt(rsa_key, &raw_body)?
265        } else {
266            raw_body.clone()
267        };
268
269        // SHA1 基于明文
270        let mut frame = Connection::build_frame(proto_id::INIT_CONNECT, serial, send_body);
271        {
272            use sha1::{Digest, Sha1};
273            let mut hasher = Sha1::new();
274            hasher.update(&raw_body);
275            let hash = hasher.finalize();
276            frame.header.body_sha1.copy_from_slice(&hash);
277        }
278        conn.send(frame).await?;
279
280        // 接收 InitConnect 响应
281        let resp_frame = conn.recv().await?.ok_or(FutuError::Codec(
282            "connection closed during InitConnect".into(),
283        ))?;
284
285        // 如果有 RSA 密钥,用 RSA 私钥解密 InitConnect 响应 body
286        let resp_body = if let Some(ref rsa_key) = self.config.rsa_key {
287            tracing::debug!("decrypting InitConnect response with RSA");
288            Bytes::from(encrypt::rsa_private_decrypt(rsa_key, &resp_frame.body)?)
289        } else {
290            resp_frame.body.clone()
291        };
292        let resp_body = verify_plaintext_body_sha1(&resp_frame, resp_body)?;
293
294        let resp: futu_proto::init_connect::Response =
295            prost::Message::decode(resp_body.as_ref()).map_err(FutuError::Proto)?;
296
297        // 检查返回码
298        let ret_type = resp.ret_type;
299        if ret_type != 0 {
300            return Err(init_connect_server_err(ret_type, resp.ret_msg));
301        }
302
303        let s2c = resp.s2c.ok_or(FutuError::Codec(
304            "missing s2c in InitConnect response".into(),
305        ))?;
306
307        let info = InitConnectInfo {
308            server_ver: s2c.server_ver,
309            login_user_id: s2c.login_user_id,
310            conn_id: s2c.conn_id,
311            conn_aes_key: s2c.conn_aes_key.clone(),
312            keep_alive_interval: s2c.keep_alive_interval,
313        };
314        self.conn_id.store(info.conn_id, Ordering::Relaxed);
315
316        // 保存 AES key
317        if !info.conn_aes_key.is_empty() {
318            let key_bytes = info.conn_aes_key.as_bytes();
319            tracing::debug!(key_len = key_bytes.len(), "received AES key");
320            *self.conn_aes_key.lock() = Some(parse_conn_aes_key(&info.conn_aes_key)?);
321        }
322
323        tracing::info!(
324            server_ver = info.server_ver,
325            conn_id = info.conn_id,
326            keep_alive_interval = info.keep_alive_interval,
327            "InitConnect succeeded"
328        );
329
330        // 启动后台任务:心跳、消息接收。C++ OpenD API server 固定下发 10s,
331        // 且每 66s 检查活跃计数;这里对异常 S2C 做本地边界保护,避免
332        // malicious/misconfigured server 传 0 或极大值导致 tokio interval panic
333        // 或长时间不发心跳。
334        let keep_alive_interval_secs = sanitize_keep_alive_interval_secs(info.keep_alive_interval);
335        if info.keep_alive_interval <= 0
336            || keep_alive_interval_secs != info.keep_alive_interval as u64
337        {
338            tracing::warn!(
339                raw_keep_alive_interval = info.keep_alive_interval,
340                sanitized_keep_alive_interval = keep_alive_interval_secs,
341                "sanitized InitConnect keep_alive_interval"
342            );
343        }
344        let keep_alive_interval = Duration::from_secs(keep_alive_interval_secs);
345        self.start_background_tasks(conn, keep_alive_interval);
346
347        Ok(info)
348    }
349
350    /// 发送请求并等待响应
351    pub async fn request(&self, proto_id: u32, body: Vec<u8>) -> Result<FutuFrame> {
352        self.request_with_timeout(proto_id, body, DEFAULT_REQUEST_TIMEOUT)
353            .await
354    }
355
356    /// 发送请求并等待响应(调用方可指定总请求等待上限)。
357    ///
358    /// 与 [`Self::request`] 共用同一 pending 清理路径:command channel 入队、
359    /// send ack、响应等待任一阶段超时时都必须移除 `pending[serial]`,避免
360    /// 调用方自己在外层 `tokio::time::timeout` 取消 future 时留下悬挂请求。
361    pub async fn request_with_timeout(
362        &self,
363        proto_id: u32,
364        body: Vec<u8>,
365        timeout: Duration,
366    ) -> Result<FutuFrame> {
367        let deadline = tokio::time::Instant::now() + timeout;
368        let serial = self.next_serial();
369
370        // 加密 body(如果有 AES key)
371        let (final_body, sha1) = self.prepare_body(&body);
372
373        let frame = FutuFrame::with_sha1(proto_id, serial, Bytes::from(final_body), sha1);
374
375        let (resp_tx, resp_rx) = oneshot::channel();
376        self.pending.insert(serial, PendingRequest { tx: resp_tx });
377
378        // 通过 command channel 发送
379        if let Some(cmd_tx) = &self.cmd_tx {
380            let (ack_tx, ack_rx) = oneshot::channel();
381            match tokio::time::timeout_at(deadline, cmd_tx.send(ClientCommand::Send(frame, ack_tx)))
382                .await
383            {
384                Ok(Ok(())) => {}
385                Ok(Err(_)) => {
386                    self.pending.remove(&serial);
387                    return Err(FutuError::NotInitialized);
388                }
389                Err(_) => {
390                    self.pending.remove(&serial);
391                    return Err(FutuError::Timeout);
392                }
393            }
394
395            match tokio::time::timeout_at(deadline, ack_rx).await {
396                Ok(Ok(())) => {}
397                Ok(Err(_)) => {
398                    self.pending.remove(&serial);
399                    return Err(FutuError::Codec("send ack failed".into()));
400                }
401                Err(_) => {
402                    self.pending.remove(&serial);
403                    return Err(FutuError::Timeout);
404                }
405            }
406        } else {
407            self.pending.remove(&serial);
408            return Err(FutuError::NotInitialized);
409        }
410
411        // 等待响应(带超时)
412        let resp = tokio::time::timeout_at(deadline, resp_rx)
413            .await
414            .map_err(|elapsed| {
415                self.pending.remove(&serial);
416                tracing::warn!(
417                    serial,
418                    error = %elapsed,
419                    "request response wait timed out; pending entry removed"
420                );
421                FutuError::Timeout
422            })?
423            .map_err(|err| {
424                FutuError::Codec(format!(
425                    "response channel closed for serial {serial}: {err}"
426                ))
427            })?;
428
429        Ok(resp)
430    }
431
432    /// Server-assigned connection id from InitConnect S2C.
433    ///
434    /// FTAPI trade-write packet ids must echo this value in `PacketID.connID`;
435    /// the gateway replay guard compares it against the actual TCP connection.
436    pub fn conn_id(&self) -> Option<u64> {
437        let conn_id = self.conn_id.load(Ordering::Relaxed);
438        (conn_id != 0).then_some(conn_id)
439    }
440
441    fn next_serial(&self) -> u32 {
442        self.serial_no.fetch_add(1, Ordering::Relaxed) + 1
443    }
444
445    fn prepare_body(&self, plaintext: &[u8]) -> (Vec<u8>, [u8; 20]) {
446        use sha1::{Digest, Sha1};
447
448        // SHA1 始终基于明文
449        let mut hasher = Sha1::new();
450        hasher.update(plaintext);
451        let sha1_result = hasher.finalize();
452        let mut sha1 = [0u8; 20];
453        sha1.copy_from_slice(&sha1_result);
454
455        // 仅在有 RSA 密钥(即启用加密)时才用 AES 加密
456        let body = if self.config.rsa_key.is_some() {
457            let key = self.conn_aes_key.lock();
458            match key.as_ref() {
459                Some(k) => encrypt::aes_ecb_encrypt(k, plaintext),
460                None => plaintext.to_vec(),
461            }
462        } else {
463            plaintext.to_vec()
464        };
465
466        (body, sha1)
467    }
468
469    fn start_background_tasks(&mut self, conn: Connection, keep_alive_interval: Duration) {
470        let (cmd_tx, cmd_rx) = mpsc::channel(256);
471        self.cmd_tx = Some(cmd_tx.clone());
472
473        let pending = Arc::clone(&self.pending);
474        let push_tx = self.push_tx.clone();
475        let aes_key = if self.config.rsa_key.is_some() {
476            *self.conn_aes_key.lock()
477        } else {
478            None
479        };
480
481        tokio::spawn(async move {
482            run_event_loop(conn, cmd_rx, pending, push_tx, aes_key, keep_alive_interval).await;
483        });
484    }
485}
486
487/// 后台事件循环:处理接收、心跳、发送
488async fn run_event_loop(
489    mut conn: Connection,
490    mut cmd_rx: mpsc::Receiver<ClientCommand>,
491    pending: Arc<DashMap<u32, PendingRequest>>,
492    push_tx: PushSender,
493    aes_key: Option<[u8; 16]>,
494    keep_alive_interval: Duration,
495) {
496    let mut heartbeat = tokio::time::interval(keep_alive_interval);
497    heartbeat.tick().await; // 跳过第一次立即触发
498    let mut heartbeat_serial: u32 = 10_000_000; // 心跳用独立序列号空间
499    let mut heartbeat_send_failures = 0u32;
500
501    loop {
502        tokio::select! {
503            // 接收服务端消息
504            result = conn.recv() => {
505                match result {
506                    Ok(Some(frame)) => {
507                        let proto_id = frame.header.proto_id;
508                        let body = match decode_client_inbound_body(&frame, aes_key.as_ref()) {
509                            Ok(body) => body,
510                            Err(e) => {
511                                tracing::warn!(
512                                    error = %e,
513                                    serial = frame.header.serial_no,
514                                    proto_id,
515                                    "decrypt failed, dropping inbound frame"
516                                );
517                                release_pending_on_inbound_decode_error(&frame, &pending);
518                                continue;
519                            }
520                        };
521
522                        if proto_id::is_push_proto(proto_id) {
523                            // 推送消息
524                            send_push_message(&push_tx, PushMessage { proto_id, body });
525                        } else {
526                            // 响应消息:匹配 serial number
527                            let serial = frame.header.serial_no;
528                            match pending.remove(&serial) {
529                                Some((_, req)) => {
530                                    let resp_frame = FutuFrame {
531                                        header: frame.header,
532                                        body,
533                                    };
534                                    send_response_frame(req.tx, resp_frame);
535                                }
536                                _ => {
537                                    tracing::debug!(
538                                        serial = serial,
539                                        proto_id = proto_id,
540                                        "unmatched response"
541                                    );
542                                }
543                            }
544                        }
545                    }
546                    Ok(None) => {
547                        tracing::warn!("connection closed by server");
548                        break;
549                    }
550                    Err(e) => {
551                        tracing::error!(error = %e, "recv error");
552                        break;
553                    }
554                }
555            }
556
557            // 发送命令
558            cmd = cmd_rx.recv() => {
559                match cmd {
560                    Some(ClientCommand::Send(frame, ack)) => {
561                        let result = conn.send(frame).await;
562                        if let Err(e) = &result {
563                            tracing::error!(error = %e, "send failed");
564                        }
565                        send_command_ack(ack);
566                        if result.is_err() {
567                            break;
568                        }
569                    }
570                    None => {
571                        tracing::info!("shutting down event loop");
572                        break;
573                    }
574                }
575            }
576
577            // 心跳
578            _ = heartbeat.tick() => {
579                heartbeat_serial += 1;
580                let req = futu_proto::keep_alive::Request {
581                    c2s: futu_proto::keep_alive::C2s {
582                        time: chrono::Utc::now().timestamp(),
583                    },
584                };
585                let body = prost::Message::encode_to_vec(&req);
586                let frame = Connection::build_frame(
587                    proto_id::KEEP_ALIVE,
588                    heartbeat_serial,
589                    body,
590                );
591                let result = conn.send(frame).await;
592                if let Err(e) = &result {
593                    tracing::warn!(
594                        error = %e,
595                        consecutive_failures = heartbeat_send_failures.saturating_add(1),
596                        max_failures = MAX_HEARTBEAT_SEND_FAILURES,
597                        "heartbeat send failed"
598                    );
599                }
600                if heartbeat_send_failure_should_exit(&mut heartbeat_send_failures, &result) {
601                    tracing::error!(
602                        consecutive_failures = heartbeat_send_failures,
603                        max_failures = MAX_HEARTBEAT_SEND_FAILURES,
604                        "heartbeat send failure threshold reached; closing event loop"
605                    );
606                    break;
607                }
608                if result.is_ok() {
609                    tracing::trace!("heartbeat sent");
610                }
611            }
612        }
613    }
614
615    // 清理所有 pending 请求
616    pending.clear();
617    tracing::info!("event loop exited");
618}
619
620/// InitConnect 握手返回的信息
621#[derive(Debug, Clone)]
622pub struct InitConnectInfo {
623    pub server_ver: i32,
624    pub login_user_id: u64,
625    pub conn_id: u64,
626    pub conn_aes_key: String,
627    pub keep_alive_interval: i32,
628}
629
630/// 带自动重连的客户端包装
631pub struct ReconnectingClient {
632    config: ClientConfig,
633    policy: ReconnectPolicy,
634    init_ai_type: Option<i32>,
635}
636
637impl ReconnectingClient {
638    pub fn new(config: ClientConfig) -> Self {
639        Self {
640            config,
641            policy: ReconnectPolicy::default_policy(),
642            init_ai_type: None,
643        }
644    }
645
646    pub fn with_policy(mut self, policy: ReconnectPolicy) -> Self {
647        self.policy = policy;
648        self
649    }
650
651    /// Set C++ 10.7 `InitConnect.C2S.aiType` on each reconnect attempt.
652    ///
653    /// `None` preserves the legacy wire shape. C++ treats an absent value as
654    /// `0` (non-AI); `Some(1)` marks a skills/AI caller.
655    pub fn with_init_ai_type(mut self, ai_type: Option<i32>) -> Self {
656        self.init_ai_type = ai_type;
657        self
658    }
659
660    /// 带重连的连接循环
661    ///
662    /// 返回成功连接的 (FutuClient, push_rx, InitConnectInfo)。
663    /// 如果达到最大重试次数则返回错误。
664    pub async fn connect(&mut self) -> Result<(FutuClient, PushReceiver, InitConnectInfo)> {
665        loop {
666            let (mut client, push_rx) = FutuClient::new(self.config.clone());
667            client = client.with_init_ai_type(self.init_ai_type);
668            match client.connect().await {
669                Ok(info) => {
670                    self.policy.reset();
671                    return Ok((client, push_rx, info));
672                }
673                Err(e) => {
674                    tracing::warn!(
675                        error = %e,
676                        attempt = self.policy.attempts(),
677                        "connection failed"
678                    );
679                    match self.policy.next_delay() {
680                        Some(delay) => {
681                            tracing::info!(delay_ms = delay.as_millis(), "reconnecting...");
682                            tokio::time::sleep(delay).await;
683                        }
684                        None => {
685                            return Err(FutuError::Codec(format!(
686                                "max retries reached after {} attempts",
687                                self.policy.attempts()
688                            )));
689                        }
690                    }
691                }
692            }
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests;
699
700fn hex_decode_16(hex_bytes: &[u8]) -> Option<[u8; 16]> {
701    if hex_bytes.len() != 32 {
702        return None;
703    }
704    let hex_str = std::str::from_utf8(hex_bytes).ok()?;
705    let mut key = [0u8; 16];
706    for i in 0..16 {
707        key[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).ok()?;
708    }
709    Some(key)
710}