futucli/cmd/key_enums.rs
1//! `gen-key` / `bind-key` 共用 enum / CSV 解析器
2//!
3//! v1.4.106 codex 0608 F5 (P3) sweep — 从 `gen_key.rs` / `bind_key.rs`
4//! 抽出 enum / 字面量解析, 替代松散的 `parse_csv` (any-string-OK) 与每个
5//! 字段独立大小写归一化, 让早期 reject 取代 silent stash:
6//!
7//! - **trd_sides**: BUY / SELL / SELL_SHORT / BUY_BACK 等 backend 枚举严格匹配
8//! - **markets**: HK / US / CN / HKCC / FUTURES / SG / AU / JP / MY / CA /
9//! HKFUND / USFUND / CRYPTO (大小写归一化为 upper, 别名 `HK_FUND` /
10//! `US_FUND` / `CC` 仍接受) — 与 `cmd::account::parse_trd_market`
11//! 对齐已支持的 trade/QOT surface 语义
12//! - **symbols**: `MARKET.CODE` 形式校验 (e.g. "HK.00700"); `MARKET` 必须
13//! 是上述合法 market 列表的一个, `CODE` 不能为空 — 拒绝 "07000" /
14//! "AAPL" / 单纯 "HK" 等显然漏写市场前缀的 input
15//! - **machine_csv**: 64-hex 指纹严格校验 (失败早 fail) — 复用 gen-key /
16//! bind-key 的格式约束
17//!
18//! 与 `parse_csv` 的差别: 这里**对每个 token loud reject 不合法 input**,
19//! 不再 silent insert "随便什么字符串" 进 `HashSet<String>` 然后留给 daemon /
20//! backend 拒绝. 与 v1.4.93 BUG-001 修法 (schema-runtime layer 双 layer 对齐,
21//! pitfall #54) 同精神 — CLI 是离 daemon 的第一道闸门, 早期严格 = 后续少 silent.
22//!
23//! **空 CSV 语义** (与 v1.4.104 external reviewer P2-008 fix 一致): 全空 token (e.g. " ,," /
24//! " ") 解析后是空集 → 调用方应**显式拒绝**, 不 silently stash 空集 (空集
25//! 在 daemon 端可能被当 "无限制" sentinel, silent inverse). 见
26//! `parse_strict_csv_non_empty` helper 文档.
27
28use std::collections::HashSet;
29
30use anyhow::{Result, anyhow, bail};
31use futu_core::trade_parsing;
32
33/// 解析 `--allowed-trd-sides` CSV → `HashSet<String>` (uppercase).
34///
35/// 行为:
36/// - case-insensitive 接入, uppercase 归一化输出 (与 daemon 校验大小写一致)
37/// - 空 token / trailing comma 跳过
38/// - **不在 core `TRD_SIDE_PARSE_CHOICES` 集合的 token → loud Err** (避免 v1.4.93
39/// BUG-001 schema-runtime drift: CLI 接受任意字符串然后 daemon 接 wire
40/// 时拒掉, 用户体验差)
41/// - 全空 / 空 CSV → 返空集; 调用方决定如何对待空集 (gen-key 走 None 表示
42/// 不限制, 见调用点逻辑)
43pub fn parse_trd_sides_csv(s: &str) -> Result<HashSet<String>> {
44 let mut out = HashSet::new();
45 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
46 let Some(side_id) = trade_parsing::parse_trd_side_id(token) else {
47 bail!(
48 "invalid trd_side {token:?}: expected one of {} \
49 (case-insensitive). v1.4.106 F5 strict parser 拒绝未知 \
50 trd_side, 不再 silently stash 任意字符串.",
51 trade_parsing::TRD_SIDE_PARSE_CHOICES
52 );
53 };
54 let canonical = trade_parsing::trd_side_label_from_id(side_id).ok_or_else(|| {
55 anyhow!(
56 "trade-side parser returned id {side_id} without a canonical label; refusing to write an ambiguous API-key policy"
57 )
58 })?;
59 out.insert(canonical.to_string());
60 }
61 Ok(out)
62}
63
64/// Trd market 合法集 — 与 backend `Trd_Common.proto::TrdMarket` enum 对齐
65/// 已支持 variant. 与 `cmd::account::parse_trd_market` 同源 (单一 source of truth).
66const ALLOWED_MARKETS: &[&str] = &[
67 "HK", "US", "CN", "HKCC", "FUTURES", "SG", "AU", "JP", "MY", "CA", "HKFUND", "USFUND", "CRYPTO",
68];
69
70/// 别名 → 规范名映射 (额外接受 `HK_FUND` / `US_FUND` / `CC`).
71fn canonicalize_market(s: &str) -> Option<&'static str> {
72 let upper = s.to_ascii_uppercase();
73 match upper.as_str() {
74 "HK" => Some("HK"),
75 "US" => Some("US"),
76 "CN" => Some("CN"),
77 "HKCC" => Some("HKCC"),
78 "FUTURES" => Some("FUTURES"),
79 "SG" => Some("SG"),
80 "AU" => Some("AU"),
81 "JP" => Some("JP"),
82 "MY" => Some("MY"),
83 "CA" => Some("CA"),
84 "HKFUND" | "HK_FUND" => Some("HKFUND"),
85 "USFUND" | "US_FUND" => Some("USFUND"),
86 // Ref: proto/Qot_Common.proto `QotMarket_CC_Security = 91`.
87 // `CC` is the public proto spelling; keep canonical user-facing key
88 // scope as CRYPTO so agent/key policies do not split one market into
89 // two names.
90 "CRYPTO" | "CC" => Some("CRYPTO"),
91 _ => None,
92 }
93}
94
95/// 解析 `--allowed-markets` CSV → `HashSet<String>` (canonical names).
96///
97/// 行为:
98/// - case-insensitive 接入, canonical name 输出 ("HK_FUND" → "HKFUND")
99/// - 空 token / trailing comma 跳过
100/// - **不在 [`ALLOWED_MARKETS`] 集合 (含别名) 的 token → loud Err**
101pub fn parse_markets_csv(s: &str) -> Result<HashSet<String>> {
102 let mut out = HashSet::new();
103 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
104 match canonicalize_market(token) {
105 Some(canon) => {
106 out.insert(canon.to_string());
107 }
108 None => bail!(
109 "invalid market {token:?}: expected one of {:?} \
110 (case-insensitive; HK_FUND / US_FUND 等同 HKFUND / USFUND; \
111 CC 等同 CRYPTO). \
112 v1.4.106 F5 strict parser 拒绝未知 market, 不再 silently \
113 stash 任意字符串.",
114 ALLOWED_MARKETS
115 ),
116 }
117 }
118 Ok(out)
119}
120
121/// 解析 `--allowed-symbols` CSV → `HashSet<String>` (uppercase).
122///
123/// 接受格式: `MARKET.CODE` (e.g. `HK.00700`, `US.AAPL`). 校验:
124/// - 必须含 `.` 分隔符
125/// - `MARKET` 部分必须在 [`ALLOWED_MARKETS`] (含别名)
126/// - `CODE` 部分非空
127/// - case-insensitive 接入, **uppercase 归一化** 输出 (`hk.00700` → `HK.00700`)
128///
129/// 拒绝:
130/// - "AAPL" (缺市场前缀)
131/// - "HK." (code 空)
132/// - ".00700" (market 空)
133/// - "FOO.BAR" (market 不在白名单)
134pub fn parse_symbols_csv(s: &str) -> Result<HashSet<String>> {
135 let mut out = HashSet::new();
136 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
137 let (market, code) = token.split_once('.').ok_or_else(|| {
138 anyhow!(
139 "invalid symbol {token:?}: expected MARKET.CODE (e.g. HK.00700). \
140 v1.4.106 F5 strict parser 拒绝缺市场前缀的 symbol."
141 )
142 })?;
143 if code.is_empty() {
144 bail!(
145 "invalid symbol {token:?}: code part empty after '.'. \
146 expected MARKET.CODE (e.g. HK.00700)."
147 );
148 }
149 let canon_market = canonicalize_market(market).ok_or_else(|| {
150 anyhow!(
151 "invalid symbol {token:?}: market {market:?} not in {:?} \
152 (case-insensitive; HK_FUND / US_FUND 等同 HKFUND / USFUND; \
153 CC 等同 CRYPTO). \
154 v1.4.106 F5 strict parser 拒绝未知 market 前缀.",
155 ALLOWED_MARKETS
156 )
157 })?;
158 // 输出: 大写归一化 (CODE 部分保持原样大小写? 用户指定 case-sensitive,
159 // 但 daemon symbol cache 是 uppercase 归一化, 所以 CLI 也归一)
160 out.insert(format!("{canon_market}.{}", code.to_ascii_uppercase()));
161 }
162 Ok(out)
163}
164
165/// 解析 64-hex 指纹 CSV → `Vec<String>` (保序去重由调用方做).
166///
167/// 与 `bind_key::parse_fingerprints` 同语义, 抽出来给 gen-key /
168/// bind-key 共用. 单个 token:
169/// - 必须 64 字符
170/// - 必须全 ASCII hex digit
171pub fn parse_fingerprints_csv(s: &str) -> Result<Vec<String>> {
172 let mut out = Vec::new();
173 for part in s.split(',') {
174 let fp = part.trim();
175 if fp.is_empty() {
176 continue;
177 }
178 if fp.len() != 64 || !fp.chars().all(|c| c.is_ascii_hexdigit()) {
179 bail!(
180 "invalid fingerprint {fp:?}: expect 64 hex chars \
181 (run `futucli machine-id --for-key <id>`)"
182 );
183 }
184 out.push(fp.to_string());
185 }
186 Ok(out)
187}
188
189#[cfg(test)]
190mod tests;