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 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
442fn 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 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}