1use std::time::{Duration, Instant};
8
9use super::config::GitLabConfig;
10use super::github::GitHubConfig;
11use super::jira::JiraConfig;
12
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct ConnectionCheck {
15 pub provider: String,
16 pub connected: bool,
17 pub account: Option<String>,
19 pub scope: Option<String>,
21 pub error: Option<String>,
22 pub latency_ms: u64,
23}
24
25pub const CHECKABLE: &[&str] = &["github", "gitlab", "jira", "postgres"];
26
27pub const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
29
30pub fn check(provider: &str, timeout: Duration) -> ConnectionCheck {
34 let started = Instant::now();
35 let (sender, receiver) = std::sync::mpsc::channel();
36 let id = provider.to_string();
37 let spawned = std::thread::Builder::new()
38 .name("provider-check".into())
39 .spawn(move || {
40 let _ = sender.send(check_now(&id));
41 });
42 let result = match spawned {
43 Ok(_) => receiver.recv_timeout(timeout).unwrap_or_else(|_| {
44 failed(
45 provider,
46 format!("no answer within {} s", timeout.as_secs()),
47 started,
48 )
49 }),
50 Err(e) => failed(provider, format!("cannot start the check: {e}"), started),
51 };
52 redact(result, &secret_values())
53}
54
55fn failed(provider: &str, error: String, started: Instant) -> ConnectionCheck {
56 ConnectionCheck {
57 provider: provider.to_string(),
58 connected: false,
59 account: None,
60 scope: None,
61 error: Some(error),
62 latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
63 }
64}
65
66fn secret_values() -> Vec<String> {
69 let mut values = Vec::new();
70 for name in super::credentials::SECRET_NAMES {
71 if let Ok(value) = super::credentials::var(name) {
72 let value = value.trim().to_string();
73 if let Some(password) = url_password(&value) {
74 values.push(password);
75 }
76 values.push(value);
77 }
78 }
79 values.retain(|value| value.len() >= 4);
81 values.sort_by_key(|value| std::cmp::Reverse(value.len()));
82 values.dedup();
83 values
84}
85
86fn url_password(url: &str) -> Option<String> {
88 let (_, rest) = url.split_once("://")?;
89 let authority = rest.split('/').next()?;
90 let (userinfo, _) = authority.rsplit_once('@')?;
91 let (_, password) = userinfo.split_once(':')?;
92 (!password.is_empty()).then(|| password.to_string())
93}
94
95fn mask(text: &str, secrets: &[String]) -> String {
96 secrets.iter().fold(text.to_string(), |text, secret| {
97 text.replace(secret.as_str(), "[redacted]")
98 })
99}
100
101fn redact(mut result: ConnectionCheck, secrets: &[String]) -> ConnectionCheck {
102 result.account = result.account.map(|value| mask(&value, secrets));
103 result.scope = result.scope.map(|value| mask(&value, secrets));
104 result.error = result
106 .error
107 .map(|value| crate::core::redaction::redact_text(&mask(&value, secrets)));
108 result
109}
110
111fn check_now(provider: &str) -> ConnectionCheck {
112 let started = Instant::now();
113 let outcome = match provider {
114 "github" => github(),
115 "gitlab" => gitlab(),
116 "jira" => jira(),
117 "postgres" => postgres(),
118 other => Err(format!(
119 "{other} cannot be checked here; use one of {}",
120 CHECKABLE.join(", ")
121 )),
122 };
123 let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
124 match outcome {
125 Ok((account, scope)) => ConnectionCheck {
126 provider: provider.to_string(),
127 connected: true,
128 account: Some(account),
129 scope,
130 error: None,
131 latency_ms,
132 },
133 Err(error) => ConnectionCheck {
134 provider: provider.to_string(),
135 connected: false,
136 account: None,
137 scope: None,
138 error: Some(error),
139 latency_ms,
140 },
141 }
142}
143
144fn field(body: &str, keys: &[&str]) -> Option<String> {
145 let value: serde_json::Value = serde_json::from_str(body).ok()?;
146 keys.iter()
147 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_str))
148 .map(str::to_string)
149}
150
151fn github() -> Result<(String, Option<String>), String> {
152 let config = GitHubConfig::from_env()?;
153 let token = format!("Bearer {}", config.token);
154 let body = super::hardened_http::provider_get_with_headers(
155 "github",
156 &format!("{}/user", config.api_base),
157 &[
158 ("Authorization", &token),
159 ("Accept", "application/vnd.github+json"),
160 ("X-GitHub-Api-Version", "2022-11-28"),
161 ],
162 )
163 .into_body()
164 .map_err(|e| format!("GitHub refused the token: {e}"))?;
165 let login = field(&body, &["login"]).unwrap_or_else(|| "signed in".to_string());
166 Ok((login, config.repo_slug()))
167}
168
169fn gitlab() -> Result<(String, Option<String>), String> {
170 let config = GitLabConfig::from_env()?;
171 let body = super::hardened_http::provider_get_with_headers(
172 "gitlab",
173 &config.api_url("/user"),
174 &[("PRIVATE-TOKEN", &config.token)],
175 )
176 .into_body()
177 .map_err(|e| format!("GitLab at {} refused the token: {e}", config.host))?;
178 let user = field(&body, &["username", "name"]).unwrap_or_else(|| "signed in".to_string());
179 Ok((user, config.project_path.clone()))
180}
181
182fn jira() -> Result<(String, Option<String>), String> {
183 let config = JiraConfig::from_env()?;
184 let user = config.whoami()?;
185 Ok((user, config.project.clone()))
186}
187
188fn postgres_command() -> Result<std::process::Command, String> {
190 if !super::credentials::is_set("DATABASE_URL") && !super::credentials::is_set("PGDATABASE") {
191 return Err("DATABASE_URL not set".to_string());
192 }
193 let mut command = super::postgres::psql_command()?;
194 command
195 .args([
196 "-X",
197 "-w",
198 "-t",
199 "-A",
200 "-c",
201 "SELECT current_user || '|' || current_database();",
202 ])
203 .env("PGCLIENTENCODING", "UTF8")
204 .stdin(std::process::Stdio::null());
205 Ok(command)
206}
207
208fn postgres() -> Result<(String, Option<String>), String> {
209 let mut command = postgres_command()?;
210 let out = super::postgres::run_psql(&mut command, std::time::Duration::from_secs(10))?;
211 let line = out.lines().next().unwrap_or("").trim();
212 let (user, database) = line.split_once('|').unwrap_or((line, ""));
213 Ok((
214 user.to_string(),
215 (!database.is_empty()).then(|| database.to_string()),
216 ))
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn unknown_sources_are_refused_without_a_request() {
225 let result = check("weather", CHECK_TIMEOUT);
226 assert!(!result.connected);
227 assert!(result.error.unwrap().contains("github"));
228 }
229
230 #[test]
231 fn database_password_is_masked_in_every_reported_field() {
232 use super::super::credentials;
233 let _lock = crate::core::data_dir::test_env_lock();
234 let url = concat!("postgres://reader:", "Pw-Secret9", "@db.example/app");
235 credentials::set_candidates("postgres", &[("DATABASE_URL".into(), url.into())]).unwrap();
236 let secrets = secret_values();
237 credentials::clear_candidates();
238 let result = redact(
239 ConnectionCheck {
240 provider: "postgres".into(),
241 connected: false,
242 account: Some("reader:Pw-Secret9".into()),
243 scope: Some("app".into()),
244 error: Some(format!("cannot reach {url}: password Pw-Secret9 refused")),
245 latency_ms: 1,
246 },
247 &secrets,
248 );
249 let line = serde_json::to_string(&result).unwrap();
250 assert!(!line.contains("Pw-Secret9"), "{line}");
251 assert_eq!(result.scope.as_deref(), Some("app"));
252 }
253
254 #[test]
255 fn psql_argv_never_carries_the_database_address() {
256 use super::super::credentials;
257 let _lock = crate::core::data_dir::test_env_lock();
258 let url = concat!(
259 "postgresql://reader:",
260 "Pw-Secret9%40x",
261 "@db.example:6543/app?sslmode=require"
262 );
263 credentials::set_candidates("postgres", &[("DATABASE_URL".into(), url.into())]).unwrap();
264 let command = postgres_command();
265 credentials::clear_candidates();
266 let command = command.unwrap();
267 for arg in command.get_args() {
268 let arg = arg.to_string_lossy();
269 assert!(
270 !arg.contains("Pw-Secret9") && !arg.contains("db.example"),
271 "{arg}"
272 );
273 }
274 let env: std::collections::BTreeMap<String, String> = command
275 .get_envs()
276 .filter_map(|(k, v)| {
277 Some((
278 k.to_string_lossy().into_owned(),
279 v?.to_string_lossy().into_owned(),
280 ))
281 })
282 .collect();
283 for (key, value) in [
284 ("PGPASSWORD", "Pw-Secret9@x"),
285 ("PGUSER", "reader"),
286 ("PGHOST", "db.example"),
287 ("PGPORT", "6543"),
288 ("PGDATABASE", "app"),
289 ("PGSSLMODE", "require"),
290 ] {
291 assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}");
292 }
293 }
294
295 #[test]
296 fn account_fields_are_read_from_the_response() {
297 assert_eq!(
298 field(r#"{"login":"octo","name":"Octo"}"#, &["login"]).as_deref(),
299 Some("octo")
300 );
301 assert_eq!(
302 field(r#"{"name":"Ada"}"#, &["username", "name"]).as_deref(),
303 Some("Ada")
304 );
305 assert_eq!(field("not json", &["login"]), None);
306 }
307}