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};
8use std::time::Duration;
9
10const GITHUB_API_BASE: &str = "https://api.github.com";
11const GITHUB_API_VERSION: &str = "2022-11-28";
12const VARIABLE_LIST_PAGE_SIZE: usize = 30;
13/// Parallel GitHub env variable writes. Kept low to avoid secondary rate limits
14/// when pushing large `.env` files (dozens of keys).
15const VARIABLE_WRITE_CONCURRENCY: usize = 2;
16/// How many times to retry a single key after HTTP 429 / secondary rate limit.
17const VARIABLE_WRITE_MAX_RATE_LIMIT_RETRIES: u32 = 8;
18const VARIABLE_WRITE_DEFAULT_BACKOFF_MS: u64 = 1_000;
19const VARIABLE_WRITE_MAX_BACKOFF_MS: u64 = 60_000;
20const GITHUB_ENV_PREFIX: &str = "GITHUB_";
21const GITHUB_STORAGE_PREFIX: &str = "_GITHUB_";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct GitHubSecretVariable {
25    pub name: String,
26    pub value: String,
27}
28
29#[derive(Debug)]
30pub struct GitHubEnvironmentClient {
31    owner: String,
32    repo: String,
33    environment: String,
34    token: String,
35    client: Client,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum VariableWriteMode {
40    Create,
41    Update,
42}
43
44impl GitHubEnvironmentClient {
45    pub fn new(
46        owner: impl Into<String>,
47        repo: impl Into<String>,
48        environment: impl Into<String>,
49        token: impl Into<String>,
50    ) -> Result<Self, String> {
51        let client = Client::builder()
52            .user_agent("xbp")
53            .build()
54            .map_err(|error| format!("Failed to build GitHub client: {}", error))?;
55        Ok(Self {
56            owner: owner.into(),
57            repo: repo.into(),
58            environment: environment.into(),
59            token: token.into(),
60            client,
61        })
62    }
63
64    pub async fn validate_repo_access(&self) -> Result<(), String> {
65        let response = self
66            .client
67            .get(format!(
68                "{}/repos/{}/{}",
69                GITHUB_API_BASE, self.owner, self.repo
70            ))
71            .headers(self.auth_headers()?)
72            .send()
73            .await
74            .map_err(|error| format!("GitHub repository check failed: {}", error))?;
75        if response.status().is_success() {
76            return Ok(());
77        }
78        let status = response.status();
79        let body = response.text().await.unwrap_or_default();
80        let detail =
81            extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
82        Err(format!(
83            "GitHub repository `{}/{}` is not accessible: {}",
84            self.owner, self.repo, detail
85        ))
86    }
87
88    pub async fn list(&self) -> Result<Vec<GitHubSecretVariable>, String> {
89        self.list_with_progress(&mut |_| {}).await
90    }
91
92    pub async fn list_with_progress<F>(
93        &self,
94        progress: &mut F,
95    ) -> Result<Vec<GitHubSecretVariable>, String>
96    where
97        F: FnMut(&str),
98    {
99        Ok(self
100            .list_raw_with_progress(progress)
101            .await?
102            .into_iter()
103            .map(normalize_variable_name_from_github)
104            .collect())
105    }
106
107    async fn list_raw_with_progress<F>(
108        &self,
109        progress: &mut F,
110    ) -> Result<Vec<GitHubSecretVariable>, String>
111    where
112        F: FnMut(&str),
113    {
114        let mut page = 1usize;
115        let mut results = Vec::new();
116        loop {
117            progress(&format!("Fetching GitHub variables page {}", page));
118            let url = format!(
119                "{}/repos/{}/{}/environments/{}/variables?per_page={}&page={}",
120                GITHUB_API_BASE,
121                self.owner,
122                self.repo,
123                self.environment,
124                VARIABLE_LIST_PAGE_SIZE,
125                page
126            );
127            let response = self
128                .client
129                .get(&url)
130                .headers(self.auth_headers()?)
131                .send()
132                .await
133                .map_err(|error| format!("GitHub list request failed: {}", error))?;
134
135            let status = response.status();
136            let body = response
137                .text()
138                .await
139                .map_err(|error| format!("GitHub response read failed: {}", error))?;
140
141            if !status.is_success() {
142                let detail = extract_github_error_message(&body)
143                    .unwrap_or_else(|| format!("HTTP {}", status));
144                return Err(format!(
145                    "GitHub API returned {} when listing variables: {}",
146                    status, detail
147                ));
148            }
149
150            let payload: ListVariablesResponse = serde_json::from_str(&body)
151                .map_err(|error| format!("GitHub response parsing failed: {}", error))?;
152            let count = payload.variables.len();
153            results.extend(
154                payload
155                    .variables
156                    .into_iter()
157                    .map(raw_variable_entry_to_secret_variable),
158            );
159            if count < VARIABLE_LIST_PAGE_SIZE {
160                break;
161            }
162            page += 1;
163        }
164        Ok(results)
165    }
166
167    pub async fn upsert(
168        &self,
169        variables: &HashMap<String, String>,
170        progress: &mut dyn FnMut(&str),
171    ) -> Result<(), String> {
172        self.upsert_with_counts(variables, &mut |completed, total, message| {
173            if total == 0 {
174                progress(message);
175            } else {
176                progress(&format!("{message} ({completed}/{total})"));
177            }
178        })
179        .await
180    }
181
182    /// Upsert with structured progress: `(completed, total, message)`.
183    pub async fn upsert_with_counts(
184        &self,
185        variables: &HashMap<String, String>,
186        progress: &mut dyn FnMut(usize, usize, &str),
187    ) -> Result<(), String> {
188        progress(0, 0, "Ensuring GitHub environment exists");
189        self.ensure_environment_exists().await?;
190        progress(0, 0, "Listing existing GitHub variables");
191        let existing_names = self.list_variable_names().await?;
192        let provider_variables = variables_for_github(variables)?;
193        let plan = plan_variable_writes(&provider_variables, &existing_names);
194        let total = plan.len();
195        if plan.is_empty() {
196            progress(0, 0, "Nothing to write to GitHub.");
197            return Ok(());
198        }
199
200        progress(0, total, "Writing GitHub variables");
201        let headers = self.auth_headers()?;
202        let provider = self;
203        let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
204            let headers = headers.clone();
205            async move {
206                provider
207                    .write_variable(&name, &value, mode, headers)
208                    .await
209                    .map(|_| name)
210            }
211        }))
212        .buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
213
214        let mut completed = 0usize;
215        while let Some(result) = writes.next().await {
216            completed += 1;
217            match result {
218                Ok(name) => {
219                    progress(
220                        completed,
221                        total,
222                        &format!("Wrote `{name}`"),
223                    );
224                }
225                Err(error) => {
226                    progress(completed, total, "Write failed");
227                    return Err(error);
228                }
229            }
230        }
231
232        progress(total, total, "All variables written");
233        Ok(())
234    }
235
236    pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
237        self.validate_repo_access().await?;
238        let variables = self.list().await?;
239        Ok(ProviderErrorResponse {
240            provider: "github".to_string(),
241            message: format!(
242                "GitHub access ok. Environment `{}` variables reachable ({} found).",
243                self.environment,
244                variables.len()
245            ),
246        })
247    }
248
249    pub async fn ensure_environment_exists(&self) -> Result<(), String> {
250        let url = format!(
251            "{}/repos/{}/{}/environments/{}",
252            GITHUB_API_BASE, self.owner, self.repo, self.environment
253        );
254        let response = self
255            .client
256            .put(&url)
257            .headers(self.auth_headers()?)
258            .json(&serde_json::json!({}))
259            .send()
260            .await
261            .map_err(|error| format!("GitHub environment create failed: {}", error))?;
262        let status = response.status();
263        if status.is_success() {
264            return Ok(());
265        }
266        let body = response.text().await.unwrap_or_default();
267        let detail =
268            extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
269        Err(format!(
270            "GitHub rejected environment `{}`: {}",
271            self.environment, detail
272        ))
273    }
274
275    async fn list_variable_names(&self) -> Result<HashMap<String, String>, String> {
276        Ok(self
277            .list_raw_with_progress(&mut |_| {})
278            .await?
279            .into_iter()
280            .map(|variable| {
281                (
282                    canonical_github_variable_name(&variable.name),
283                    variable.name,
284                )
285            })
286            .collect())
287    }
288
289    async fn write_variable(
290        &self,
291        name: &str,
292        value: &str,
293        mode: VariableWriteMode,
294        headers: HeaderMap,
295    ) -> Result<(), String> {
296        let mut mode = mode;
297        let mut mode_flipped = false;
298        let mut rate_limit_attempts = 0u32;
299
300        loop {
301            let response = self
302                .send_variable_write(name, value, mode, headers.clone())
303                .await?;
304            let status = response.status();
305            if status.is_success() {
306                return Ok(());
307            }
308
309            // Create/update race: flip mode once and retry immediately.
310            if !mode_flipped {
311                if let Some(retry_mode) = retry_variable_write_mode(mode, status) {
312                    mode = retry_mode;
313                    mode_flipped = true;
314                    // Drain body so the connection can be reused.
315                    let _ = response.bytes().await;
316                    continue;
317                }
318            }
319
320            let retry_after_ms = parse_retry_after_ms(response.headers());
321            let detail = describe_variable_write_failure(response).await;
322
323            if is_github_rate_limit_detail(status, &detail) {
324                if rate_limit_attempts >= VARIABLE_WRITE_MAX_RATE_LIMIT_RETRIES {
325                    return Err(format!(
326                        "GitHub rejected {}: {} (gave up after {} rate-limit retries)",
327                        name, detail, VARIABLE_WRITE_MAX_RATE_LIMIT_RETRIES
328                    ));
329                }
330                let backoff_ms = rate_limit_backoff_ms(retry_after_ms, rate_limit_attempts);
331                rate_limit_attempts += 1;
332                tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
333                continue;
334            }
335
336            return Err(format!("GitHub rejected {}: {}", name, detail));
337        }
338    }
339
340    async fn send_variable_write(
341        &self,
342        name: &str,
343        value: &str,
344        mode: VariableWriteMode,
345        headers: HeaderMap,
346    ) -> Result<reqwest::Response, String> {
347        let payload = serde_json::json!({
348            "name": name,
349            "value": value,
350        });
351        Ok(match mode {
352            VariableWriteMode::Create => self
353                .client
354                .post(format!(
355                    "{}/repos/{}/{}/environments/{}/variables",
356                    GITHUB_API_BASE, self.owner, self.repo, self.environment
357                ))
358                .headers(headers)
359                .json(&payload)
360                .send()
361                .await
362                .map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
363            VariableWriteMode::Update => self
364                .client
365                .patch(format!(
366                    "{}/repos/{}/{}/environments/{}/variables/{}",
367                    GITHUB_API_BASE, self.owner, self.repo, self.environment, name
368                ))
369                .headers(headers)
370                .json(&payload)
371                .send()
372                .await
373                .map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
374        })
375    }
376
377    fn auth_headers(&self) -> Result<HeaderMap, String> {
378        let mut headers = HeaderMap::new();
379        headers.insert(
380            AUTHORIZATION,
381            HeaderValue::from_str(&format!("Bearer {}", self.token))
382                .map_err(|error| format!("Invalid authorization header: {}", error))?,
383        );
384        headers.insert(
385            ACCEPT,
386            HeaderValue::from_static("application/vnd.github+json"),
387        );
388        headers.insert(
389            "X-GitHub-Api-Version",
390            HeaderValue::from_static(GITHUB_API_VERSION),
391        );
392        headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
393        Ok(headers)
394    }
395}
396
397fn retry_variable_write_mode(
398    mode: VariableWriteMode,
399    status: StatusCode,
400) -> Option<VariableWriteMode> {
401    match (mode, status) {
402        (VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
403            Some(VariableWriteMode::Update)
404        }
405        (VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
406        _ => None,
407    }
408}
409
410async fn describe_variable_write_failure(response: reqwest::Response) -> String {
411    let status = response.status();
412    let body = response.text().await.unwrap_or_default();
413    extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
414}
415
416/// True for HTTP 429 and GitHub secondary rate-limit style 403 messages.
417fn is_github_rate_limit_detail(status: StatusCode, detail: &str) -> bool {
418    if status == StatusCode::TOO_MANY_REQUESTS {
419        return true;
420    }
421    if status == StatusCode::FORBIDDEN {
422        let lower = detail.to_ascii_lowercase();
423        return lower.contains("rate limit")
424            || lower.contains("secondary rate")
425            || lower.contains("abuse detection")
426            || lower.contains("too many requests");
427    }
428    false
429}
430
431/// Prefer `Retry-After` (seconds). Fall back to exponential backoff capped at 60s.
432fn rate_limit_backoff_ms(retry_after_ms: Option<u64>, attempt: u32) -> u64 {
433    if let Some(ms) = retry_after_ms {
434        return ms.clamp(100, VARIABLE_WRITE_MAX_BACKOFF_MS);
435    }
436    let shift = attempt.min(6);
437    VARIABLE_WRITE_DEFAULT_BACKOFF_MS
438        .saturating_mul(1u64 << shift)
439        .min(VARIABLE_WRITE_MAX_BACKOFF_MS)
440}
441
442fn parse_retry_after_ms(headers: &HeaderMap) -> Option<u64> {
443    let value = headers.get("retry-after")?.to_str().ok()?.trim();
444    if value.is_empty() {
445        return None;
446    }
447    // GitHub typically sends delay-seconds.
448    if let Ok(secs) = value.parse::<u64>() {
449        return Some(secs.saturating_mul(1000));
450    }
451    None
452}
453
454fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
455    match value {
456        serde_json::Value::String(value) => value,
457        other => other.to_string(),
458    }
459}
460
461fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
462    GitHubSecretVariable {
463        name: variable.name,
464        value: github_variable_value_to_env_string(variable.value),
465    }
466}
467
468fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
469    GitHubSecretVariable {
470        name: github_variable_name_to_env_name(&variable.name),
471        value: variable.value,
472    }
473}
474
475fn variables_for_github(
476    variables: &HashMap<String, String>,
477) -> Result<HashMap<String, String>, String> {
478    let mut canonicalized = HashMap::<String, (String, String)>::new();
479
480    for (name, value) in variables {
481        let provider_name = env_name_to_github_variable_name(name);
482        validate_github_variable_name(&provider_name)?;
483        let canonical_name = canonical_github_variable_name(&provider_name);
484
485        match canonicalized.entry(canonical_name) {
486            Entry::Vacant(entry) => {
487                entry.insert((provider_name, value.clone()));
488            }
489            Entry::Occupied(mut entry) => {
490                let (existing_name, existing_value) = entry.get_mut();
491                if existing_value != value {
492                    return Err(format!(
493                        "Local env keys collapse to the same GitHub variable `{}` but have different values: `{}` and `{}`",
494                        existing_name,
495                        existing_name,
496                        provider_name
497                    ));
498                }
499                if should_prefer_github_variable_name(&provider_name, existing_name) {
500                    *existing_name = provider_name;
501                }
502            }
503        }
504    }
505
506    Ok(canonicalized.into_values().collect())
507}
508
509/// GitHub Actions environment variable names: `[a-zA-Z_][a-zA-Z0-9_]*`.
510fn validate_github_variable_name(name: &str) -> Result<(), String> {
511    let mut chars = name.chars();
512    let Some(first) = chars.next() else {
513        return Err("GitHub variable name cannot be empty.".to_string());
514    };
515    if !(first.is_ascii_alphabetic() || first == '_') {
516        return Err(format!(
517            "Invalid GitHub variable name `{name}`: must start with a letter or underscore \
518(got leading character U+{:04X}). If this came from a `.env` file, re-save without a UTF-8 BOM.",
519            first as u32
520        ));
521    }
522    if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
523        return Err(format!(
524            "Invalid GitHub variable name `{name}`: only letters, digits, and underscores are allowed."
525        ));
526    }
527    Ok(())
528}
529
530fn env_name_to_github_variable_name(name: &str) -> String {
531    // Defense in depth: strip BOM / zero-width junk even if parse missed them.
532    let name = name
533        .trim()
534        .trim_start_matches('\u{feff}')
535        .chars()
536        .filter(|ch| {
537            !matches!(
538                ch,
539                '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
540            )
541        })
542        .collect::<String>();
543
544    name.strip_prefix(GITHUB_ENV_PREFIX)
545        .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
546        .unwrap_or(name)
547}
548
549fn github_variable_name_to_env_name(name: &str) -> String {
550    name.strip_prefix(GITHUB_STORAGE_PREFIX)
551        .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
552        .unwrap_or_else(|| name.to_string())
553}
554
555fn canonical_github_variable_name(name: &str) -> String {
556    name.to_ascii_uppercase()
557}
558
559fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
560    match (has_lowercase_ascii(candidate), has_lowercase_ascii(current)) {
561        (false, true) => true,
562        (true, false) => false,
563        _ => candidate < current,
564    }
565}
566
567fn has_lowercase_ascii(value: &str) -> bool {
568    value.chars().any(|ch| ch.is_ascii_lowercase())
569}
570
571fn plan_variable_writes(
572    secrets: &HashMap<String, String>,
573    existing_names: &HashMap<String, String>,
574) -> Vec<(String, String, VariableWriteMode)> {
575    let mut plan = secrets
576        .iter()
577        .map(|(name, value)| {
578            if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
579                (
580                    existing_name.clone(),
581                    value.clone(),
582                    VariableWriteMode::Update,
583                )
584            } else {
585                (name.clone(), value.clone(), VariableWriteMode::Create)
586            }
587        })
588        .collect::<Vec<_>>();
589    plan.sort_by(|left, right| left.0.cmp(&right.0));
590    plan
591}
592
593#[derive(Debug, Deserialize)]
594struct ListVariablesResponse {
595    variables: Vec<VariableEntry>,
596}
597
598#[derive(Debug, Deserialize)]
599struct VariableEntry {
600    name: String,
601    value: serde_json::Value,
602}
603
604#[cfg(test)]
605mod tests {
606    use super::{
607        is_github_rate_limit_detail, parse_retry_after_ms, plan_variable_writes,
608        rate_limit_backoff_ms, variables_for_github, VariableWriteMode,
609    };
610    use reqwest::header::{HeaderMap, HeaderValue};
611    use reqwest::StatusCode;
612    use std::collections::HashMap;
613
614    #[test]
615    fn rate_limit_detects_429() {
616        assert!(is_github_rate_limit_detail(
617            StatusCode::TOO_MANY_REQUESTS,
618            "HTTP 429"
619        ));
620    }
621
622    #[test]
623    fn rate_limit_detects_secondary_403() {
624        assert!(is_github_rate_limit_detail(
625            StatusCode::FORBIDDEN,
626            "You have exceeded a secondary rate limit. Please wait a few minutes."
627        ));
628        assert!(!is_github_rate_limit_detail(
629            StatusCode::FORBIDDEN,
630            "Resource not accessible by integration"
631        ));
632    }
633
634    #[test]
635    fn rate_limit_backoff_prefers_retry_after() {
636        assert_eq!(rate_limit_backoff_ms(Some(5_000), 0), 5_000);
637        assert_eq!(rate_limit_backoff_ms(Some(10), 0), 100); // floor
638        assert_eq!(rate_limit_backoff_ms(None, 0), 1_000);
639        assert_eq!(rate_limit_backoff_ms(None, 1), 2_000);
640        assert_eq!(rate_limit_backoff_ms(None, 2), 4_000);
641        assert_eq!(rate_limit_backoff_ms(None, 10), 60_000); // cap
642    }
643
644    #[test]
645    fn parse_retry_after_seconds_header() {
646        let mut headers = HeaderMap::new();
647        headers.insert("retry-after", HeaderValue::from_static("3"));
648        assert_eq!(parse_retry_after_ms(&headers), Some(3_000));
649    }
650
651    #[test]
652    fn variables_for_github_strips_bom_from_keys() {
653        let mut variables = HashMap::new();
654        variables.insert("\u{feff}MOLLIE_API_KEY".to_string(), "secret".to_string());
655
656        let provider = variables_for_github(&variables).expect("bom key should sanitize");
657        assert_eq!(provider.get("MOLLIE_API_KEY"), Some(&"secret".to_string()));
658        assert!(!provider.keys().any(|k| k.contains('\u{feff}')));
659    }
660
661    #[test]
662    fn variables_for_github_dedupes_case_insensitive_aliases() {
663        let mut variables = HashMap::new();
664        variables.insert(
665            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
666            "xbp".to_string(),
667        );
668        variables.insert(
669            "cloudflare_oauth_client_publisher".to_string(),
670            "xbp".to_string(),
671        );
672
673        let provider = variables_for_github(&variables).expect("dedupe should succeed");
674
675        assert_eq!(provider.len(), 1);
676        assert_eq!(
677            provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
678            Some(&"xbp".to_string())
679        );
680    }
681
682    #[test]
683    fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
684        let mut variables = HashMap::new();
685        variables.insert(
686            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
687            "one".to_string(),
688        );
689        variables.insert(
690            "cloudflare_oauth_client_publisher".to_string(),
691            "two".to_string(),
692        );
693
694        let error = variables_for_github(&variables).expect_err("conflict should be rejected");
695        assert!(error.contains("collapse to the same GitHub variable"));
696    }
697
698    #[test]
699    fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
700        let mut secrets = HashMap::new();
701        secrets.insert(
702            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
703            "xbp".to_string(),
704        );
705
706        let mut existing_names = HashMap::new();
707        existing_names.insert(
708            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
709            "cloudflare_oauth_client_publisher".to_string(),
710        );
711
712        let plan = plan_variable_writes(&secrets, &existing_names);
713
714        assert_eq!(
715            plan,
716            vec![(
717                "cloudflare_oauth_client_publisher".to_string(),
718                "xbp".to_string(),
719                VariableWriteMode::Update,
720            )]
721        );
722    }
723}