Skip to main content

actr_platform_native/
platform.rs

1//! Native platform provider (filesystem + SQLite).
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use tokio::sync::Mutex;
8use tracing::{debug, info, warn};
9
10use actr_platform_traits::{CryptoProvider, KvStore, PlatformError, PlatformProvider};
11
12use crate::crypto::NativeCryptoProvider;
13
14const INSTANCE_UID_FILE: &str = ".hyper-instance-uid";
15
16/// Native platform provider backed by a filesystem directory and SQLite.
17///
18/// All provider state lives under `data_dir`. The directory is created on
19/// first use; callers never have to set it up.
20pub struct NativePlatformProvider {
21    data_dir: PathBuf,
22    crypto: Arc<NativeCryptoProvider>,
23    data_dir_ready: Mutex<bool>,
24}
25
26impl NativePlatformProvider {
27    /// Build a provider rooted at `data_dir`.
28    ///
29    /// The directory does not need to exist yet — it's created lazily on the
30    /// first method call that needs it.
31    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
32        Self {
33            data_dir: data_dir.into(),
34            crypto: Arc::new(NativeCryptoProvider),
35            data_dir_ready: Mutex::new(false),
36        }
37    }
38
39    async fn ensure_data_dir(&self) -> Result<(), PlatformError> {
40        let mut ready = self.data_dir_ready.lock().await;
41        if *ready {
42            return Ok(());
43        }
44        tokio::fs::create_dir_all(&self.data_dir)
45            .await
46            .map_err(|e| {
47                PlatformError::Io(format!(
48                    "failed to create data_dir `{}`: {e}",
49                    self.data_dir.display()
50                ))
51            })?;
52        *ready = true;
53        Ok(())
54    }
55}
56
57#[async_trait]
58impl PlatformProvider for NativePlatformProvider {
59    async fn instance_uid(&self) -> Result<String, PlatformError> {
60        self.ensure_data_dir().await?;
61        let uid_file = self.data_dir.join(INSTANCE_UID_FILE);
62
63        match tokio::fs::read_to_string(&uid_file).await {
64            Ok(raw) => {
65                let id = raw.trim();
66                if !id.is_empty() {
67                    return Ok(id.to_string());
68                }
69                warn!("instance_uid file is empty; regenerating");
70            }
71            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
72            Err(e) => {
73                return Err(PlatformError::Io(format!(
74                    "failed to read instance_uid: {e}"
75                )));
76            }
77        }
78
79        let new_id = uuid::Uuid::new_v4().to_string();
80        tokio::fs::write(&uid_file, &new_id)
81            .await
82            .map_err(|e| PlatformError::Io(format!("failed to write instance_uid: {e}")))?;
83        info!(instance_uid = %new_id, "generated new instance_uid");
84        Ok(new_id)
85    }
86
87    async fn secret_store(&self, namespace: &str) -> Result<Arc<dyn KvStore>, PlatformError> {
88        self.ensure_data_dir().await?;
89        // `namespace` is resolved as a filesystem path — ActorStore expects a
90        // writable SQLite location. Callers compose absolute paths through
91        // Hyper's NamespaceResolver today, so we pass it through verbatim.
92        let path = Path::new(namespace);
93        if let Some(parent) = path.parent()
94            && !parent.as_os_str().is_empty()
95        {
96            tokio::fs::create_dir_all(parent).await.map_err(|e| {
97                PlatformError::Io(format!(
98                    "failed to create secret store parent `{}`: {e}",
99                    parent.display()
100                ))
101            })?;
102        }
103        let store = actr_hyper::ActorStore::open(path)
104            .await
105            .map_err(|e| PlatformError::Storage(format!("failed to open ActorStore: {e}")))?;
106        debug!(namespace, "native secret store opened");
107        Ok(Arc::new(store))
108    }
109
110    fn crypto(&self) -> Arc<dyn CryptoProvider> {
111        self.crypto.clone()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use tempfile::TempDir;
119
120    #[tokio::test]
121    async fn instance_uid_stable_across_calls() {
122        let dir = TempDir::new().unwrap();
123        let provider = NativePlatformProvider::new(dir.path());
124
125        let id1 = provider.instance_uid().await.unwrap();
126        let id2 = provider.instance_uid().await.unwrap();
127        assert_eq!(id1, id2);
128    }
129
130    #[tokio::test]
131    async fn instance_uid_creates_data_dir_lazily() {
132        let parent = TempDir::new().unwrap();
133        let nested = parent.path().join("a/b/c");
134        assert!(!nested.exists());
135
136        let provider = NativePlatformProvider::new(&nested);
137        provider.instance_uid().await.unwrap();
138        assert!(nested.exists());
139    }
140
141    #[tokio::test]
142    async fn secret_store_roundtrip() {
143        let dir = TempDir::new().unwrap();
144        let db_path = dir.path().join("test.db");
145        let provider = NativePlatformProvider::new(dir.path());
146
147        let store = provider
148            .secret_store(db_path.to_str().unwrap())
149            .await
150            .unwrap();
151
152        store.set("key", b"value").await.unwrap();
153        let val = store.get("key").await.unwrap();
154        assert_eq!(val, Some(b"value".to_vec()));
155    }
156}