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    pub 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.into_values().collect())
407}
408
409fn env_name_to_github_variable_name(name: &str) -> String {
410    name.strip_prefix(GITHUB_ENV_PREFIX)
411        .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
412        .unwrap_or_else(|| name.to_string())
413}
414
415fn github_variable_name_to_env_name(name: &str) -> String {
416    name.strip_prefix(GITHUB_STORAGE_PREFIX)
417        .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
418        .unwrap_or_else(|| name.to_string())
419}
420
421fn canonical_github_variable_name(name: &str) -> String {
422    name.to_ascii_uppercase()
423}
424
425fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
426    match (has_lowercase_ascii(candidate), has_lowercase_ascii(current)) {
427        (false, true) => true,
428        (true, false) => false,
429        _ => candidate < current,
430    }
431}
432
433fn has_lowercase_ascii(value: &str) -> bool {
434    value.chars().any(|ch| ch.is_ascii_lowercase())
435}
436
437fn plan_variable_writes(
438    secrets: &HashMap<String, String>,
439    existing_names: &HashMap<String, String>,
440) -> Vec<(String, String, VariableWriteMode)> {
441    let mut plan = secrets
442        .iter()
443        .map(|(name, value)| {
444            if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
445                (
446                    existing_name.clone(),
447                    value.clone(),
448                    VariableWriteMode::Update,
449                )
450            } else {
451                (name.clone(), value.clone(), VariableWriteMode::Create)
452            }
453        })
454        .collect::<Vec<_>>();
455    plan.sort_by(|left, right| left.0.cmp(&right.0));
456    plan
457}
458
459#[derive(Debug, Deserialize)]
460struct ListVariablesResponse {
461    variables: Vec<VariableEntry>,
462}
463
464#[derive(Debug, Deserialize)]
465struct VariableEntry {
466    name: String,
467    value: serde_json::Value,
468}
469
470#[cfg(test)]
471mod tests {
472    use super::{plan_variable_writes, variables_for_github, VariableWriteMode};
473    use std::collections::HashMap;
474
475    #[test]
476    fn variables_for_github_dedupes_case_insensitive_aliases() {
477        let mut variables = HashMap::new();
478        variables.insert(
479            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
480            "xbp".to_string(),
481        );
482        variables.insert(
483            "cloudflare_oauth_client_publisher".to_string(),
484            "xbp".to_string(),
485        );
486
487        let provider = variables_for_github(&variables).expect("dedupe should succeed");
488
489        assert_eq!(provider.len(), 1);
490        assert_eq!(
491            provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
492            Some(&"xbp".to_string())
493        );
494    }
495
496    #[test]
497    fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
498        let mut variables = HashMap::new();
499        variables.insert(
500            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
501            "one".to_string(),
502        );
503        variables.insert(
504            "cloudflare_oauth_client_publisher".to_string(),
505            "two".to_string(),
506        );
507
508        let error = variables_for_github(&variables).expect_err("conflict should be rejected");
509        assert!(error.contains("collapse to the same GitHub variable"));
510    }
511
512    #[test]
513    fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
514        let mut secrets = HashMap::new();
515        secrets.insert(
516            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
517            "xbp".to_string(),
518        );
519
520        let mut existing_names = HashMap::new();
521        existing_names.insert(
522            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
523            "cloudflare_oauth_client_publisher".to_string(),
524        );
525
526        let plan = plan_variable_writes(&secrets, &existing_names);
527
528        assert_eq!(
529            plan,
530            vec![(
531                "cloudflare_oauth_client_publisher".to_string(),
532                "xbp".to_string(),
533                VariableWriteMode::Update,
534            )]
535        );
536    }
537}