futu_cache/stock_note_state/
store.rs1use std::fs::{File, OpenOptions};
2use std::io::{Read, Write};
3use std::path::{Path, PathBuf};
4
5use futu_domain_qot_stock_note::ProjectedNoteBasic;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9const SCHEMA_VERSION: u32 = 1;
10const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct LoadedStockNotes {
14 pub revision: u64,
15 pub basics: Vec<ProjectedNoteBasic>,
16}
17
18#[derive(Debug, thiserror::Error)]
19pub enum StockNoteStoreError {
20 #[error("stock-note store I/O failed: {0}")]
21 Io(#[from] std::io::Error),
22 #[error("stock-note store JSON failed: {0}")]
23 Json(#[from] serde_json::Error),
24 #[error("stock-note store file is too large")]
25 TooLarge,
26 #[error("stock-note store schema is unsupported")]
27 UnsupportedSchema,
28 #[error("stock-note store identity does not match the requested user")]
29 IdentityMismatch,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34struct PersistedStockNotes {
35 schema_version: u32,
36 uid_fingerprint: String,
37 revision: u64,
38 basics: Vec<ProjectedNoteBasic>,
39}
40
41#[derive(Debug, Clone)]
42pub struct StockNoteStore {
43 root: PathBuf,
44}
45
46impl StockNoteStore {
47 #[must_use]
48 pub fn new(root: impl Into<PathBuf>) -> Self {
49 Self { root: root.into() }
50 }
51
52 #[must_use]
53 pub fn path_for_uid(&self, uid: u64) -> PathBuf {
54 self.root
55 .join(format!("stock-note-{}.json", uid_fingerprint(uid)))
56 }
57
58 pub fn load(&self, uid: u64) -> Result<Option<LoadedStockNotes>, StockNoteStoreError> {
59 let path = self.path_for_uid(uid);
60 let mut file = match OpenOptions::new().read(true).open(&path) {
61 Ok(file) => file,
62 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
63 Err(error) => return Err(error.into()),
64 };
65 let metadata = file.metadata()?;
66 if metadata.len() > MAX_FILE_BYTES {
67 return Err(StockNoteStoreError::TooLarge);
68 }
69 let mut bytes = Vec::with_capacity(metadata.len() as usize);
70 file.read_to_end(&mut bytes)?;
71 let persisted: PersistedStockNotes = serde_json::from_slice(&bytes)?;
72 if persisted.schema_version != SCHEMA_VERSION {
73 return Err(StockNoteStoreError::UnsupportedSchema);
74 }
75 if persisted.uid_fingerprint != uid_fingerprint(uid) {
76 return Err(StockNoteStoreError::IdentityMismatch);
77 }
78 Ok(Some(LoadedStockNotes {
79 revision: persisted.revision,
80 basics: persisted.basics.into_iter().filter(admissible).collect(),
81 }))
82 }
83
84 pub fn save(
85 &self,
86 uid: u64,
87 revision: u64,
88 basics: &[ProjectedNoteBasic],
89 ) -> Result<(), StockNoteStoreError> {
90 std::fs::create_dir_all(&self.root)?;
91 set_directory_private(&self.root)?;
92 let persisted = PersistedStockNotes {
93 schema_version: SCHEMA_VERSION,
94 uid_fingerprint: uid_fingerprint(uid),
95 revision,
96 basics: basics
97 .iter()
98 .filter(|item| admissible(item))
99 .cloned()
100 .collect(),
101 };
102 let bytes = serde_json::to_vec(&persisted)?;
103 let destination = self.path_for_uid(uid);
104 let mut last_collision = None;
105 for suffix in 0..16_u8 {
106 let temporary = self.root.join(format!(
107 ".stock-note-{}-{}-{revision}-{suffix}.tmp",
108 uid_fingerprint(uid),
109 std::process::id()
110 ));
111 let mut file = match OpenOptions::new()
112 .write(true)
113 .create_new(true)
114 .open(&temporary)
115 {
116 Ok(file) => file,
117 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
118 last_collision = Some(error);
119 continue;
120 }
121 Err(error) => return Err(error.into()),
122 };
123 set_file_private(&temporary)?;
124 if let Err(error) = write_and_commit(&mut file, &temporary, &destination, &bytes) {
125 let _ = std::fs::remove_file(&temporary);
126 return Err(error.into());
127 }
128 set_file_private(&destination)?;
129 sync_directory(&self.root)?;
130 return Ok(());
131 }
132 Err(last_collision
133 .unwrap_or_else(|| std::io::Error::other("stock-note temp name unavailable"))
134 .into())
135 }
136}
137
138#[must_use]
139pub fn default_stock_note_root_from_home(home: &Path) -> PathBuf {
140 home.join(".futu-opend-rs").join("stock-notes-v1")
141}
142
143#[must_use]
144pub fn default_stock_note_store() -> Option<StockNoteStore> {
145 std::env::var_os("HOME")
146 .or_else(|| std::env::var_os("USERPROFILE"))
147 .map(PathBuf::from)
148 .map(|home| StockNoteStore::new(default_stock_note_root_from_home(&home)))
149}
150
151fn write_and_commit(
152 file: &mut File,
153 temporary: &Path,
154 destination: &Path,
155 bytes: &[u8],
156) -> std::io::Result<()> {
157 file.write_all(bytes)?;
158 file.sync_all()?;
159 std::fs::rename(temporary, destination)
160}
161
162fn uid_fingerprint(uid: u64) -> String {
163 let mut hasher = Sha256::new();
164 hasher.update(b"futu-stock-note-user-v1:");
165 hasher.update(uid.to_be_bytes());
166 hex::encode(hasher.finalize())[..32].to_owned()
167}
168
169fn admissible(item: &ProjectedNoteBasic) -> bool {
170 item.stock_id.present && item.stock_id.value != 0
171}
172
173#[cfg(unix)]
174fn set_file_private(path: &Path) -> std::io::Result<()> {
175 use std::os::unix::fs::PermissionsExt;
176 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
177}
178
179#[cfg(not(unix))]
180fn set_file_private(_path: &Path) -> std::io::Result<()> {
181 Ok(())
182}
183
184#[cfg(unix)]
185fn set_directory_private(path: &Path) -> std::io::Result<()> {
186 use std::os::unix::fs::PermissionsExt;
187 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
188}
189
190#[cfg(not(unix))]
191fn set_directory_private(_path: &Path) -> std::io::Result<()> {
192 Ok(())
193}
194
195fn sync_directory(path: &Path) -> std::io::Result<()> {
196 File::open(path)?.sync_all()
197}