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        let providers = self.config_backend.list_providers().await?;
140        Ok(providers.is_empty())
141    }
142
143    pub async fn unlock_vault(&self, password: &str) -> DomainResult<()> {
144        if !*self.vault_locked.read().await {
145            return Ok(()); // Already unlocked
146        }
147
148        std::env::set_var("PIPEDASH_VAULT_PASSWORD", password);
149
150        let new_store = Self::create_token_store_with_password(
151            &self.config,
152            self.config.storage.backend,
153            self.is_desktop,
154            &self.data_dir,
155            password,
156        )
157        .await?;
158
159        {
160            let mut store = self.token_store.write().await;
161            *store = new_store;
162        }
163
164        {
165            let mut locked = self.vault_locked.write().await;
166            *locked = false;
167        }
168
169        tracing::info!("Vault unlocked successfully");
170        Ok(())
171    }
172
173    async fn create_token_store_with_password(
174        config: &PipedashConfig, backend: ConfigStorageBackend, _is_desktop: bool, data_dir: &Path,
175        password: &str,
176    ) -> DomainResult<Arc<dyn TokenStore>> {
177        match backend {
178            ConfigStorageBackend::Sqlite => {
179                let db_path = data_dir.join("pipedash.db");
180                let pool = init_database(db_path).await.map_err(|e| {
181                    DomainError::DatabaseError(format!(
182                        "Failed to initialize SQLite database for token storage: {}",
183                        e
184                    ))
185                })?;
186
187                let store = SqliteTokenStore::new(pool, Some(password.to_string())).await?;
188                Ok(Arc::new(store))
189            }
190            ConfigStorageBackend::Postgres => {
191                #[cfg(feature = "postgres")]
192                {
193                    let connection_string = &config.storage.postgres.connection_string;
194                    if connection_string.is_empty() {
195                        return Err(DomainError::InvalidConfig(
196                            "PostgreSQL connection string required for token storage".into(),
197                        ));
198                    }
199
200                    let store =
201                        PostgresTokenStore::new(connection_string, Some(password.to_string()))
202                            .await?;
203                    Ok(Arc::new(store))
204                }
205                #[cfg(not(feature = "postgres"))]
206                {
207                    let _ = config; // silence unused warning
208                    Err(DomainError::InvalidConfig(
209                        "PostgreSQL feature not enabled".into(),
210                    ))
211                }
212            }
213        }
214    }
215
216    async fn create_token_store(
217        config: &PipedashConfig, backend: ConfigStorageBackend, is_desktop: bool, data_dir: &Path,
218    ) -> DomainResult<Arc<dyn TokenStore>> {
219        let vault_password = config
220            .storage
221            .vault_password
222            .clone()
223            .or_else(|| std::env::var("PIPEDASH_VAULT_PASSWORD").ok());
224
225        match (backend, is_desktop) {
226            (ConfigStorageBackend::Sqlite, true) if vault_password.is_none() => {
227                Err(DomainError::InvalidConfig(
228                    "Keyring backend must be created externally (Tauri-specific)".into(),
229                ))
230            }
231            (ConfigStorageBackend::Sqlite, _) => {
232                let db_path = data_dir.join("pipedash.db");
233                let pool = init_database(db_path).await.map_err(|e| {
234                    DomainError::DatabaseError(format!(
235                        "Failed to initialize SQLite database for token storage: {}",
236                        e
237                    ))
238                })?;
239
240                let store = SqliteTokenStore::new(pool, vault_password).await?;
241                Ok(Arc::new(store))
242            }
243            (ConfigStorageBackend::Postgres, _) => {
244                #[cfg(feature = "postgres")]
245                {
246                    let connection_string = &config.storage.postgres.connection_string;
247                    if connection_string.is_empty() {
248                        return Err(DomainError::InvalidConfig(
249                            "PostgreSQL connection string required for token storage".into(),
250                        ));
251                    }
252
253                    let vault_password = config
254                        .storage
255                        .vault_password
256                        .clone()
257                        .or_else(|| std::env::var("PIPEDASH_VAULT_PASSWORD").ok());
258                    let store = PostgresTokenStore::new(connection_string, vault_password).await?;
259                    Ok(Arc::new(store))
260                }
261                #[cfg(not(feature = "postgres"))]
262                {
263                    let _ = config; // silence unused warning
264                    Err(DomainError::InvalidConfig(
265                        "PostgreSQL feature not enabled".into(),
266                    ))
267                }
268            }
269        }
270    }
271
272    async fn create_config_backend(
273        config: &PipedashConfig, backend: ConfigStorageBackend, data_dir: &Path,
274    ) -> DomainResult<(
275        Arc<dyn ConfigBackend>,
276        crate::infrastructure::database::DatabasePool,
277    )> {
278        match backend {
279            ConfigStorageBackend::Sqlite => {
280                let db_path = data_dir.join("pipedash.db");
281                let pool = init_database(db_path).await.map_err(|e| {
282                    DomainError::DatabaseError(format!("Failed to initialize database: {}", e))
283                })?;
284                let db_pool = crate::infrastructure::database::DatabasePool::Sqlite(pool.clone());
285                let config_backend = SqliteConfigBackend::new(pool);
286                Ok((Arc::new(config_backend), db_pool))
287            }
288            ConfigStorageBackend::Postgres => {
289                #[cfg(feature = "postgres")]
290                {
291                    if config.storage.postgres.connection_string.is_empty() {
292                        return Err(DomainError::InvalidConfig(
293                            "PostgreSQL connection string not configured".into(),
294                        ));
295                    }
296                    let config_backend =
297                        PostgresConfigBackend::new(&config.storage.postgres.connection_string)
298                            .await?;
299                    let db_pool = crate::infrastructure::database::DatabasePool::Postgres(
300                        config_backend.pool().clone(),
301                    );
302                    Ok((Arc::new(config_backend), db_pool))
303                }
304                #[cfg(not(feature = "postgres"))]
305                {
306                    let _ = config; // silence unused warning
307                    Err(DomainError::InvalidConfig(
308                        "PostgreSQL feature not enabled".into(),
309                    ))
310                }
311            }
312        }
313    }
314
315    async fn create_cache_backend(
316        config: &PipedashConfig, backend: ConfigStorageBackend, data_dir: &Path,
317    ) -> DomainResult<Arc<dyn StorageBackend>> {
318        match backend {
319            ConfigStorageBackend::Sqlite => {
320                let cache_dir = data_dir.join("cache");
321                let storage = LocalStorage::new(cache_dir);
322                storage.ensure_directories().await?;
323                Ok(Arc::new(storage))
324            }
325            ConfigStorageBackend::Postgres => {
326                #[cfg(feature = "postgres")]
327                {
328                    use crate::infrastructure::storage::PostgresStorage;
329
330                    if config.storage.postgres.connection_string.is_empty() {
331                        return Err(DomainError::InvalidConfig(
332                            "PostgreSQL connection string not configured".into(),
333                        ));
334                    }
335                    let storage = PostgresStorage::from_connection_string(
336                        &config.storage.postgres.connection_string,
337                    )
338                    .await?;
339                    Ok(Arc::new(storage))
340                }
341                #[cfg(not(feature = "postgres"))]
342                {
343                    let _ = config; // silence unused warning
344                    Err(DomainError::InvalidConfig(
345                        "PostgreSQL feature not enabled".into(),
346                    ))
347                }
348            }
349        }
350    }
351
352    pub fn config(&self) -> &PipedashConfig {
353        &self.config
354    }
355
356    pub async fn token_store(&self) -> Arc<dyn TokenStore> {
357        let guard = self.token_store.read().await;
358        Arc::clone(&guard)
359    }
360
361    pub fn config_backend(&self) -> Arc<dyn ConfigBackend> {
362        Arc::clone(&self.config_backend)
363    }
364
365    pub fn cache_backend(&self) -> Arc<dyn StorageBackend> {
366        Arc::clone(&self.cache_backend)
367    }
368
369    pub fn database_pool(&self) -> crate::infrastructure::database::DatabasePool {
370        self.database_pool.clone()
371    }
372
373    pub async fn with_token_store(
374        config: PipedashConfig, token_store: Arc<dyn TokenStore>, is_desktop: bool,
375    ) -> DomainResult<Self> {
376        let data_dir = config.data_dir();
377        let backend = config.storage.backend;
378
379        std::fs::create_dir_all(&data_dir).map_err(|e| {
380            DomainError::DatabaseError(format!("Failed to create data directory: {}", e))
381        })?;
382
383        let (config_backend, database_pool) =
384            Self::create_config_backend(&config, backend, &data_dir).await?;
385        let cache_backend = Self::create_cache_backend(&config, backend, &data_dir).await?;
386
387        Ok(Self {
388            config,
389            token_store: Arc::new(RwLock::new(token_store)),
390            config_backend,
391            cache_backend,
392            database_pool,
393            vault_locked: Arc::new(RwLock::new(false)), // Custom token store means not locked
394            data_dir,
395            is_desktop,
396        })
397    }
398
399    pub async fn with_token_store_locked(
400        config: PipedashConfig, token_store: Arc<dyn TokenStore>, is_desktop: bool,
401    ) -> DomainResult<Self> {
402        let data_dir = config.data_dir();
403        let backend = config.storage.backend;
404
405        std::fs::create_dir_all(&data_dir).map_err(|e| {
406            DomainError::DatabaseError(format!("Failed to create data directory: {}", e))
407        })?;
408
409        let (config_backend, database_pool) =
410            Self::create_config_backend(&config, backend, &data_dir).await?;
411        let cache_backend = Self::create_cache_backend(&config, backend, &data_dir).await?;
412
413        tracing::info!("StorageManager created in locked vault mode");
414
415        Ok(Self {
416            config,
417            token_store: Arc::new(RwLock::new(token_store)),
418            config_backend,
419            cache_backend,
420            database_pool,
421            vault_locked: Arc::new(RwLock::new(true)), // Vault is locked
422            data_dir,
423            is_desktop,
424        })
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use tempfile::TempDir;
431
432    use super::*;
433
434    #[tokio::test]
435    async fn test_storage_manager_creation() {
436        let temp_dir = TempDir::new().unwrap();
437        let mut config = PipedashConfig::default();
438        config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
439
440        let token_store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
441
442        let manager = StorageManager::with_token_store(config, token_store, true)
443            .await
444            .unwrap();
445
446        assert!(manager.token_store().await.get_token(1).await.is_err());
447        assert!(manager.cache_backend().is_available().await);
448    }
449
450    #[tokio::test]
451    async fn test_storage_manager_vault_locked_mode() {
452        let temp_dir = TempDir::new().unwrap();
453        let mut config = PipedashConfig::default();
454        config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
455
456        let manager = StorageManager::from_config_allow_locked(config, false)
457            .await
458            .unwrap();
459
460        assert!(manager.is_vault_locked().await);
461        assert!(manager.requires_vault_password());
462    }
463
464    #[test]
465    fn test_storage_config_defaults() {
466        let config = PipedashConfig::default();
467
468        assert_eq!(config.storage.backend, ConfigStorageBackend::Sqlite);
469    }
470
471    #[test]
472    fn test_requires_vault_password() {
473        let config = PipedashConfig::default();
474
475        let is_desktop = true;
476        let is_server = false;
477
478        match (config.storage.backend, is_desktop) {
479            (ConfigStorageBackend::Sqlite, true) => {} // Keyring - expected
480            _ => panic!("Expected Keyring for desktop SQLite"),
481        }
482
483        match (config.storage.backend, is_server) {
484            (ConfigStorageBackend::Sqlite, false) => {} // SQLite encrypted - expected
485            _ => panic!("Expected SQLite encrypted for server SQLite"),
486        }
487    }
488}