Skip to main content

futu_cache/
broker_dictionary.rs

1//! Atomic broker-name dictionary and durable last-good snapshot.
2//!
3//! C++ loads `Broker.dat` before login, then CMD18008 replaces the version,
4//! issuer table and broker-code table as one logical snapshot. Readers must
5//! never observe those maps at different generations.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs::{File, OpenOptions};
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use arc_swap::ArcSwap;
14use serde::{Deserialize, Serialize};
15
16// Rust-owned durable format version, not a backend value. Increment it only
17// when the persisted four-part snapshot schema changes; unsupported versions
18// are rejected wholesale instead of being guessed or partially migrated.
19const SCHEMA_VERSION: u32 = 1;
20// CMD18008's C++ decompressed payload is capped at 2 MiB
21// (`NNBiz_Qot_Broker.cpp:225-230`). JSON escaping/field labels expand that
22// representation, so disk input is bounded at 16 MiB. Revisit only if a valid
23// full CMD18008 snapshot demonstrably exceeds this derived ceiling.
24const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
25
26// Ref: `NNBase_Define_Enum.h` NN_AppLanguage and
27// `NNBiz_Qot_TenBuySellBroker.cpp:45-63`.
28const APP_LANGUAGE_ZH: i32 = 0;
29const APP_LANGUAGE_HK: i32 = 1;
30const APP_LANGUAGE_EN: i32 = 2;
31const APP_LANGUAGE_JA: i32 = 5;
32
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CachedBrokerInfo {
36    /// Legacy Chinese abbreviation (`nameAbbr.sc`).
37    pub name_zh_cn: String,
38    /// Legacy English abbreviation (`nameAbbr.en`).
39    pub name_en: String,
40    /// Legacy Traditional Chinese abbreviation (`nameAbbr.tc`).
41    pub name_tc: String,
42    /// Legacy Chinese full name (`name.sc`).
43    pub full_name_zh_cn: String,
44    /// Legacy English full name (`name.en`).
45    pub full_name_en: String,
46    /// Legacy Traditional Chinese full name (`name.tc`).
47    pub full_name_tc: String,
48}
49
50impl CachedBrokerInfo {
51    /// C++ `NNBiz_Qot_TenBuySellBroker.cpp:43-74` lookup order:
52    /// selected-language abbreviation, EN/TC/SC abbreviations, then the same
53    /// selected/full-name fallback sequence. Unknown app languages use ZH.
54    #[must_use]
55    pub fn display_name(&self, app_lang: i32) -> Option<String> {
56        let (selected_abbr, selected_full) = match app_lang {
57            APP_LANGUAGE_EN | APP_LANGUAGE_JA => (&self.name_en, &self.full_name_en),
58            APP_LANGUAGE_HK => (&self.name_tc, &self.full_name_tc),
59            APP_LANGUAGE_ZH => (&self.name_zh_cn, &self.full_name_zh_cn),
60            _ => (&self.name_zh_cn, &self.full_name_zh_cn),
61        };
62        [
63            selected_abbr.as_str(),
64            self.name_en.as_str(),
65            self.name_tc.as_str(),
66            self.name_zh_cn.as_str(),
67            selected_full.as_str(),
68            self.full_name_en.as_str(),
69            self.full_name_tc.as_str(),
70            self.full_name_zh_cn.as_str(),
71        ]
72        .into_iter()
73        .find(|name| !name.is_empty())
74        .map(ToOwned::to_owned)
75    }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
79pub enum BrokerDictionarySnapshotError {
80    #[error("broker dictionary version is invalid")]
81    InvalidVersion,
82    #[error("broker dictionary issuer id is invalid")]
83    InvalidIssuer,
84    #[error("broker dictionary issuer id is duplicated")]
85    DuplicateIssuer,
86    #[error("broker dictionary broker code is invalid")]
87    InvalidBrokerCode,
88    #[error("broker dictionary broker code is duplicated")]
89    DuplicateBrokerCode,
90    #[error("broker dictionary broker code points to a missing issuer")]
91    DanglingBrokerCode,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Default)]
95pub struct BrokerDictionarySnapshot {
96    initialized: bool,
97    backend_version: i32,
98    issuer_infos: BTreeMap<i64, CachedBrokerInfo>,
99    broker_code_to_issuer: BTreeMap<i64, i64>,
100}
101
102impl BrokerDictionarySnapshot {
103    pub fn try_from_parts(
104        backend_version: i32,
105        issuer_entries: Vec<(i64, CachedBrokerInfo)>,
106        broker_code_entries: Vec<(i64, i64)>,
107    ) -> Result<Self, BrokerDictionarySnapshotError> {
108        if backend_version < 0 {
109            return Err(BrokerDictionarySnapshotError::InvalidVersion);
110        }
111        let mut issuer_infos = BTreeMap::new();
112        for (issuer_id, info) in issuer_entries {
113            if issuer_id <= 0 {
114                return Err(BrokerDictionarySnapshotError::InvalidIssuer);
115            }
116            if issuer_infos.insert(issuer_id, info).is_some() {
117                return Err(BrokerDictionarySnapshotError::DuplicateIssuer);
118            }
119        }
120
121        let mut broker_code_to_issuer = BTreeMap::new();
122        for (broker_code, issuer_id) in broker_code_entries {
123            if broker_code <= 0 {
124                return Err(BrokerDictionarySnapshotError::InvalidBrokerCode);
125            }
126            if !issuer_infos.contains_key(&issuer_id) {
127                return Err(BrokerDictionarySnapshotError::DanglingBrokerCode);
128            }
129            if broker_code_to_issuer
130                .insert(broker_code, issuer_id)
131                .is_some()
132            {
133                return Err(BrokerDictionarySnapshotError::DuplicateBrokerCode);
134            }
135        }
136        Ok(Self {
137            initialized: true,
138            backend_version,
139            issuer_infos,
140            broker_code_to_issuer,
141        })
142    }
143
144    #[must_use]
145    pub const fn initialized(&self) -> bool {
146        self.initialized
147    }
148
149    #[must_use]
150    pub const fn backend_version(&self) -> i32 {
151        self.backend_version
152    }
153
154    #[must_use]
155    pub const fn issuer_infos(&self) -> &BTreeMap<i64, CachedBrokerInfo> {
156        &self.issuer_infos
157    }
158
159    #[must_use]
160    pub const fn broker_code_to_issuer(&self) -> &BTreeMap<i64, i64> {
161        &self.broker_code_to_issuer
162    }
163}
164
165pub struct BrokerDictionaryCache {
166    snapshot: ArcSwap<BrokerDictionarySnapshot>,
167}
168
169impl BrokerDictionaryCache {
170    #[must_use]
171    pub fn new() -> Self {
172        Self {
173            snapshot: ArcSwap::new(Arc::new(BrokerDictionarySnapshot::default())),
174        }
175    }
176
177    #[must_use]
178    pub fn snapshot(&self) -> Arc<BrokerDictionarySnapshot> {
179        self.snapshot.load_full()
180    }
181
182    pub fn replace(&self, snapshot: BrokerDictionarySnapshot) {
183        debug_assert!(snapshot.initialized());
184        self.snapshot.store(Arc::new(snapshot));
185    }
186
187    #[must_use]
188    pub fn has_last_good(&self) -> bool {
189        self.snapshot.load().initialized()
190    }
191
192    #[must_use]
193    pub fn display_name_for_issuer(&self, issuer_id: i64, app_lang: i32) -> Option<String> {
194        self.snapshot
195            .load()
196            .issuer_infos
197            .get(&issuer_id)
198            .and_then(|info| info.display_name(app_lang))
199    }
200
201    #[must_use]
202    pub fn display_name_for_broker_code(&self, broker_code: i64, app_lang: i32) -> Option<String> {
203        let snapshot = self.snapshot.load();
204        let issuer_id = snapshot.broker_code_to_issuer.get(&broker_code)?;
205        snapshot
206            .issuer_infos
207            .get(issuer_id)
208            .and_then(|info| info.display_name(app_lang))
209    }
210
211    #[must_use]
212    pub fn issuer_for_broker_code(&self, broker_code: i64) -> Option<i64> {
213        self.snapshot
214            .load()
215            .broker_code_to_issuer
216            .get(&broker_code)
217            .copied()
218    }
219
220    #[must_use]
221    pub fn contains_key(&self, issuer_id: &i64) -> bool {
222        self.snapshot.load().issuer_infos.contains_key(issuer_id)
223    }
224
225    #[must_use]
226    pub fn is_empty(&self) -> bool {
227        self.snapshot.load().issuer_infos.is_empty()
228    }
229
230    #[must_use]
231    pub fn len(&self) -> usize {
232        self.snapshot.load().issuer_infos.len()
233    }
234
235    /// Compatibility helper for tests and manually seeded embedded runtimes.
236    /// Production CMD18008 publication uses [`Self::replace`].
237    pub fn insert(&self, issuer_id: i64, info: CachedBrokerInfo) {
238        if issuer_id <= 0 {
239            return;
240        }
241        self.snapshot.rcu(|current| {
242            let mut issuer_entries = current
243                .issuer_infos
244                .iter()
245                .map(|(id, info)| (*id, info.clone()))
246                .collect::<Vec<_>>();
247            issuer_entries.retain(|(id, _)| *id != issuer_id);
248            issuer_entries.push((issuer_id, info.clone()));
249            let mut broker_code_entries = current
250                .broker_code_to_issuer
251                .iter()
252                .map(|(code, id)| (*code, *id))
253                .collect::<Vec<_>>();
254            if !current.broker_code_to_issuer.contains_key(&issuer_id) {
255                broker_code_entries.push((issuer_id, issuer_id));
256            }
257            match BrokerDictionarySnapshot::try_from_parts(
258                current.backend_version.max(0),
259                issuer_entries,
260                broker_code_entries,
261            ) {
262                Ok(snapshot) => Arc::new(snapshot),
263                Err(_) => {
264                    tracing::error!(
265                        error_class = "snapshot_invariant",
266                        "broker dictionary compatibility insert preserved last-good snapshot"
267                    );
268                    Arc::clone(current)
269                }
270            }
271        });
272    }
273}
274
275impl Default for BrokerDictionaryCache {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281#[derive(Debug, thiserror::Error)]
282pub enum BrokerDictionaryStoreError {
283    #[error("broker dictionary store I/O failed: {0}")]
284    Io(#[from] std::io::Error),
285    #[error("broker dictionary store JSON failed: {0}")]
286    Json(#[from] serde_json::Error),
287    #[error("broker dictionary store file is too large")]
288    TooLarge,
289    #[error("broker dictionary store schema is unsupported")]
290    UnsupportedSchema,
291    #[error("broker dictionary snapshot is uninitialized")]
292    Uninitialized,
293    #[error("broker dictionary snapshot is invalid: {0}")]
294    InvalidSnapshot(#[from] BrokerDictionarySnapshotError),
295}
296
297impl BrokerDictionaryStoreError {
298    #[must_use]
299    pub const fn class(&self) -> &'static str {
300        match self {
301            Self::Io(_) => "io",
302            Self::Json(_) => "json",
303            Self::TooLarge => "too_large",
304            Self::UnsupportedSchema => "unsupported_schema",
305            Self::Uninitialized => "uninitialized",
306            Self::InvalidSnapshot(_) => "invalid_snapshot",
307        }
308    }
309}
310
311#[derive(Debug, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313struct PersistedBrokerDictionary {
314    schema_version: u32,
315    backend_version: i32,
316    issuer_infos: Vec<PersistedIssuerInfo>,
317    broker_code_to_issuer: Vec<PersistedBrokerCode>,
318}
319
320#[derive(Debug, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322struct PersistedIssuerInfo {
323    issuer_id: i64,
324    info: CachedBrokerInfo,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329struct PersistedBrokerCode {
330    broker_code: i64,
331    issuer_id: i64,
332}
333
334#[derive(Debug, Clone)]
335pub struct BrokerDictionaryStore {
336    path: PathBuf,
337}
338
339impl BrokerDictionaryStore {
340    #[must_use]
341    pub fn new(path: impl Into<PathBuf>) -> Self {
342        Self { path: path.into() }
343    }
344
345    #[must_use]
346    pub fn path(&self) -> &Path {
347        &self.path
348    }
349
350    pub fn load(&self) -> Result<Option<BrokerDictionarySnapshot>, BrokerDictionaryStoreError> {
351        let mut file = match OpenOptions::new().read(true).open(&self.path) {
352            Ok(file) => file,
353            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
354            Err(error) => return Err(error.into()),
355        };
356        let metadata = file.metadata()?;
357        if metadata.len() > MAX_FILE_BYTES {
358            return Err(BrokerDictionaryStoreError::TooLarge);
359        }
360        let mut bytes = Vec::with_capacity(metadata.len() as usize);
361        file.read_to_end(&mut bytes)?;
362        let persisted: PersistedBrokerDictionary = serde_json::from_slice(&bytes)?;
363        if persisted.schema_version != SCHEMA_VERSION {
364            return Err(BrokerDictionaryStoreError::UnsupportedSchema);
365        }
366        let issuer_entries = persisted
367            .issuer_infos
368            .into_iter()
369            .map(|entry| (entry.issuer_id, entry.info))
370            .collect();
371        let broker_code_entries = persisted
372            .broker_code_to_issuer
373            .into_iter()
374            .map(|entry| (entry.broker_code, entry.issuer_id))
375            .collect();
376        Ok(Some(BrokerDictionarySnapshot::try_from_parts(
377            persisted.backend_version,
378            issuer_entries,
379            broker_code_entries,
380        )?))
381    }
382
383    pub fn save(
384        &self,
385        snapshot: &BrokerDictionarySnapshot,
386    ) -> Result<(), BrokerDictionaryStoreError> {
387        self.prepare_save(snapshot)?.commit()
388    }
389
390    /// Serialize and durably write a unique private temp file without changing
391    /// the visible last-good path. Callers may perform slow preparation before
392    /// acquiring their publication fence, then use
393    /// [`PreparedBrokerDictionarySave::commit`] for the terminal rename.
394    pub fn prepare_save(
395        &self,
396        snapshot: &BrokerDictionarySnapshot,
397    ) -> Result<PreparedBrokerDictionarySave, BrokerDictionaryStoreError> {
398        self.prepare_save_inner(snapshot, || {})
399    }
400
401    /// Same two-phase preparation with an observer invoked after the private
402    /// temp is opened and permissioned, immediately before payload write/fsync.
403    /// Runtime barrier tests use this exact I/O boundary so moving preparation
404    /// under a route lock becomes observable.
405    #[cfg(any(test, feature = "test-util"))]
406    pub fn prepare_save_with_hook(
407        &self,
408        snapshot: &BrokerDictionarySnapshot,
409        during_write: impl FnOnce(),
410    ) -> Result<PreparedBrokerDictionarySave, BrokerDictionaryStoreError> {
411        self.prepare_save_inner(snapshot, during_write)
412    }
413
414    fn prepare_save_inner(
415        &self,
416        snapshot: &BrokerDictionarySnapshot,
417        during_write: impl FnOnce(),
418    ) -> Result<PreparedBrokerDictionarySave, BrokerDictionaryStoreError> {
419        if !snapshot.initialized() {
420            return Err(BrokerDictionaryStoreError::Uninitialized);
421        }
422        let explicit_parent = self
423            .path
424            .parent()
425            .filter(|path| !path.as_os_str().is_empty());
426        let parent = explicit_parent.unwrap_or_else(|| Path::new("."));
427        if let Some(explicit_parent) = explicit_parent {
428            std::fs::create_dir_all(explicit_parent)?;
429        }
430        let persisted = PersistedBrokerDictionary {
431            schema_version: SCHEMA_VERSION,
432            backend_version: snapshot.backend_version,
433            issuer_infos: snapshot
434                .issuer_infos
435                .iter()
436                .map(|(issuer_id, info)| PersistedIssuerInfo {
437                    issuer_id: *issuer_id,
438                    info: info.clone(),
439                })
440                .collect(),
441            broker_code_to_issuer: snapshot
442                .broker_code_to_issuer
443                .iter()
444                .map(|(broker_code, issuer_id)| PersistedBrokerCode {
445                    broker_code: *broker_code,
446                    issuer_id: *issuer_id,
447                })
448                .collect(),
449        };
450        let bytes = serde_json::to_vec(&persisted)?;
451        let stem = self
452            .path
453            .file_name()
454            .and_then(|name| name.to_str())
455            .unwrap_or("broker-dictionary-v1.json");
456        let mut collisions = BTreeSet::new();
457        // Sixteen collision-resistant process-local candidates are enough for
458        // concurrent daemon saves; exhaustion is surfaced as I/O failure and
459        // never permits a non-atomic write. Replace this bound only if the
460        // store adopts a cross-process lock or OS-provided unnamed tempfile.
461        let mut during_write = Some(during_write);
462        for suffix in 0..16_u8 {
463            let temporary = parent.join(format!(".{stem}.{}.{suffix}.tmp", std::process::id()));
464            let mut options = OpenOptions::new();
465            options.write(true).create_new(true);
466            #[cfg(unix)]
467            {
468                use std::os::unix::fs::OpenOptionsExt as _;
469                options.mode(0o600);
470            }
471            let mut file = match options.open(&temporary) {
472                Ok(file) => file,
473                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
474                    collisions.insert(suffix);
475                    continue;
476                }
477                Err(error) => return Err(error.into()),
478            };
479            if let Err(error) = set_file_private(&temporary) {
480                let _ = std::fs::remove_file(&temporary);
481                return Err(error.into());
482            }
483            if let Some(during_write) = during_write.take() {
484                during_write();
485            }
486            if let Err(error) = write_prepared(&mut file, &bytes) {
487                let _ = std::fs::remove_file(&temporary);
488                return Err(error.into());
489            }
490            return Ok(PreparedBrokerDictionarySave {
491                temporary,
492                destination: self.path.clone(),
493                parent: parent.to_path_buf(),
494                committed: false,
495            });
496        }
497        Err(std::io::Error::other(format!(
498            "broker dictionary temp names unavailable: {} collisions",
499            collisions.len()
500        ))
501        .into())
502    }
503}
504
505pub struct PreparedBrokerDictionarySave {
506    temporary: PathBuf,
507    destination: PathBuf,
508    parent: PathBuf,
509    committed: bool,
510}
511
512impl PreparedBrokerDictionarySave {
513    /// Atomically replace the visible snapshot. This terminal step contains no
514    /// JSON serialization or payload write. The prepared file is synchronized
515    /// before this safe cross-platform rename, and Unix also synchronizes the
516    /// containing directory after publication.
517    pub fn commit(mut self) -> Result<(), BrokerDictionaryStoreError> {
518        atomic_replace(&self.temporary, &self.destination)?;
519        self.committed = true;
520        set_file_private(&self.destination)?;
521        sync_directory(&self.parent)?;
522        Ok(())
523    }
524}
525
526impl Drop for PreparedBrokerDictionarySave {
527    fn drop(&mut self) {
528        if !self.committed {
529            let _ = std::fs::remove_file(&self.temporary);
530        }
531    }
532}
533
534#[must_use]
535pub fn default_broker_dictionary_path_from_home(home: &Path) -> PathBuf {
536    home.join(".futu-opend-rs")
537        .join("broker-dictionary-v1.json")
538}
539
540#[must_use]
541pub fn default_broker_dictionary_store() -> Option<BrokerDictionaryStore> {
542    std::env::var_os("HOME")
543        .or_else(|| std::env::var_os("USERPROFILE"))
544        .map(PathBuf::from)
545        .map(|home| BrokerDictionaryStore::new(default_broker_dictionary_path_from_home(&home)))
546}
547
548fn write_prepared(file: &mut File, bytes: &[u8]) -> std::io::Result<()> {
549    file.write_all(bytes)?;
550    file.sync_all()
551}
552
553fn atomic_replace(temporary: &Path, destination: &Path) -> std::io::Result<()> {
554    // Rust 1.95's std::fs::rename contract replaces an existing destination on
555    // both Unix and Windows. On Windows the standard library owns the
556    // MoveFileExW / SetFileInformationByHandle details, so this crate does not
557    // need a local unsafe FFI boundary.
558    std::fs::rename(temporary, destination)
559}
560
561#[cfg(unix)]
562fn set_file_private(path: &Path) -> std::io::Result<()> {
563    use std::os::unix::fs::PermissionsExt;
564    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
565}
566
567#[cfg(not(unix))]
568fn set_file_private(_path: &Path) -> std::io::Result<()> {
569    Ok(())
570}
571
572#[cfg(not(windows))]
573fn sync_directory(path: &Path) -> std::io::Result<()> {
574    File::open(path)?.sync_all()
575}
576
577#[cfg(windows)]
578fn sync_directory(_path: &Path) -> std::io::Result<()> {
579    // The prepared file was synchronized before std::fs::rename; opening a
580    // directory as `File` is not portable on Windows.
581    Ok(())
582}