1use std::collections::HashSet;
17use std::path::PathBuf;
18
19use anyhow::{Context, Result, anyhow};
20use futu_auth::{machine, store};
21
22fn default_keys_path() -> Result<PathBuf> {
23 let base =
24 dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
25 Ok(base.join("futu").join("keys.json"))
26}
27
28#[cfg(test)]
32fn parse_fingerprints(raw: &str) -> Result<Vec<String>> {
33 crate::cmd::key_enums::parse_fingerprints_csv(raw)
34}
35
36pub struct BindKeyCommand {
37 pub id: String,
38 pub keys_file: Option<PathBuf>,
39 pub this_machine: bool,
40 pub machines: Option<String>,
41 pub replace: bool,
42 pub clear: bool,
43 pub freeze: bool,
44}
45
46pub async fn run(input: BindKeyCommand) -> Result<()> {
47 let BindKeyCommand {
48 id,
49 keys_file,
50 this_machine,
51 machines,
52 replace,
53 clear,
54 freeze,
55 } = input;
56
57 let mutating_ops = [clear, freeze, replace].iter().filter(|x| **x).count();
59 if mutating_ops > 1 {
60 return Err(anyhow!(
61 "--clear / --freeze / --replace are mutually exclusive"
62 ));
63 }
64 if clear && (this_machine || machines.is_some()) {
65 return Err(anyhow!(
66 "--clear does not take --this-machine / --machines (it removes the binding entirely)"
67 ));
68 }
69 if freeze && (this_machine || machines.is_some()) {
70 return Err(anyhow!(
71 "--freeze does not take --this-machine / --machines (it sets allowed_machines = [])"
72 ));
73 }
74 if !clear && !freeze && !this_machine && machines.is_none() {
75 return Err(anyhow!(
76 "nothing to do — pass --this-machine / --machines / --clear / --freeze"
77 ));
78 }
79
80 let path = match keys_file {
81 Some(p) => p,
82 None => default_keys_path()?,
83 };
84
85 let mut new_fps: Vec<String> = Vec::new();
93 if this_machine {
94 let fp = machine::fingerprint_for(&id)
95 .map_err(|e| anyhow!("compute this machine's fingerprint: {e}"))?;
96 new_fps.push(fp);
97 }
98 if let Some(raw) = machines.as_deref() {
99 let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
100 if parsed.is_empty() && !this_machine {
101 return Err(anyhow!(
102 "v1.4.106 F4: --machines {raw:?} parsed to empty list \
103 (no --this-machine either). 这是 silent no-op — 与你的意图相反. \
104 如要 \"清除绑定\" 用 --clear; 如要 \"冻结所有\" 用 --freeze; \
105 如要追加机器, 传至少 1 个 64-hex 指纹."
106 ));
107 }
108 new_fps.extend(parsed);
109 }
110
111 let changed = store::update_key(&path, &id, |rec| {
112 if clear {
113 if rec.allowed_machines.is_none() {
114 return false;
115 }
116 rec.allowed_machines = None;
117 return true;
118 }
119 if freeze {
120 if matches!(rec.allowed_machines.as_deref(), Some(list) if list.is_empty()) {
121 return false;
122 }
123 rec.allowed_machines = Some(Vec::new());
124 return true;
125 }
126 if replace {
127 rec.allowed_machines = Some(new_fps.clone());
128 return true;
129 }
130 let mut existing = rec.allowed_machines.clone().unwrap_or_default();
132 let mut seen: HashSet<String> = existing.iter().cloned().collect();
133 let mut added_any = false;
134 for fp in &new_fps {
135 if seen.insert(fp.clone()) {
136 existing.push(fp.clone());
137 added_any = true;
138 }
139 }
140 if !added_any && rec.allowed_machines.is_some() {
141 return false;
142 }
143 rec.allowed_machines = Some(existing);
144 true
145 })
146 .with_context(|| format!("update key in {}", path.display()))?;
147
148 if !changed {
149 let ids = store::list_keys(&path)
151 .with_context(|| format!("list keys after no-op update in {}", path.display()))?;
152 if !ids.iter().any(|k| k.id == id) {
153 return Err(anyhow!("no key with id={id:?} in {}", path.display()));
154 }
155 println!("no change (already in desired state): id={id:?}");
156 return Ok(());
157 }
158
159 let rec = store::list_keys(&path)
161 .context("re-read after update")?
162 .into_iter()
163 .find(|k| k.id == id)
164 .ok_or_else(|| anyhow!("key disappeared after update (race?)"))?;
165
166 println!("updated: id={id:?} ({})", path.display());
167 match rec.allowed_machines.as_deref() {
168 None => println!(" bound : (none — unbound, any machine allowed)"),
169 Some([]) => println!(" bound : [] (FROZEN — no machine allowed)"),
170 Some(list) => {
171 println!(" bound : {} machine(s)", list.len());
172 for fp in list {
173 println!(" {}", &fp[..16]);
174 }
175 }
176 }
177 println!();
178 println!("note: running gateway must receive SIGHUP to pick up the change. Run:");
179 println!(" kill -HUP $(pgrep futu-opend) # Linux / macOS,shell 会自动探测 pid");
180 println!(
181 " # Windows / 没装 pgrep:先 `ps aux | grep futu-opend` 查 pid,再 `kill -HUP <pid>`"
182 );
183 Ok(())
184}
185
186#[cfg(test)]
187mod tests;