Skip to main content

helix_driver_host/
local_store.rs

1//! Host-side local store locator.
2//!
3//! This module owns the platform path decision for per-account SQLite stores.
4//! It deliberately stays in driver-host: core and business modules receive a
5//! Storage port and never learn about filesystem layout or tenant identity.
6
7use 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}