1use serde::Serialize;
8use std::path::PathBuf;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct BackendScope {
13 pub environment: String,
14 pub api_base_url: String,
15 pub ws_url: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct LocalStoreIdentity {
20 pub app_namespace: String,
21 pub backend: BackendScope,
22 pub company_id: String,
23 pub account_id: String,
24 pub tenant_hint: Option<String>,
25 pub schema_family: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LocalStorePaths {
30 pub key: String,
31 pub dir: PathBuf,
32 pub db_path: PathBuf,
33 pub db_url: String,
34 pub manifest_path: PathBuf,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct LocalStoreLocator {
39 pub base_dir: PathBuf,
40}
41
42#[derive(Debug, thiserror::Error)]
43pub enum LocalStoreError {
44 #[error("missing local store identity field: {0}")]
45 MissingField(&'static str),
46 #[error("create local store directory failed: {0}")]
47 CreateDir(std::io::Error),
48 #[error("write local store manifest failed: {0}")]
49 WriteManifest(std::io::Error),
50 #[error("serialize local store manifest failed: {0}")]
51 SerializeManifest(serde_json::Error),
52}
53
54#[derive(Serialize)]
55#[serde(rename_all = "camelCase")]
56struct LocalStoreManifest<'a> {
57 version: u32,
58 local_store_key: &'a str,
59 app_namespace: &'a str,
60 environment: &'a str,
61 api_base_url: &'a str,
62 ws_url: &'a str,
63 company_id: &'a str,
64 account_id: &'a str,
65 tenant_hint: &'a str,
66 schema_family: &'a str,
67 created_at_ms: i64,
68 last_opened_at_ms: i64,
69}
70
71impl LocalStoreLocator {
72 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
73 Self {
74 base_dir: base_dir.into(),
75 }
76 }
77
78 pub fn resolve(
79 &self,
80 identity: &LocalStoreIdentity,
81 ) -> Result<LocalStorePaths, LocalStoreError> {
82 validate_identity(identity)?;
83
84 let key = local_store_key(identity);
85 let dir = self.base_dir.join("data").join(&key);
86 std::fs::create_dir_all(&dir).map_err(LocalStoreError::CreateDir)?;
87
88 let db_path = dir.join("tenant.db");
89 let db_url = format!("sqlite:file:{}", db_path.to_string_lossy());
90 let manifest_path = dir.join("manifest.json");
91 let now = now_ms();
92 let created_at_ms = existing_created_at_ms(&manifest_path).unwrap_or(now);
93 let tenant_hint = identity.tenant_hint.as_deref().unwrap_or("");
94 let manifest = LocalStoreManifest {
95 version: 1,
96 local_store_key: &key,
97 app_namespace: identity.app_namespace.trim(),
98 environment: identity.backend.environment.trim(),
99 api_base_url: identity.backend.api_base_url.trim(),
100 ws_url: identity.backend.ws_url.trim(),
101 company_id: identity.company_id.trim(),
102 account_id: identity.account_id.trim(),
103 tenant_hint: tenant_hint.trim(),
104 schema_family: identity.schema_family.trim(),
105 created_at_ms,
106 last_opened_at_ms: now,
107 };
108 let raw =
109 serde_json::to_vec_pretty(&manifest).map_err(LocalStoreError::SerializeManifest)?;
110 std::fs::write(&manifest_path, raw).map_err(LocalStoreError::WriteManifest)?;
111
112 Ok(LocalStorePaths {
113 key,
114 dir,
115 db_path,
116 db_url,
117 manifest_path,
118 })
119 }
120}
121
122fn validate_identity(identity: &LocalStoreIdentity) -> Result<(), LocalStoreError> {
123 validate_non_empty(&identity.app_namespace, "app_namespace")?;
124 validate_non_empty(&identity.backend.environment, "environment")?;
125 validate_non_empty(&identity.backend.api_base_url, "api_base_url")?;
126 validate_non_empty(&identity.backend.ws_url, "ws_url")?;
127 validate_non_empty(&identity.company_id, "company_id")?;
128 validate_non_empty(&identity.account_id, "account_id")?;
129 validate_non_empty(&identity.schema_family, "schema_family")?;
130 Ok(())
131}
132
133fn validate_non_empty(value: &str, field: &'static str) -> Result<(), LocalStoreError> {
134 if value.trim().is_empty() {
135 Err(LocalStoreError::MissingField(field))
136 } else {
137 Ok(())
138 }
139}
140
141fn local_store_key(identity: &LocalStoreIdentity) -> String {
142 format!("s_{:016x}", fnv1a64(&canonical_identity(identity)))
143}
144
145fn canonical_identity(identity: &LocalStoreIdentity) -> String {
146 let mut out = String::new();
147 push_canonical_field(&mut out, "app_namespace", identity.app_namespace.trim());
148 push_canonical_field(&mut out, "environment", identity.backend.environment.trim());
149 push_canonical_field(
150 &mut out,
151 "api_base_url",
152 identity.backend.api_base_url.trim(),
153 );
154 push_canonical_field(&mut out, "ws_url", identity.backend.ws_url.trim());
155 push_canonical_field(&mut out, "company_id", identity.company_id.trim());
156 push_canonical_field(&mut out, "account_id", identity.account_id.trim());
157 push_canonical_field(
158 &mut out,
159 "tenant_hint",
160 identity.tenant_hint.as_deref().unwrap_or("").trim(),
161 );
162 push_canonical_field(&mut out, "schema_family", identity.schema_family.trim());
163 out
164}
165
166fn push_canonical_field(out: &mut String, name: &str, value: &str) {
167 out.push_str(name);
168 out.push(':');
169 out.push_str(&value.len().to_string());
170 out.push(':');
171 out.push_str(value);
172 out.push('\n');
173}
174
175fn fnv1a64(input: &str) -> u64 {
176 let mut hash = 0xcbf29ce484222325u64;
177 for b in input.as_bytes() {
178 hash ^= *b as u64;
179 hash = hash.wrapping_mul(0x100000001b3);
180 }
181 hash
182}
183
184fn now_ms() -> i64 {
185 SystemTime::now()
186 .duration_since(UNIX_EPOCH)
187 .map(|d| d.as_millis() as i64)
188 .unwrap_or(0)
189}
190
191fn existing_created_at_ms(path: &PathBuf) -> Option<i64> {
192 let raw = std::fs::read_to_string(path).ok()?;
193 let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
194 json.get("createdAtMs").and_then(serde_json::Value::as_i64)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::sync::atomic::{AtomicUsize, Ordering};
201
202 static DIR_SEQ: AtomicUsize = AtomicUsize::new(0);
203
204 fn temp_base(label: &str) -> PathBuf {
205 let seq = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
206 let path = std::env::temp_dir().join(format!(
207 "helix_local_store_{label}_{}_{}",
208 std::process::id(),
209 seq
210 ));
211 let _ = std::fs::remove_dir_all(&path);
212 path
213 }
214
215 fn identity(account_id: &str) -> LocalStoreIdentity {
216 LocalStoreIdentity {
217 app_namespace: "com.jinqidongli.cses.dev".to_string(),
218 backend: BackendScope {
219 environment: "dev".to_string(),
220 api_base_url: "http://127.0.0.1:8066/api/cses".to_string(),
221 ws_url: "ws://127.0.0.1:8066/api/cses/websocket".to_string(),
222 },
223 company_id: "64118".to_string(),
224 account_id: account_id.to_string(),
225 tenant_hint: Some(account_id.to_string()),
226 schema_family: "helix-im-v1".to_string(),
227 }
228 }
229
230 #[test]
231 fn same_identity_produces_stable_key() {
232 let locator = LocalStoreLocator::new(temp_base("stable"));
233 let first = locator.resolve(&identity("444")).expect("first resolve");
234 let second = locator.resolve(&identity("444")).expect("second resolve");
235
236 assert_eq!(first.key, second.key);
237 assert_eq!(first.db_path, second.db_path);
238 assert_eq!(first.key.len(), 18);
239 assert!(first.key.starts_with("s_"));
240 }
241
242 #[test]
243 fn different_accounts_produce_different_paths() {
244 let locator = LocalStoreLocator::new(temp_base("different"));
245 let user_444 = locator.resolve(&identity("444")).expect("resolve 444");
246 let user_678 = locator.resolve(&identity("678")).expect("resolve 678");
247
248 assert_ne!(user_444.key, user_678.key);
249 assert_ne!(user_444.db_path, user_678.db_path);
250 assert!(user_444.db_path.ends_with("tenant.db"));
251 assert!(user_678.db_path.ends_with("tenant.db"));
252 }
253
254 #[test]
255 fn missing_required_fields_are_rejected() {
256 let locator = LocalStoreLocator::new(temp_base("missing"));
257 let mut broken = identity("444");
258 broken.company_id.clear();
259
260 let err = locator.resolve(&broken).expect_err("missing company_id");
261 assert!(matches!(err, LocalStoreError::MissingField("company_id")));
262 }
263
264 #[test]
265 fn manifest_contains_identity_but_no_secret_fields() {
266 let locator = LocalStoreLocator::new(temp_base("manifest"));
267 let paths = locator.resolve(&identity("444")).expect("resolve");
268 let raw = std::fs::read_to_string(paths.manifest_path).expect("read manifest");
269 let json: serde_json::Value = serde_json::from_str(&raw).expect("manifest json");
270
271 assert_eq!(json["accountId"], "444");
272 assert_eq!(json["companyId"], "64118");
273 assert_eq!(json["localStoreKey"], paths.key);
274 assert!(json.get("cookie").is_none());
275 assert!(json.get("token").is_none());
276 assert!(json.get("authorization").is_none());
277 }
278
279 #[test]
280 fn existing_manifest_preserves_created_at_ms() {
281 let locator = LocalStoreLocator::new(temp_base("created_at"));
282 let paths = locator.resolve(&identity("444")).expect("resolve");
283 let mut json: serde_json::Value =
284 serde_json::from_str(&std::fs::read_to_string(&paths.manifest_path).unwrap()).unwrap();
285 json["createdAtMs"] = serde_json::json!(123_i64);
286 std::fs::write(
287 &paths.manifest_path,
288 serde_json::to_vec_pretty(&json).unwrap(),
289 )
290 .unwrap();
291
292 let paths = locator.resolve(&identity("444")).expect("resolve again");
293 let raw = std::fs::read_to_string(paths.manifest_path).expect("read manifest");
294 let json: serde_json::Value = serde_json::from_str(&raw).expect("manifest json");
295 assert_eq!(json["createdAtMs"], 123_i64);
296 assert!(json["lastOpenedAtMs"].as_i64().unwrap_or(0) >= 123_i64);
297 }
298}