Skip to main content

lean_ctx/core/providers/
connection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! A real connection check for one built-in source: the same configuration a
4//! provider builds, one authenticated read-only request, and who the source
5//! says the Engine is. Used by `lean-ctx provider check <id>`.
6
7use 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    /// The account the source reports for these credentials.
18    pub account: Option<String>,
19    /// What the source will be read for (repository, project, database).
20    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
27/// Upper bound for one check, below the Cockpit's 40 s command deadline.
28pub const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
29
30/// Checks `provider` within `timeout`. Every secret the check could see
31/// (tokens, the database address and its password) is masked in `account`,
32/// `scope` and `error`, whatever the source or its client echoed back.
33pub 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
66/// The secret values a check resolves, longest first so a token that
67/// contains another secret is masked whole.
68fn 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    // Very short values would mask ordinary words; real secrets are longer.
80    values.retain(|value| value.len() >= 4);
81    values.sort_by_key(|value| std::cmp::Reverse(value.len()));
82    values.dedup();
83    values
84}
85
86/// The password of `scheme://user:password@host/…`, if any.
87fn 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    // Errors may quote a response body: also apply the generic secret rules.
105    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
188/// The check's `psql` call; the connection travels only in `PG*` variables.
189fn 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}