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.upsert_with_counts(variables, &mut |completed, total, message| {
166            if total == 0 {
167                progress(message);
168            } else {
169                progress(&format!("{message} ({completed}/{total})"));
170            }
171        })
172        .await
173    }
174
175    /// Upsert with structured progress: `(completed, total, message)`.
176    pub async fn upsert_with_counts(
177        &self,
178        variables: &HashMap<String, String>,
179        progress: &mut dyn FnMut(usize, usize, &str),
180    ) -> Result<(), String> {
181        progress(0, 0, "Ensuring GitHub environment exists");
182        self.ensure_environment_exists().await?;
183        progress(0, 0, "Listing existing GitHub variables");
184        let existing_names = self.list_variable_names().await?;
185        let provider_variables = variables_for_github(variables)?;
186        let plan = plan_variable_writes(&provider_variables, &existing_names);
187        let total = plan.len();
188        if plan.is_empty() {
189            progress(0, 0, "Nothing to write to GitHub.");
190            return Ok(());
191        }
192
193        progress(0, total, "Writing GitHub variables");
194        let headers = self.auth_headers()?;
195        let provider = self;
196        let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
197            let headers = headers.clone();
198            async move {
199                provider
200                    .write_variable(&name, &value, mode, headers)
201                    .await
202                    .map(|_| name)
203            }
204        }))
205        .buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
206
207        let mut completed = 0usize;
208        while let Some(result) = writes.next().await {
209            completed += 1;
210            match result {
211                Ok(name) => {
212                    progress(
213                        completed,
214                        total,
215                        &format!("Wrote `{name}`"),
216                    );
217                }
218                Err(error) => {
219                    progress(completed, total, "Write failed");
220                    return Err(error);
221                }
222            }
223        }
224
225        progress(total, total, "All variables written");
226        Ok(())
227    }
228
229    pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
230        self.validate_repo_access().await?;
231        let variables = self.list().await?;
232        Ok(ProviderErrorResponse {
233            provider: "github".to_string(),
234            message: format!(
235                "GitHub access ok. Environment `{}` variables reachable ({} found).",
236                self.environment,
237                variables.len()
238            ),
239        })
240    }
241
242    pub async fn ensure_environment_exists(&self) -> Result<(), String> {
243        let url = format!(
244            "{}/repos/{}/{}/environments/{}",
245            GITHUB_API_BASE, self.owner, self.repo, self.environment
246        );
247        let response = self
248            .client
249            .put(&url)
250            .headers(self.auth_headers()?)
251            .json(&serde_json::json!({}))
252            .send()
253            .await
254            .map_err(|error| format!("GitHub environment create failed: {}", error))?;
255        let status = response.status();
256        if status.is_success() {
257            return Ok(());
258        }
259        let body = response.text().await.unwrap_or_default();
260        let detail =
261            extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
262        Err(format!(
263            "GitHub rejected environment `{}`: {}",
264            self.environment, detail
265        ))
266    }
267
268    async fn list_variable_names(&self) -> Result<HashMap<String, String>, String> {
269        Ok(self
270            .list_raw_with_progress(&mut |_| {})
271            .await?
272            .into_iter()
273            .map(|variable| {
274                (
275                    canonical_github_variable_name(&variable.name),
276                    variable.name,
277                )
278            })
279            .collect())
280    }
281
282    async fn write_variable(
283        &self,
284        name: &str,
285        value: &str,
286        mode: VariableWriteMode,
287        headers: HeaderMap,
288    ) -> Result<(), String> {
289        let response = self
290            .send_variable_write(name, value, mode, headers.clone())
291            .await?;
292        if response.status().is_success() {
293            return Ok(());
294        }
295
296        if let Some(retry_mode) = retry_variable_write_mode(mode, response.status()) {
297            let retry = self
298                .send_variable_write(name, value, retry_mode, headers)
299                .await?;
300            if retry.status().is_success() {
301                return Ok(());
302            }
303            let detail = describe_variable_write_failure(retry).await;
304            return Err(format!("GitHub rejected {}: {}", name, detail));
305        }
306
307        let detail = describe_variable_write_failure(response).await;
308        Err(format!("GitHub rejected {}: {}", name, detail))
309    }
310
311    async fn send_variable_write(
312        &self,
313        name: &str,
314        value: &str,
315        mode: VariableWriteMode,
316        headers: HeaderMap,
317    ) -> Result<reqwest::Response, String> {
318        let payload = serde_json::json!({
319            "name": name,
320            "value": value,
321        });
322        Ok(match mode {
323            VariableWriteMode::Create => self
324                .client
325                .post(format!(
326                    "{}/repos/{}/{}/environments/{}/variables",
327                    GITHUB_API_BASE, self.owner, self.repo, self.environment
328                ))
329                .headers(headers)
330                .json(&payload)
331                .send()
332                .await
333                .map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
334            VariableWriteMode::Update => self
335                .client
336                .patch(format!(
337                    "{}/repos/{}/{}/environments/{}/variables/{}",
338                    GITHUB_API_BASE, self.owner, self.repo, self.environment, name
339                ))
340                .headers(headers)
341                .json(&payload)
342                .send()
343                .await
344                .map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
345        })
346    }
347
348    fn auth_headers(&self) -> Result<HeaderMap, String> {
349        let mut headers = HeaderMap::new();
350        headers.insert(
351            AUTHORIZATION,
352            HeaderValue::from_str(&format!("Bearer {}", self.token))
353                .map_err(|error| format!("Invalid authorization header: {}", error))?,
354        );
355        headers.insert(
356            ACCEPT,
357            HeaderValue::from_static("application/vnd.github+json"),
358        );
359        headers.insert(
360            "X-GitHub-Api-Version",
361            HeaderValue::from_static(GITHUB_API_VERSION),
362        );
363        headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
364        Ok(headers)
365    }
366}
367
368fn retry_variable_write_mode(
369    mode: VariableWriteMode,
370    status: StatusCode,
371) -> Option<VariableWriteMode> {
372    match (mode, status) {
373        (VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
374            Some(VariableWriteMode::Update)
375        }
376        (VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
377        _ => None,
378    }
379}
380
381async fn describe_variable_write_failure(response: reqwest::Response) -> String {
382    let status = response.status();
383    let body = response.text().await.unwrap_or_default();
384    extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
385}
386
387fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
388    match value {
389        serde_json::Value::String(value) => value,
390        other => other.to_string(),
391    }
392}
393
394fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
395    GitHubSecretVariable {
396        name: variable.name,
397        value: github_variable_value_to_env_string(variable.value),
398    }
399}
400
401fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
402    GitHubSecretVariable {
403        name: github_variable_name_to_env_name(&variable.name),
404        value: variable.value,
405    }
406}
407
408fn variables_for_github(
409    variables: &HashMap<String, String>,
410) -> Result<HashMap<String, String>, String> {
411    let mut canonicalized = HashMap::<String, (String, String)>::new();
412
413    for (name, value) in variables {
414        let provider_name = env_name_to_github_variable_name(name);
415        validate_github_variable_name(&provider_name)?;
416        let canonical_name = canonical_github_variable_name(&provider_name);
417
418        match canonicalized.entry(canonical_name) {
419            Entry::Vacant(entry) => {
420                entry.insert((provider_name, value.clone()));
421            }
422            Entry::Occupied(mut entry) => {
423                let (existing_name, existing_value) = entry.get_mut();
424                if existing_value != value {
425                    return Err(format!(
426                        "Local env keys collapse to the same GitHub variable `{}` but have different values: `{}` and `{}`",
427                        existing_name,
428                        existing_name,
429                        provider_name
430                    ));
431                }
432                if should_prefer_github_variable_name(&provider_name, existing_name) {
433                    *existing_name = provider_name;
434                }
435            }
436        }
437    }
438
439    Ok(canonicalized.into_values().collect())
440}
441
442/// GitHub Actions environment variable names: `[a-zA-Z_][a-zA-Z0-9_]*`.
443fn validate_github_variable_name(name: &str) -> Result<(), String> {
444    let mut chars = name.chars();
445    let Some(first) = chars.next() else {
446        return Err("GitHub variable name cannot be empty.".to_string());
447    };
448    if !(first.is_ascii_alphabetic() || first == '_') {
449        return Err(format!(
450            "Invalid GitHub variable name `{name}`: must start with a letter or underscore \
451(got leading character U+{:04X}). If this came from a `.env` file, re-save without a UTF-8 BOM.",
452            first as u32
453        ));
454    }
455    if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
456        return Err(format!(
457            "Invalid GitHub variable name `{name}`: only letters, digits, and underscores are allowed."
458        ));
459    }
460    Ok(())
461}
462
463fn env_name_to_github_variable_name(name: &str) -> String {
464    // Defense in depth: strip BOM / zero-width junk even if parse missed them.
465    let name = name
466        .trim()
467        .trim_start_matches('\u{feff}')
468        .chars()
469        .filter(|ch| {
470            !matches!(
471                ch,
472                '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
473            )
474        })
475        .collect::<String>();
476
477    name.strip_prefix(GITHUB_ENV_PREFIX)
478        .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
479        .unwrap_or(name)
480}
481
482fn github_variable_name_to_env_name(name: &str) -> String {
483    name.strip_prefix(GITHUB_STORAGE_PREFIX)
484        .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
485        .unwrap_or_else(|| name.to_string())
486}
487
488fn canonical_github_variable_name(name: &str) -> String {
489    name.to_ascii_uppercase()
490}
491
492fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
493    match (has_lowercase_ascii(candidate), has_lowercase_ascii(current)) {
494        (false, true) => true,
495        (true, false) => false,
496        _ => candidate < current,
497    }
498}
499
500fn has_lowercase_ascii(value: &str) -> bool {
501    value.chars().any(|ch| ch.is_ascii_lowercase())
502}
503
504fn plan_variable_writes(
505    secrets: &HashMap<String, String>,
506    existing_names: &HashMap<String, String>,
507) -> Vec<(String, String, VariableWriteMode)> {
508    let mut plan = secrets
509        .iter()
510        .map(|(name, value)| {
511            if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
512                (
513                    existing_name.clone(),
514                    value.clone(),
515                    VariableWriteMode::Update,
516                )
517            } else {
518                (name.clone(), value.clone(), VariableWriteMode::Create)
519            }
520        })
521        .collect::<Vec<_>>();
522    plan.sort_by(|left, right| left.0.cmp(&right.0));
523    plan
524}
525
526#[derive(Debug, Deserialize)]
527struct ListVariablesResponse {
528    variables: Vec<VariableEntry>,
529}
530
531#[derive(Debug, Deserialize)]
532struct VariableEntry {
533    name: String,
534    value: serde_json::Value,
535}
536
537#[cfg(test)]
538mod tests {
539    use super::{plan_variable_writes, variables_for_github, VariableWriteMode};
540    use std::collections::HashMap;
541
542    #[test]
543    fn variables_for_github_strips_bom_from_keys() {
544        let mut variables = HashMap::new();
545        variables.insert("\u{feff}MOLLIE_API_KEY".to_string(), "secret".to_string());
546
547        let provider = variables_for_github(&variables).expect("bom key should sanitize");
548        assert_eq!(provider.get("MOLLIE_API_KEY"), Some(&"secret".to_string()));
549        assert!(!provider.keys().any(|k| k.contains('\u{feff}')));
550    }
551
552    #[test]
553    fn variables_for_github_dedupes_case_insensitive_aliases() {
554        let mut variables = HashMap::new();
555        variables.insert(
556            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
557            "xbp".to_string(),
558        );
559        variables.insert(
560            "cloudflare_oauth_client_publisher".to_string(),
561            "xbp".to_string(),
562        );
563
564        let provider = variables_for_github(&variables).expect("dedupe should succeed");
565
566        assert_eq!(provider.len(), 1);
567        assert_eq!(
568            provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
569            Some(&"xbp".to_string())
570        );
571    }
572
573    #[test]
574    fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
575        let mut variables = HashMap::new();
576        variables.insert(
577            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
578            "one".to_string(),
579        );
580        variables.insert(
581            "cloudflare_oauth_client_publisher".to_string(),
582            "two".to_string(),
583        );
584
585        let error = variables_for_github(&variables).expect_err("conflict should be rejected");
586        assert!(error.contains("collapse to the same GitHub variable"));
587    }
588
589    #[test]
590    fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
591        let mut secrets = HashMap::new();
592        secrets.insert(
593            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
594            "xbp".to_string(),
595        );
596
597        let mut existing_names = HashMap::new();
598        existing_names.insert(
599            "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
600            "cloudflare_oauth_client_publisher".to_string(),
601        );
602
603        let plan = plan_variable_writes(&secrets, &existing_names);
604
605        assert_eq!(
606            plan,
607            vec![(
608                "cloudflare_oauth_client_publisher".to_string(),
609                "xbp".to_string(),
610                VariableWriteMode::Update,
611            )]
612        );
613    }
614}