flow_application/
connections.rs1use 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#[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
38pub 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 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 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 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 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}