Skip to main content

futu_cache/stock_note_state/
store.rs

1use 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}