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 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(()); }
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; 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; 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; 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; 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)), 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)), 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) => {} _ => panic!("Expected Keyring for desktop SQLite"),
481 }
482
483 match (config.storage.backend, is_server) {
484 (ConfigStorageBackend::Sqlite, false) => {} _ => panic!("Expected SQLite encrypted for server SQLite"),
486 }
487 }
488}