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 reason: Option<FailureReason>,
24 pub latency_ms: u64,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum FailureReason {
32 NotConfigured,
34 Auth,
36 Unreachable,
38 Timeout,
39 RateLimited,
40 NotFound,
41 Unavailable,
43 Unsupported,
45 InvalidInput,
47 Error,
48}
49
50impl FailureReason {
51 pub fn of_status(status: u16) -> Self {
53 match status {
54 401 | 403 => Self::Auth,
55 404 => Self::NotFound,
56 408 => Self::Timeout,
57 429 => Self::RateLimited,
58 500..=599 => Self::Unavailable,
59 _ => Self::Error,
60 }
61 }
62
63 pub fn of_message(message: &str) -> Self {
66 let m = message.to_ascii_lowercase();
67 let has = |needles: &[&str]| needles.iter().any(|n| m.contains(n));
68 if has(&[
69 "not set",
70 "not configured",
71 "missing",
72 "no such file",
73 "not found in path",
74 ]) {
75 Self::NotConfigured
76 } else if has(&[
77 "http 401",
78 "http 403",
79 "unauthorized",
80 "forbidden",
81 "authentication failed",
82 "refused the token",
83 ]) {
84 Self::Auth
85 } else if has(&["timeout", "timed out", "no answer within"]) {
86 Self::Timeout
87 } else if has(&["http 429", "rate limit"]) {
88 Self::RateLimited
89 } else if has(&["http 404"]) {
90 Self::NotFound
91 } else if has(&["http 5"]) {
92 Self::Unavailable
93 } else if has(&[
94 "could not connect",
95 "connection refused",
96 "dns",
97 "resolve",
98 "unreachable",
99 "tls",
100 "connect",
101 ]) {
102 Self::Unreachable
103 } else {
104 Self::Error
105 }
106 }
107}
108
109pub const CHECKABLE: &[&str] = &["github", "gitlab", "jira", "postgres"];
110
111pub const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
113
114pub fn check(provider: &str, timeout: Duration) -> ConnectionCheck {
118 let started = Instant::now();
119 let (sender, receiver) = std::sync::mpsc::channel();
120 let id = provider.to_string();
121 let spawned = std::thread::Builder::new()
122 .name("provider-check".into())
123 .spawn(move || {
124 let _ = sender.send(check_now(&id));
125 });
126 let result = match spawned {
127 Ok(_) => receiver.recv_timeout(timeout).unwrap_or_else(|_| {
128 failed(
129 provider,
130 FailureReason::Timeout,
131 format!("no answer within {} s", timeout.as_secs()),
132 started,
133 )
134 }),
135 Err(e) => failed(
136 provider,
137 FailureReason::Error,
138 format!("cannot start the check: {e}"),
139 started,
140 ),
141 };
142 redact(result, &secret_values())
143}
144
145fn failed(
146 provider: &str,
147 reason: FailureReason,
148 error: String,
149 started: Instant,
150) -> ConnectionCheck {
151 ConnectionCheck {
152 provider: provider.to_string(),
153 connected: false,
154 account: None,
155 scope: None,
156 error: Some(error),
157 reason: Some(reason),
158 latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
159 }
160}
161
162fn secret_values() -> Vec<String> {
165 let mut values = Vec::new();
166 for name in super::credentials::SECRET_NAMES {
167 if let Ok(value) = super::credentials::var(name) {
168 let value = value.trim().to_string();
169 if let Some(password) = url_password(&value) {
170 values.push(password);
171 }
172 values.push(value);
173 }
174 }
175 values.retain(|value| value.len() >= 4);
177 values.sort_by_key(|value| std::cmp::Reverse(value.len()));
178 values.dedup();
179 values
180}
181
182fn url_password(url: &str) -> Option<String> {
184 let (_, rest) = url.split_once("://")?;
185 let authority = rest.split('/').next()?;
186 let (userinfo, _) = authority.rsplit_once('@')?;
187 let (_, password) = userinfo.split_once(':')?;
188 (!password.is_empty()).then(|| password.to_string())
189}
190
191fn mask(text: &str, secrets: &[String]) -> String {
192 secrets.iter().fold(text.to_string(), |text, secret| {
193 text.replace(secret.as_str(), "[redacted]")
194 })
195}
196
197fn redact(mut result: ConnectionCheck, secrets: &[String]) -> ConnectionCheck {
198 result.account = result.account.map(|value| mask(&value, secrets));
199 result.scope = result.scope.map(|value| mask(&value, secrets));
200 result.error = result
202 .error
203 .map(|value| crate::core::redaction::redact_text(&mask(&value, secrets)));
204 result
205}
206
207fn check_now(provider: &str) -> ConnectionCheck {
208 let started = Instant::now();
209 let outcome = match provider {
210 "github" => github(),
211 "gitlab" => gitlab(),
212 "jira" => jira(),
213 "postgres" => postgres(),
214 other => Err((
215 FailureReason::Unsupported,
216 format!(
217 "{other} cannot be checked here; use one of {}",
218 CHECKABLE.join(", ")
219 ),
220 )),
221 };
222 let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
223 match outcome {
224 Ok((account, scope)) => ConnectionCheck {
225 provider: provider.to_string(),
226 connected: true,
227 account: Some(account),
228 scope,
229 error: None,
230 reason: None,
231 latency_ms,
232 },
233 Err((reason, error)) => ConnectionCheck {
234 provider: provider.to_string(),
235 connected: false,
236 account: None,
237 scope: None,
238 error: Some(error),
239 reason: Some(reason),
240 latency_ms,
241 },
242 }
243}
244
245type Failure = (FailureReason, String);
247
248fn not_configured(error: String) -> Failure {
249 (FailureReason::NotConfigured, error)
250}
251
252fn by_message(error: String) -> Failure {
253 (FailureReason::of_message(&error), error)
254}
255
256fn http_body(
258 outcome: super::hardened_http::HttpOutcome,
259 context: impl FnOnce(String) -> String,
260) -> Result<String, Failure> {
261 use super::hardened_http::HttpOutcome as O;
262 let reason = match &outcome {
263 O::Success { .. } => None,
264 O::Timeout { .. } => Some(FailureReason::Timeout),
265 O::NetworkError { .. } => Some(FailureReason::Unreachable),
266 O::HttpError { status, .. } => Some(FailureReason::of_status(*status)),
267 };
268 outcome
269 .into_body()
270 .map_err(|e| (reason.unwrap_or(FailureReason::Error), context(e)))
271}
272
273fn field(body: &str, keys: &[&str]) -> Option<String> {
274 let value: serde_json::Value = serde_json::from_str(body).ok()?;
275 keys.iter()
276 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_str))
277 .map(str::to_string)
278}
279
280fn github() -> Result<(String, Option<String>), Failure> {
281 let config = GitHubConfig::from_env().map_err(not_configured)?;
282 let token = format!("Bearer {}", config.token);
283 let body = http_body(
284 super::hardened_http::provider_get_with_headers(
285 "github",
286 &format!("{}/user", config.api_base),
287 &[
288 ("Authorization", &token),
289 ("Accept", "application/vnd.github+json"),
290 ("X-GitHub-Api-Version", "2022-11-28"),
291 ],
292 ),
293 |e| format!("GitHub refused the token: {e}"),
294 )?;
295 let login = field(&body, &["login"]).unwrap_or_else(|| "signed in".to_string());
296 Ok((login, config.repo_slug()))
297}
298
299fn gitlab() -> Result<(String, Option<String>), Failure> {
300 let config = GitLabConfig::from_env().map_err(not_configured)?;
301 let body = http_body(
302 super::hardened_http::provider_get_with_headers(
303 "gitlab",
304 &config.api_url("/user"),
305 &[("PRIVATE-TOKEN", &config.token)],
306 ),
307 |e| format!("GitLab at {} refused the token: {e}", config.host),
308 )?;
309 let user = field(&body, &["username", "name"]).unwrap_or_else(|| "signed in".to_string());
310 Ok((user, config.project_path.clone()))
311}
312
313fn jira() -> Result<(String, Option<String>), Failure> {
314 let config = JiraConfig::from_env().map_err(not_configured)?;
315 let user = config.whoami().map_err(by_message)?;
316 Ok((user, config.project.clone()))
317}
318
319fn postgres_command() -> Result<std::process::Command, Failure> {
321 if !super::credentials::is_set("DATABASE_URL") && !super::credentials::is_set("PGDATABASE") {
322 return Err(not_configured("DATABASE_URL not set".to_string()));
323 }
324 let mut command = super::postgres::psql_command().map_err(not_configured)?;
325 command
326 .args([
327 "-X",
328 "-w",
329 "-t",
330 "-A",
331 "-c",
332 "SELECT current_user || '|' || current_database();",
333 ])
334 .env("PGCLIENTENCODING", "UTF8")
335 .stdin(std::process::Stdio::null());
336 Ok(command)
337}
338
339fn postgres() -> Result<(String, Option<String>), Failure> {
340 let mut command = postgres_command()?;
341 let out = super::postgres::run_psql(&mut command, std::time::Duration::from_secs(10))
342 .map_err(by_message)?;
343 let line = out.lines().next().unwrap_or("").trim();
344 let (user, database) = line.split_once('|').unwrap_or((line, ""));
345 Ok((
346 user.to_string(),
347 (!database.is_empty()).then(|| database.to_string()),
348 ))
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn unknown_sources_are_refused_without_a_request() {
357 let result = check("weather", CHECK_TIMEOUT);
358 assert!(!result.connected);
359 assert_eq!(result.reason, Some(FailureReason::Unsupported));
360 assert!(result.error.unwrap().contains("github"));
361 }
362
363 #[test]
364 fn failures_carry_a_machine_readable_reason() {
365 assert_eq!(FailureReason::of_status(401), FailureReason::Auth);
366 assert_eq!(FailureReason::of_status(429), FailureReason::RateLimited);
367 assert_eq!(FailureReason::of_status(503), FailureReason::Unavailable);
368 for (text, reason) in [
369 ("JIRA_URL not set", FailureReason::NotConfigured),
370 ("HTTP 401: Unauthorized", FailureReason::Auth),
371 (
372 "psql: FATAL: password authentication failed",
373 FailureReason::Auth,
374 ),
375 (
376 "psql: could not connect to server: Connection refused",
377 FailureReason::Unreachable,
378 ),
379 ("request timed out", FailureReason::Timeout),
380 ("something odd", FailureReason::Error),
381 ] {
382 assert_eq!(FailureReason::of_message(text), reason, "{text}");
383 }
384 let line = serde_json::to_string(&FailureReason::NotConfigured).unwrap();
385 assert_eq!(line, "\"not_configured\"");
386 }
387
388 #[test]
389 fn database_password_is_masked_in_every_reported_field() {
390 use super::super::credentials;
391 let _lock = crate::core::data_dir::test_env_lock();
392 let url = concat!("postgres://reader:", "Pw-Secret9", "@db.example/app");
393 credentials::set_candidates("postgres", &[("DATABASE_URL".into(), url.into())]).unwrap();
394 let secrets = secret_values();
395 credentials::clear_candidates();
396 let result = redact(
397 ConnectionCheck {
398 provider: "postgres".into(),
399 connected: false,
400 account: Some("reader:Pw-Secret9".into()),
401 scope: Some("app".into()),
402 error: Some(format!("cannot reach {url}: password Pw-Secret9 refused")),
403 reason: Some(FailureReason::Auth),
404 latency_ms: 1,
405 },
406 &secrets,
407 );
408 let line = serde_json::to_string(&result).unwrap();
409 assert!(!line.contains("Pw-Secret9"), "{line}");
410 assert_eq!(result.scope.as_deref(), Some("app"));
411 }
412
413 #[test]
414 fn psql_argv_never_carries_the_database_address() {
415 use super::super::credentials;
416 let _lock = crate::core::data_dir::test_env_lock();
417 let url = concat!(
418 "postgresql://reader:",
419 "Pw-Secret9%40x",
420 "@db.example:6543/app?sslmode=require"
421 );
422 credentials::set_candidates("postgres", &[("DATABASE_URL".into(), url.into())]).unwrap();
423 let command = postgres_command();
424 credentials::clear_candidates();
425 let command = command.unwrap();
426 for arg in command.get_args() {
427 let arg = arg.to_string_lossy();
428 assert!(
429 !arg.contains("Pw-Secret9") && !arg.contains("db.example"),
430 "{arg}"
431 );
432 }
433 let env: std::collections::BTreeMap<String, String> = command
434 .get_envs()
435 .filter_map(|(k, v)| {
436 Some((
437 k.to_string_lossy().into_owned(),
438 v?.to_string_lossy().into_owned(),
439 ))
440 })
441 .collect();
442 for (key, value) in [
443 ("PGPASSWORD", "Pw-Secret9@x"),
444 ("PGUSER", "reader"),
445 ("PGHOST", "db.example"),
446 ("PGPORT", "6543"),
447 ("PGDATABASE", "app"),
448 ("PGSSLMODE", "require"),
449 ] {
450 assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}");
451 }
452 }
453
454 #[test]
455 fn account_fields_are_read_from_the_response() {
456 assert_eq!(
457 field(r#"{"login":"octo","name":"Octo"}"#, &["login"]).as_deref(),
458 Some("octo")
459 );
460 assert_eq!(
461 field(r#"{"name":"Ada"}"#, &["username", "name"]).as_deref(),
462 Some("Ada")
463 );
464 assert_eq!(field("not json", &["login"]), None);
465 }
466}