Skip to main content

flow_application/
connections.rs

1//! Connection profile persistence.
2//!
3//! Profile metadata (host / port / user / protocol / rejectUnauthorized) is
4//! stored as JSON at `<dir>/connections.json` - single file, single array.
5//! Secrets (passwords / tokens) live in the OS keyring under
6//! `flow-security`'s `zowe:<id>` account, never on disk.
7//!
8//! Resolving a profile for the Zowe adapter at execution time combines the
9//! metadata with the keyring secret into a `ResolvedZoweConnection`, which is
10//! consumed once and dropped - the adapter never persists or logs it.
11
12use flow_domain::connection::ConnectionProfile;
13use flow_execution::{ConnectionLookup, ResolvedZoweConnection};
14use flow_security::{CredentialKind, CredentialStore};
15use parking_lot::Mutex;
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use thiserror::Error;
20
21const FILE_NAME: &str = "connections.json";
22
23/// Storage-layer errors. Distinct from the runtime [`ConnectionError`] so the
24/// `flow-application` API can surface IO / JSON failures without leaking them
25/// into the cross-crate `ConnectionLookup` contract.
26#[derive(Debug, Error)]
27pub enum StoreError {
28    #[error("connection id is empty")]
29    EmptyId,
30    #[error(transparent)]
31    Io(#[from] std::io::Error),
32    #[error(transparent)]
33    Json(#[from] serde_json::Error),
34    #[error("credential lookup failed: {0}")]
35    Credential(String),
36}
37
38// Re-export the runtime-side types so callers in this crate can use them
39// without depending on `flow-execution` directly.
40pub use flow_execution::ConnectionError as RuntimeConnectionError;
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43struct File {
44    #[serde(default)]
45    connections: Vec<ConnectionProfile>,
46}
47
48#[derive(Clone)]
49pub struct ConnectionStore {
50    path: PathBuf,
51    credentials: Arc<dyn CredentialStore>,
52    cache: Arc<Mutex<File>>,
53}
54
55impl ConnectionStore {
56    pub fn open(dir: &Path, credentials: Arc<dyn CredentialStore>) -> Result<Self, StoreError> {
57        std::fs::create_dir_all(dir)?;
58        let path = dir.join(FILE_NAME);
59        let cache = if path.exists() {
60            let body = std::fs::read_to_string(&path)?;
61            serde_json::from_str::<File>(&body).unwrap_or_default()
62        } else {
63            File::default()
64        };
65        Ok(Self {
66            path,
67            credentials,
68            cache: Arc::new(Mutex::new(cache)),
69        })
70    }
71
72    pub fn list(&self) -> Vec<ConnectionProfile> {
73        self.cache.lock().connections.clone()
74    }
75
76    pub fn get(&self, id: &str) -> Option<ConnectionProfile> {
77        self.cache
78            .lock()
79            .connections
80            .iter()
81            .find(|c| c.id == id)
82            .cloned()
83    }
84
85    /// Insert or update a profile. If `secret` is `Some`, also writes it to
86    /// the keyring; passing `None` leaves the existing keyring entry alone (so
87    /// edits to host/port/user don't require re-entering the password).
88    pub fn upsert(
89        &self,
90        profile: ConnectionProfile,
91        secret: Option<&str>,
92    ) -> Result<(), StoreError> {
93        if profile.id.is_empty() {
94            return Err(StoreError::EmptyId);
95        }
96        if let Some(s) = secret {
97            self.set_secret(&profile.id, s)?;
98        }
99        let mut guard = self.cache.lock();
100        if let Some(slot) = guard.connections.iter_mut().find(|c| c.id == profile.id) {
101            *slot = profile;
102        } else {
103            guard.connections.push(profile);
104        }
105        let body = serde_json::to_string_pretty(&*guard)?;
106        drop(guard);
107        std::fs::write(&self.path, body)?;
108        Ok(())
109    }
110
111    pub fn delete(&self, id: &str) -> Result<(), StoreError> {
112        let mut guard = self.cache.lock();
113        let before = guard.connections.len();
114        guard.connections.retain(|c| c.id != id);
115        if guard.connections.len() == before {
116            return Err(StoreError::Io(std::io::Error::new(
117                std::io::ErrorKind::NotFound,
118                format!("connection '{id}' not found"),
119            )));
120        }
121        let body = serde_json::to_string_pretty(&*guard)?;
122        drop(guard);
123        std::fs::write(&self.path, body)?;
124        // Best-effort: remove the keyring entry too. A missing entry is fine.
125        let account = CredentialKind::ZoweProfile.account_for(id);
126        let _ = self.credentials.delete(&account);
127        Ok(())
128    }
129
130    pub fn set_secret(&self, id: &str, secret: &str) -> Result<(), StoreError> {
131        let account = CredentialKind::ZoweProfile.account_for(id);
132        self.credentials
133            .set(&account, secret)
134            .map_err(|e| StoreError::Credential(e.to_string()))
135    }
136
137    pub fn has_secret(&self, id: &str) -> bool {
138        let account = CredentialKind::ZoweProfile.account_for(id);
139        self.credentials.exists(&account)
140    }
141}
142
143impl ConnectionLookup for ConnectionStore {
144    fn resolve_zowe(
145        &self,
146        connection_id: &str,
147    ) -> Result<ResolvedZoweConnection, RuntimeConnectionError> {
148        let profile = self
149            .get(connection_id)
150            .ok_or_else(|| RuntimeConnectionError::NotFound(connection_id.to_string()))?;
151        if profile.kind != "zosmf" {
152            return Err(RuntimeConnectionError::NotFound(format!(
153                "{connection_id} (not a zosmf connection)"
154            )));
155        }
156        let account = CredentialKind::ZoweProfile.account_for(connection_id);
157        let password = self
158            .credentials
159            .get(&account)
160            .map_err(|e| RuntimeConnectionError::Credential(e.to_string()))?;
161        Ok(ResolvedZoweConnection {
162            host: profile.host,
163            port: profile.port,
164            user: profile.user,
165            password,
166            protocol: profile.protocol,
167            reject_unauthorized: profile.reject_unauthorized,
168        })
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use flow_security::InMemoryCredentialStore;
176
177    fn tmp_dir() -> PathBuf {
178        let p = std::env::temp_dir().join(format!("flow-conn-{}", uuid::Uuid::new_v4()));
179        std::fs::create_dir_all(&p).unwrap();
180        p
181    }
182
183    fn make_profile(id: &str) -> ConnectionProfile {
184        ConnectionProfile::new_zosmf(
185            id,
186            format!("{id} profile"),
187            "lpar.example.com",
188            443,
189            "USR01",
190        )
191    }
192
193    #[test]
194    fn upsert_and_list_round_trip() {
195        let dir = tmp_dir();
196        let store = ConnectionStore::open(&dir, Arc::new(InMemoryCredentialStore::new())).unwrap();
197        store
198            .upsert(make_profile("prd01"), Some("hunter2"))
199            .unwrap();
200        let listed = store.list();
201        assert_eq!(listed.len(), 1);
202        assert_eq!(listed[0].id, "prd01");
203        assert!(store.has_secret("prd01"));
204        let _ = std::fs::remove_dir_all(&dir);
205    }
206
207    #[test]
208    fn upsert_replaces_existing() {
209        let dir = tmp_dir();
210        let store = ConnectionStore::open(&dir, Arc::new(InMemoryCredentialStore::new())).unwrap();
211        store.upsert(make_profile("prd01"), Some("first")).unwrap();
212        let mut p2 = make_profile("prd01");
213        p2.host = "new.host".into();
214        // Pass None for secret so the old keyring entry is preserved.
215        store.upsert(p2, None).unwrap();
216        let resolved = store.resolve_zowe("prd01").unwrap();
217        assert_eq!(resolved.host, "new.host");
218        assert_eq!(
219            resolved.password, "first",
220            "secret must persist when not patched"
221        );
222        let _ = std::fs::remove_dir_all(&dir);
223    }
224
225    #[test]
226    fn delete_removes_metadata_and_secret() {
227        let dir = tmp_dir();
228        let store = ConnectionStore::open(&dir, Arc::new(InMemoryCredentialStore::new())).unwrap();
229        store.upsert(make_profile("dev01"), Some("pw")).unwrap();
230        store.delete("dev01").unwrap();
231        assert!(store.list().is_empty());
232        assert!(!store.has_secret("dev01"));
233        let _ = std::fs::remove_dir_all(&dir);
234    }
235
236    #[test]
237    fn delete_missing_is_not_found() {
238        let dir = tmp_dir();
239        let store = ConnectionStore::open(&dir, Arc::new(InMemoryCredentialStore::new())).unwrap();
240        let err = store.delete("nope").unwrap_err();
241        match err {
242            StoreError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
243            other => panic!("unexpected error: {other:?}"),
244        }
245        let _ = std::fs::remove_dir_all(&dir);
246    }
247
248    #[test]
249    fn resolve_with_no_secret_is_credential_error() {
250        let dir = tmp_dir();
251        let creds = Arc::new(InMemoryCredentialStore::new());
252        let store = ConnectionStore::open(&dir, creds).unwrap();
253        store.upsert(make_profile("p"), None).unwrap();
254        assert!(matches!(
255            store.resolve_zowe("p"),
256            Err(RuntimeConnectionError::Credential(_))
257        ));
258        let _ = std::fs::remove_dir_all(&dir);
259    }
260
261    #[test]
262    fn upsert_rejects_empty_id() {
263        let dir = tmp_dir();
264        let store = ConnectionStore::open(&dir, Arc::new(InMemoryCredentialStore::new())).unwrap();
265        let mut p = make_profile("ok");
266        p.id = "".into();
267        assert!(matches!(
268            store.upsert(p, Some("x")),
269            Err(StoreError::EmptyId)
270        ));
271        let _ = std::fs::remove_dir_all(&dir);
272    }
273
274    #[test]
275    fn round_trip_via_disk_persists_metadata() {
276        let dir = tmp_dir();
277        let creds = Arc::new(InMemoryCredentialStore::new());
278        {
279            let store = ConnectionStore::open(&dir, creds.clone()).unwrap();
280            store.upsert(make_profile("alpha"), Some("pw")).unwrap();
281        }
282        // Reopen - metadata reloads from JSON, secret reloads from the same store.
283        let store = ConnectionStore::open(&dir, creds).unwrap();
284        assert_eq!(store.list().len(), 1);
285        assert_eq!(
286            store.resolve_zowe("alpha").unwrap().host,
287            "lpar.example.com"
288        );
289        let _ = std::fs::remove_dir_all(&dir);
290    }
291}