Skip to main content

pipedash_core/infrastructure/config/
manager.rs

1use std::path::{
2    Path,
3    PathBuf,
4};
5use std::sync::Arc;
6
7use tokio::sync::RwLock;
8
9use crate::domain::{
10    DomainError,
11    DomainResult,
12};
13use crate::infrastructure::config::{
14    PipedashConfig,
15    StorageBackend as ConfigStorageBackend,
16};
17use crate::infrastructure::database::init_database;
18#[cfg(feature = "postgres")]
19use crate::infrastructure::secrets::PostgresTokenStore;
20use crate::infrastructure::secrets::SqliteTokenStore;
21use crate::infrastructure::storage::{
22    LocalStorage,
23    StorageBackend,
24};
25use crate::infrastructure::token_store::{
26    MemoryTokenStore,
27    TokenStore,
28};
29#[cfg(feature = "postgres")]
30use crate::infrastructure::PostgresConfigBackend;
31use crate::infrastructure::{
32    ConfigBackend,
33    SqliteConfigBackend,
34};
35
36pub struct StorageManager {
37    config: PipedashConfig,
38    token_store: Arc<RwLock<Arc<dyn TokenStore>>>,
39    config_backend: Arc<dyn ConfigBackend>,
40    cache_backend: Arc<dyn StorageBackend>,
41    database_pool: crate::infrastructure::database::DatabasePool,
42    vault_locked: Arc<RwLock<bool>>,
43    data_dir: PathBuf,
44    is_desktop: bool,
45}
46
47impl StorageManager {
48    pub async fn from_config(config: PipedashConfig, is_desktop: bool) -> DomainResult<Self> {
49        Self::from_config_internal(config, is_desktop, false).await
50    }
51
52    pub async fn from_config_allow_locked(
53        config: PipedashConfig, is_desktop: bool,
54    ) -> DomainResult<Self> {
55        Self::from_config_internal(config, is_desktop, true).await
56    }
57
58    async fn from_config_internal(
59        config: PipedashConfig, is_desktop: bool, allow_locked: bool,
60    ) -> DomainResult<Self> {
61        let data_dir = config.data_dir();
62
63        std::fs::create_dir_all(&data_dir).map_err(|e| {
64            DomainError::DatabaseError(format!("Failed to create data directory: {}", e))
65        })?;
66
67        let backend = config.storage.backend;
68
69        #[cfg(feature = "postgres")]
70        if backend == ConfigStorageBackend::Postgres {
71            use crate::infrastructure::database::init_postgres_database;
72
73            init_postgres_database(&config.storage.postgres.connection_string)
74                .await
75                .map_err(|e| {
76                    DomainError::DatabaseError(format!(
77                        "Failed to initialize PostgreSQL database: {}",
78                        e
79                    ))
80                })?;
81
82            tracing::info!("PostgreSQL migrations completed");
83        }
84
85        let (token_store, vault_locked) =
86            match Self::create_token_store(&config, backend, is_desktop, &data_dir).await {
87                Ok(store) => (store, false),
88                Err(e) if allow_locked && Self::is_vault_password_error(&e) => {
89                    tracing::warn!("Vault password not set - starting in locked mode");
90                    (
91                        Arc::new(MemoryTokenStore::new()) as Arc<dyn TokenStore>,
92                        true,
93                    )
94                }
95                Err(e) => return Err(e),
96            };
97
98        let (config_backend, database_pool) =
99            Self::create_config_backend(&config, backend, &data_dir).await?;
100        let cache_backend = Self::create_cache_backend(&config, backend, &data_dir).await?;
101
102        Ok(Self {
103            config,
104            token_store: Arc::new(RwLock::new(token_store)),
105            config_backend,
106            cache_backend,
107            database_pool,
108            vault_locked: Arc::new(RwLock::new(vault_locked)),
109            data_dir,
110            is_desktop,
111        })
112    }
113
114    fn is_vault_password_error(error: &DomainError) -> bool {
115        match error {
116            DomainError::InvalidConfig(msg) => {
117                msg.contains("PIPEDASH_VAULT_PASSWORD") || msg.contains("vault password")
118            }
119            DomainError::AuthenticationFailed(msg) => {
120                msg.contains("vault password") || msg.contains("decrypt")
121            }
122            _ => false,
123        }
124    }
125
126    pub async fn is_vault_locked(&self) -> bool {
127        *self.vault_locked.read().await
128    }
129
130    pub fn requires_vault_password(&self) -> bool {
131        match (self.config.storage.backend, self.is_desktop) {
132            (ConfigStorageBackend::Sqlite, true) => false, // Keyring
133            (ConfigStorageBackend::Sqlite, false) => true, // SQLite encrypted
134            (ConfigStorageBackend::Postgres, _) => true,   // Postgres encrypted
135        }
136    }
137
138    pub async fn is_first_time_vault_setup(&self) -> DomainResult<bool> {
139        use crate::infrastructure::database::DatabasePool;
140
141        const QUERY: &str = "SELECT 1 FROM vault_verifier";
142        let verifier = match &self.database_pool {
143            DatabasePool::Sqlite(pool) => sqlx::query(QUERY)
144                .fetch_optional(pool)
145                .await
146                .map(|r| r.is_some()),
147            DatabasePool::Postgres(pool) => sqlx::query(QUERY)
148                .fetch_optional(pool)
149                .await
150                .map(|r| r.is_some()),
151        }
152        .map_err(|e| DomainError::DatabaseError(format!("Failed to read vault verifier: {}", e)))?;
153
154        Ok(!verifier && self.config_backend.list_providers().await?.is_empty())
155    }
156
157    pub async fn unlock_vault(&self, password: &str) -> DomainResult<()> {
158        if !*self.vault_locked.read().await {
159            return Ok(()); // Already unlocked
160        }
161
162        std::env::set_var("PIPEDASH_VAULT_PASSWORD", password);
163
164        let new_store = Self::create_token_store_with_password(
165            &self.config,
166            self.config.storage.backend,
167            self.is_desktop,
168            &self.data_dir,
169            password,
170        )
171        .await?;
172
173        {
174            let mut store = self.token_store.write().await;
175            *store = new_store;
176        }
177
178        {
179            let mut locked = self.vault_locked.write().await;
180            *locked = false;
181        }
182
183        tracing::info!("Vault unlocked successfully");
184        Ok(())
185    }
186
187    async fn create_token_store_with_password(
188        config: &PipedashConfig, backend: ConfigStorageBackend, _is_desktop: bool, data_dir: &Path,
189        password: &str,
190    ) -> DomainResult<Arc<dyn TokenStore>> {
191        match backend {
192            ConfigStorageBackend::Sqlite => {
193                let db_path = data_dir.join("pipedash.db");
194                let pool = init_database(db_path).await.map_err(|e| {
195                    DomainError::DatabaseError(format!(
196                        "Failed to initialize SQLite database for token storage: {}",
197                        e
198                    ))
199                })?;
200
201                let store = SqliteTokenStore::new(pool, Some(password.to_string())).await?;
202                Ok(Arc::new(store))
203            }
204            ConfigStorageBackend::Postgres => {
205                #[cfg(feature = "postgres")]
206                {
207                    let connection_string = &config.storage.postgres.connection_string;
208                    if connection_string.is_empty() {
209                        return Err(DomainError::InvalidConfig(
210                            "PostgreSQL connection string required for token storage".into(),
211                        ));
212                    }
213
214                    let store =
215                        PostgresTokenStore::new(connection_string, Some(password.to_string()))
216                            .await?;
217                    Ok(Arc::new(store))
218                }
219                #[cfg(not(feature = "postgres"))]
220                {
221                    let _ = config; // silence unused warning
222                    Err(DomainError::InvalidConfig(
223                        "PostgreSQL feature not enabled".into(),
224                    ))
225                }
226            }
227        }
228    }
229
230    async fn create_token_store(
231        config: &PipedashConfig, backend: ConfigStorageBackend, is_desktop: bool, data_dir: &Path,
232    ) -> DomainResult<Arc<dyn TokenStore>> {
233        let vault_password = config
234            .storage
235            .vault_password
236            .clone()
237            .or_else(|| std::env::var("PIPEDASH_VAULT_PASSWORD").ok());
238
239        match (backend, is_desktop) {
240            (ConfigStorageBackend::Sqlite, true) if vault_password.is_none() => {
241                Err(DomainError::InvalidConfig(
242                    "Keyring backend must be created externally (Tauri-specific)".into(),
243                ))
244            }
245            (ConfigStorageBackend::Sqlite, _) => {
246                let db_path = data_dir.join("pipedash.db");
247                let pool = init_database(db_path).await.map_err(|e| {
248                    DomainError::DatabaseError(format!(
249                        "Failed to initialize SQLite database for token storage: {}",
250                        e
251                    ))
252                })?;
253
254                let store = SqliteTokenStore::new(pool, vault_password).await?;
255                Ok(Arc::new(store))
256            }
257            (ConfigStorageBackend::Postgres, _) => {
258                #[cfg(feature = "postgres")]
259                {
260                    let connection_string = &config.storage.postgres.connection_string;
261                    if connection_string.is_empty() {
262                        return Err(DomainError::InvalidConfig(
263                            "PostgreSQL connection string required for token storage".into(),
264                        ));
265                    }
266
267                    let vault_password = config
268                        .storage
269                        .vault_password
270                        .clone()
271                        .or_else(|| std::env::var("PIPEDASH_VAULT_PASSWORD").ok());
272                    let store = PostgresTokenStore::new(connection_string, vault_password).await?;
273                    Ok(Arc::new(store))
274                }
275                #[cfg(not(feature = "postgres"))]
276                {
277                    let _ = config; // silence unused warning
278                    Err(DomainError::InvalidConfig(
279                        "PostgreSQL feature not enabled".into(),
280                    ))
281                }
282            }
283        }
284    }
285
286    async fn create_config_backend(
287        config: &PipedashConfig, backend: ConfigStorageBackend, data_dir: &Path,
288    ) -> DomainResult<(
289        Arc<dyn ConfigBackend>,
290        crate::infrastructure::database::DatabasePool,
291    )> {
292        match backend {
293            ConfigStorageBackend::Sqlite => {
294                let db_path = data_dir.join("pipedash.db");
295                let pool = init_database(db_path).await.map_err(|e| {
296                    DomainError::DatabaseError(format!("Failed to initialize database: {}", e))
297                })?;
298                let db_pool = crate::infrastructure::database::DatabasePool::Sqlite(pool.clone());
299                let config_backend = SqliteConfigBackend::new(pool);
300                Ok((Arc::new(config_backend), db_pool))
301            }
302            ConfigStorageBackend::Postgres => {
303                #[cfg(feature = "postgres")]
304                {
305                    if config.storage.postgres.connection_string.is_empty() {
306                        return Err(DomainError::InvalidConfig(
307                            "PostgreSQL connection string not configured".into(),
308                        ));
309                    }
310                    let config_backend =
311                        PostgresConfigBackend::new(&config.storage.postgres.connection_string)
312                            .await?;
313                    let db_pool = crate::infrastructure::database::DatabasePool::Postgres(
314                        config_backend.pool().clone(),
315                    );
316                    Ok((Arc::new(config_backend), db_pool))
317                }
318                #[cfg(not(feature = "postgres"))]
319                {
320                    let _ = config; // silence unused warning
321                    Err(DomainError::InvalidConfig(
322                        "PostgreSQL feature not enabled".into(),
323                    ))
324                }
325            }
326        }
327    }
328
329    async fn create_cache_backend(
330        config: &PipedashConfig, backend: ConfigStorageBackend, data_dir: &Path,
331    ) -> DomainResult<Arc<dyn StorageBackend>> {
332        match backend {
333            ConfigStorageBackend::Sqlite => {
334                let cache_dir = data_dir.join("cache");
335                let storage = LocalStorage::new(cache_dir);
336                storage.ensure_directories().await?;
337                Ok(Arc::new(storage))
338            }
339            ConfigStorageBackend::Postgres => {
340                #[cfg(feature = "postgres")]
341                {
342                    use crate::infrastructure::storage::PostgresStorage;
343
344                    if config.storage.postgres.connection_string.is_empty() {
345                        return Err(DomainError::InvalidConfig(
346                            "PostgreSQL connection string not configured".into(),
347                        ));
348                    }
349                    let storage = PostgresStorage::from_connection_string(
350                        &config.storage.postgres.connection_string,
351                    )
352                    .await?;
353                    Ok(Arc::new(storage))
354                }
355                #[cfg(not(feature = "postgres"))]
356                {
357                    let _ = config; // silence unused warning
358                    Err(DomainError::InvalidConfig(
359                        "PostgreSQL feature not enabled".into(),
360                    ))
361                }
362            }
363        }
364    }
365
366    pub fn config(&self) -> &PipedashConfig {
367        &self.config
368    }
369
370    pub async fn token_store(&self) -> Arc<dyn TokenStore> {
371        let guard = self.token_store.read().await;
372        Arc::clone(&guard)
373    }
374
375    pub fn config_backend(&self) -> Arc<dyn ConfigBackend> {
376        Arc::clone(&self.config_backend)
377    }
378
379    pub fn cache_backend(&self) -> Arc<dyn StorageBackend> {
380        Arc::clone(&self.cache_backend)
381    }
382
383    pub fn database_pool(&self) -> crate::infrastructure::database::DatabasePool {
384        self.database_pool.clone()
385    }
386
387    pub async fn with_token_store(
388        config: PipedashConfig, token_store: Arc<dyn TokenStore>, is_desktop: bool,
389    ) -> DomainResult<Self> {
390        let data_dir = config.data_dir();
391        let backend = config.storage.backend;
392
393        std::fs::create_dir_all(&data_dir).map_err(|e| {
394            DomainError::DatabaseError(format!("Failed to create data directory: {}", e))
395        })?;
396
397        let (config_backend, database_pool) =
398            Self::create_config_backend(&config, backend, &data_dir).await?;
399        let cache_backend = Self::create_cache_backend(&config, backend, &data_dir).await?;
400
401        Ok(Self {
402            config,
403            token_store: Arc::new(RwLock::new(token_store)),
404            config_backend,
405            cache_backend,
406            database_pool,
407            vault_locked: Arc::new(RwLock::new(false)), // Custom token store means not locked
408            data_dir,
409            is_desktop,
410        })
411    }
412
413    pub async fn with_token_store_locked(
414        config: PipedashConfig, token_store: Arc<dyn TokenStore>, is_desktop: bool,
415    ) -> DomainResult<Self> {
416        let data_dir = config.data_dir();
417        let backend = config.storage.backend;
418
419        std::fs::create_dir_all(&data_dir).map_err(|e| {
420            DomainError::DatabaseError(format!("Failed to create data directory: {}", e))
421        })?;
422
423        let (config_backend, database_pool) =
424            Self::create_config_backend(&config, backend, &data_dir).await?;
425        let cache_backend = Self::create_cache_backend(&config, backend, &data_dir).await?;
426
427        tracing::info!("StorageManager created in locked vault mode");
428
429        Ok(Self {
430            config,
431            token_store: Arc::new(RwLock::new(token_store)),
432            config_backend,
433            cache_backend,
434            database_pool,
435            vault_locked: Arc::new(RwLock::new(true)), // Vault is locked
436            data_dir,
437            is_desktop,
438        })
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use tempfile::TempDir;
445
446    use super::*;
447
448    #[tokio::test]
449    async fn test_storage_manager_creation() {
450        let temp_dir = TempDir::new().unwrap();
451        let mut config = PipedashConfig::default();
452        config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
453
454        let token_store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
455
456        let manager = StorageManager::with_token_store(config, token_store, true)
457            .await
458            .unwrap();
459
460        assert!(manager.token_store().await.get_token(1).await.is_err());
461        assert!(manager.cache_backend().is_available().await);
462    }
463
464    #[tokio::test]
465    async fn test_storage_manager_vault_locked_mode() {
466        let temp_dir = TempDir::new().unwrap();
467        let mut config = PipedashConfig::default();
468        config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
469
470        let manager = StorageManager::from_config_allow_locked(config, false)
471            .await
472            .unwrap();
473
474        assert!(manager.is_vault_locked().await);
475        assert!(manager.requires_vault_password());
476    }
477
478    #[test]
479    fn test_storage_config_defaults() {
480        let config = PipedashConfig::default();
481
482        assert_eq!(config.storage.backend, ConfigStorageBackend::Sqlite);
483    }
484
485    #[test]
486    fn test_requires_vault_password() {
487        let config = PipedashConfig::default();
488
489        let is_desktop = true;
490        let is_server = false;
491
492        match (config.storage.backend, is_desktop) {
493            (ConfigStorageBackend::Sqlite, true) => {} // Keyring - expected
494            _ => panic!("Expected Keyring for desktop SQLite"),
495        }
496
497        match (config.storage.backend, is_server) {
498            (ConfigStorageBackend::Sqlite, false) => {} // SQLite encrypted - expected
499            _ => panic!("Expected SQLite encrypted for server SQLite"),
500        }
501    }
502}