Skip to main content

xbp_cli/provider_support/
github.rs

1use super::http::extract_github_error_message;
2use super::models::ProviderErrorResponse;
3use futures::StreamExt;
4use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
5use reqwest::{Client, StatusCode};
6use serde::{Deserialize, Serialize};
7use std::collections::{hash_map::Entry, HashMap};
8
9const GITHUB_API_BASE: &str = "https://api.github.com";
10const GITHUB_API_VERSION: &str = "2022-11-28";
11const VARIABLE_LIST_PAGE_SIZE: usize = 30;
12const VARIABLE_WRITE_CONCURRENCY: usize = 8;
13const GITHUB_ENV_PREFIX: &str = "GITHUB_";
14const GITHUB_STORAGE_PREFIX: &str = "_GITHUB_";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GitHubSecretVariable {
18    pub name: String,
19    pub value: String,
20}
21
22#[derive(Debug)]
23pub struct GitHubEnvironmentClient {
24    owner: String,
25    repo: String,
26    environment: String,
27    token: String,
28    client: Client,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum VariableWriteMode {
33    Create,
34    Update,
35}
36
37impl GitHubEnvironmentClient {
38    pub fn new(
39        owner: impl Into<String>,
40        repo: impl Into<String>,
41        environment: impl Into<String>,
42        token: impl Into<String>,
43    ) -> Result<Self, String> {
44        let client = Client::builder()
45            .user_agent("xbp")
46            .build()
47            .map_err(|error| format!("Failed to build GitHub client: {}", error))?;
48        Ok(Self {
49            owner: owner.into(),
50            repo: repo.into(),
51            environment: environment.into(),
52            token: token.into(),
53            client,
54        })
55    }
56
57    pub async fn validate_repo_access(&self) -> Result<(), String> {
58        let response = self
59            .client
60            .get(format!(
61                "{}/repos/{}/{}",
62                GITHUB_API_BASE, self.owner, self.repo
63            ))
64            .headers(self.auth_headers()?)
65            .send()
66            .await
67            .map_err(|error| format!("GitHub repository check failed: {}", error))?;
68        if response.status().is_success() {
69            return Ok(());
70        }
71        let status = response.status();
72        let body = response.text().await.unwrap_or_default();
73        let detail =
74            extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
75        Err(format!(
76            "GitHub repository `{}/{}` is not accessible: {}",
77            self.owner, self.repo, detail
78        ))
79    }
80
81    pub async fn list(&self) -> Result<Vec<GitHubSecretVariable>, String> {
82        self.list_with_progress(&mut |_| {}).await
83    }
84
85    pub async fn list_with_progress<F>(
86        &self,
87        progress: &mut F,
88    ) -> Result<Vec<GitHubSecretVariable>, String>
89    where
90        F: FnMut(&str),
91    {
92        Ok(self
93            .list_raw_with_progress(progress)
94            .await?
95            .into_iter()
96            .map(normalize_variable_name_from_github)
97            .collect())
98    }
99
100    async fn list_raw_with_progress<F>(
101        &self,
102        progress: &mut F,
103    ) -> Result<Vec<GitHubSecretVariable>, String>
104    where
105        F: FnMut(&str),
106    {
107        let mut page = 1usize;
108        let mut results = Vec::new();
109        loop {
110            progress(&format!("Fetching GitHub variables page {}", page));
111            let url = format!(
112                "{}/repos/{}/{}/environments/{}/variables?per_page={}&page={}",
113                GITHUB_API_BASE,
114                self.owner,
115                self.repo,
116                self.environment,
117                VARIABLE_LIST_PAGE_SIZE,
118                page
119            );
120            let response = self
121                .client
122                .get(&url)
123                .headers(self.auth_headers()?)
124                .send()
125                .await
126                .map_err(|error| format!("GitHub list request failed: {}", error))?;
127
128            let status = response.status();
129            let body = response
130                .text()
131                .await
132                .map_err(|error| format!("GitHub response read failed: {}", error))?;
133
134            if !status.is_success() {
135                let detail = extract_github_error_message(&body)
136                    .unwrap_or_else(|| format!("HTTP {}", status));
137                return Err(format!(
138                    "GitHub API returned {} when listing variables: {}",
139                    status, detail
140                ));
141            }
142
143            let payload: ListVariablesResponse = serde_json::from_str(&body)
144                .map_err(|error| format!("GitHub response parsing failed: {}", error))?;
145            let count = payload.variables.len();
146            results.extend(
147                payload
148                    .variables
149                    .into_iter()
150                    .map(raw_variable_entry_to_secret_variable),
151            );
152            if count < VARIABLE_LIST_PAGE_SIZE {
153                break;
154            }
155            page += 1;
156        }
157        Ok(results)
158    }
159
160    pub async fn upsert(
161        &self,
162        variables: &HashMap<String, String>,
163        progress: &mut dyn FnMut(&str),
164    ) -> Result<(), String> {
165        self.ensure_environment_exists().await?;
166        let existing_names = self.list_variable_names().await?;
167        let provider_variables = variables_for_github(variables)?;
168        let plan = plan_variable_writes(&provider_variables, &existing_names);
169        if plan.is_empty() {
170            progress("Nothing to write to GitHub.");
171            return Ok(());
172        }
173
174        let headers = self.auth_headers()?;
175        let provider = self;
176        let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
177            let headers = headers.clone();
178            async move {
179                provider
180                    .write_variable(&name, &value, mode, headers)
181                    .await
182                    .map(|_| name)
183            }
184        }))
185        .buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
186
187        let mut completed = 0usize;
188        while let Some(result) = writes.next().await {
189            completed += 1;
190            progress(&format!("Writing GitHub variables ({})", completed));
191            result?;
192        }
193
194        Ok(())
195    }
196
197    pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
198        self.validate_repo_access().await?;
199        let variables = self.list().await?;
200        Ok(ProviderErrorResponse {
201            provider: "github".to_string(),
202            message: format!(
203                "GitHub access ok. Environment `{}` variables reachable ({} found).",
204                self.environment,
205                variables.len()
206            ),
207        })
208    }
209
210    async fn ensure_environment_exists(&self) -> Result<(), String> {
211        let url = format!(
212            "{}/repos/{}/{}/environments/{}",
213            GITHUB_API_BASE, self.owner, self.repo, self.environment
214        );
215        let response = self
216            .client
217            .put(&url)
218            .headers(self.auth_headers()?)
219            .json(&serde_json::json!({}))
220            .send()
221            .await
222            .map_err(|error| format!("GitHub environment create failed: {}", error))?;
223        let status = response.status();
224        if status.is_success() {
225            return Ok(());
226        }
227        let body = response.text().await.unwrap_or_default();
228        let detail =
229            extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
230        Err(format!(
231            "GitHub rejected environment `{}`: {}",
232            self.environment, detail
233        ))
234    }
235
236    async fn list_variable_names(&self) -> Result<HashMap<String, String>, String> {
237        Ok(self
238            .list_raw_with_progress(&mut |_| {})
239            .await?
240            .into_iter()
241            .map(|variable| {
242                (
243                    canonical_github_variable_name(&variable.name),
244                    variable.name,
245                )
246            })
247            .collect())
248    }
249
250    async fn write_variable(
251        &self,
252        name: &str,
253        value: &str,
254        mode: VariableWriteMode,
255        headers: HeaderMap,
256    ) -> Result<(), String> {
257        let response = self
258            .send_variable_write(name, value, mode, headers.clone())
259            .await?;
260        if response.status().is_success() {
261            return Ok(());
262        }
263
264        if let Some(retry_mode) = retry_variable_write_mode(mode, response.status()) {
265            let retry = self
266                .send_variable_write(name, value, retry_mode, headers)
267                .await?;
268            if retry.status().is_success() {
269                return Ok(());
270            }
271            let detail = describe_variable_write_failure(retry).await;
272            return Err(format!("GitHub rejected {}: {}", name, detail));
273        }
274
275        let detail = describe_variable_write_failure(response).await;
276        Err(format!("GitHub rejected {}: {}", name, detail))
277    }
278
279    async fn send_variable_write(
280        &self,
281        name: &str,
282        value: &str,
283        mode: VariableWriteMode,
284        headers: HeaderMap,
285    ) -> Result<reqwest::Response, String> {
286        let payload = serde_json::json!({
287            "name": name,
288            "value": value,
289        });
290        Ok(match mode {
291            VariableWriteMode::Create => self
292                .client
293                .post(format!(
294                    "{}/repos/{}/{}/environments/{}/variables",
295                    GITHUB_API_BASE, self.owner, self.repo, self.environment
296                ))
297                .headers(headers)
298                .json(&payload)
299                .send()
300                .await
301                .map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
302            VariableWriteMode::Update => self
303                .client
304                .patch(format!(
305                    "{}/repos/{}/{}/environments/{}/variables/{}",
306                    GITHUB_API_BASE, self.owner, self.repo, self.environment, name
307                ))
308                .headers(headers)
309                .json(&payload)
310                .send()
311                .await
312                .map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
313        })
314    }
315
316    fn auth_headers(&self) -> Result<HeaderMap, String> {
317        let mut headers = HeaderMap::new();
318        headers.insert(
319            AUTHORIZATION,
320            HeaderValue::from_str(&format!("Bearer {}", self.token))
321                .map_err(|error| format!("Invalid authorization header: {}", error))?,
322        );
323        headers.insert(
324            ACCEPT,
325            HeaderValue::from_static("application/vnd.github+json"),
326        );
327        headers.insert(
328            "X-GitHub-Api-Version",
329            HeaderValue::from_static(GITHUB_API_VERSION),
330        );
331        headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
332        Ok(headers)
333    }
334}
335
336fn retry_variable_write_mode(
337    mode: VariableWriteMode,
338    status: StatusCode,
339) -> Option<VariableWriteMode> {
340    match (mode, status) {
341        (VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
342            Some(VariableWriteMode::Update)
343        }
344        (VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
345        _ => None,
346    }
347}
348
349async fn describe_variable_write_failure(response: reqwest::Response) -> String {
350    let status = response.status();
351    let body = response.text().await.unwrap_or_default();
352    extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
353}
354
355fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
356    match value {
357        serde_json::Value::String(value) => value,
358        other => other.to_string(),
359    }
360}
361
362fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
363    GitHubSecretVariable {
364        name: variable.name,
365        value: github_variable_value_to_env_string(variable.value),
366    }
367}
368
369fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
370    GitHubSecretVariable {
371        name: github_variable_name_to_env_name(&variable.name),
372        value: variable.value,
373    }
374}
375
376fn variables_for_github(
377    variables: &HashMap<String, String>,
378) -> Result<HashMap<String, String>, String> {
379    let mut canonicalized = HashMap::<String, (String, String)>::new();
380
381    for (name, value) in variables {
382        let provider_name = env_name_to_github_variable_name(name);
383        let canonical_name = canonical_github_variable_name(&provider_name);
384
385        match canonicalized.entry(canonical_name) {
386            Entry::Vacant(entry) => {
387                entry.insert((provider_name, value.clone()));
388            }
389            Entry::Occupied(mut entry) => {
390                let (existing_name, existing_value) = entry.get_mut();
391                if existing_value != value {
392                    return Err(format!(
393                        "Local env keys collapse to the same GitHub variable `{}` but have different values: `{}` and `{}`",
394                        existing_name,
395                        existing_name,
396                        provider_name
397                    ));
398                }
399                if should_prefer_github_variable_name(&provider_name, existing_name) {
400                    *existing_name = provider_name;
401                }
402            }
403        }
404    }
405
406    Ok(canonicalized
407        .into_values()
408        .map(|(name, value)| (name, value))
409        .collect())
410}
411
412fn env_name_to_github_variable_name(name: &str) -> String {
413    name.strip_prefix(GITHUB_ENV_PREFIX)
414        .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
415        .unwrap_or_else(|| name.to_string())
416}
417
418fn github_variable_name_to_env_name(name: &str) -> String {
419    name.strip_prefix(GITHUB_STORAGE_PREFIX)
420        .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
421        .unwrap_or_else(|| name.to_string())
422}
423
424fn canonical_github_variable_name(name: &str) -> String {
425    name.to_ascii_uppercase()
426}
427
428fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
429    match (has_lowercase_ascii(candidate), has_lowercase_ascii(current)) {
430        (false, true) => true,
431        (true, false) => false,
432        _ => candidate < current,
433    }
434}
435
436fn has_lowercase_ascii(value: &str) -> bool {
437    value.chars().any(|ch| ch.is_ascii_lowercase())
438}
439
440fn plan_variable_writes(
441    secrets: &HashMap<String, String>,
442    existing_names: &HashMap<String, String>,
443) -> Vec<(String, String, VariableWriteMode)> {
444    let mut plan = secrets
445        .iter()
446        .map(|(name, value)| {
447            if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
448                (
449                    existing_name.clone(),
450                    value.clone(),
451                    VariableWriteMode::Update,
452                )
453            } else {
454                (name.clone(), value.clone(), VariableWriteMode::Create)
455            }
456        })
457        .collect::<Vec<_>>();
458    plan.sort_by(|left, right| left.0.cmp(&right.0));
459    plan
460}
461
462#[derive(Debug, Deserialize)]
463struct ListVariablesResponse {
464    variables: Vec<VariableEntry>,
465}
466
467#[derive(Debug, Deserialize)]
468struct VariableEntry {
469    name: String,
470    value: serde_json::Value,
471}
472
473#[cfg(test)]
474mod tests {
475    use super::{plan_variable_writes, variables_for_github, VariableWriteMode};
476    use std::collections::HashMap;
477
478    #[test]
479    fn variables_for_github_dedupes_case_insensitive_aliases() {
480        let mut variables = HashMap::new();
481        variables.insert(
482            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
483            "xbp".to_string(),
484        );
485        variables.insert(
486            "cloudflare_oauth_client_publisher".to_string(),
487            "xbp".to_string(),
488        );
489
490        let provider = variables_for_github(&variables).expect("dedupe should succeed");
491
492        assert_eq!(provider.len(), 1);
493        assert_eq!(
494            provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
495            Some(&"xbp".to_string())
496        );
497    }
498
499    #[test]
500    fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
501        let mut variables = HashMap::new();
502        variables.insert(
503            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
504            "one".to_string(),
505        );
506        variables.insert(
507            "cloudflare_oauth_client_publisher".to_string(),
508            "two".to_string(),
509        );
510
511        let error = variables_for_github(&variables).expect_err("conflict should be rejected");
512        assert!(error.contains("collapse to the same GitHub variable"));
513    }
514
515    #[test]
516    fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
517        let mut secrets = HashMap::new();
518        secrets.insert(
519            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
520            "xbp".to_string(),
521        );
522
523        let mut existing_names = HashMap::new();
524        existing_names.insert(
525            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
526            "cloudflare_oauth_client_publisher".to_string(),
527        );
528
529        let plan = plan_variable_writes(&secrets, &existing_names);
530
531        assert_eq!(
532            plan,
533            vec![(
534                "cloudflare_oauth_client_publisher".to_string(),
535                "xbp".to_string(),
536                VariableWriteMode::Update,
537            )]
538        );
539    }
540}