pipedash_core/infrastructure/config/
manager.rs1use 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, (ConfigStorageBackend::Sqlite, false) => true, (ConfigStorageBackend::Postgres, _) => true, }
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(()); }
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; 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; 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; 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; 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)), 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)), 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) => {} _ => panic!("Expected Keyring for desktop SQLite"),
495 }
496
497 match (config.storage.backend, is_server) {
498 (ConfigStorageBackend::Sqlite, false) => {} _ => panic!("Expected SQLite encrypted for server SQLite"),
500 }
501 }
502}