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(variables: &HashMap<String, String>) -> Result<HashMap<String, String>, String> {
377 let mut canonicalized = HashMap::<String, (String, String)>::new();
378
379 for (name, value) in variables {
380 let provider_name = env_name_to_github_variable_name(name);
381 let canonical_name = canonical_github_variable_name(&provider_name);
382
383 match canonicalized.entry(canonical_name) {
384 Entry::Vacant(entry) => {
385 entry.insert((provider_name, value.clone()));
386 }
387 Entry::Occupied(mut entry) => {
388 let (existing_name, existing_value) = entry.get_mut();
389 if existing_value != value {
390 return Err(format!(
391 "Local env keys collapse to the same GitHub variable `{}` but have different values: `{}` and `{}`",
392 existing_name,
393 existing_name,
394 provider_name
395 ));
396 }
397 if should_prefer_github_variable_name(&provider_name, existing_name) {
398 *existing_name = provider_name;
399 }
400 }
401 }
402 }
403
404 Ok(canonicalized
405 .into_values()
406 .map(|(name, value)| (name, value))
407 .collect())
408}
409
410fn env_name_to_github_variable_name(name: &str) -> String {
411 name.strip_prefix(GITHUB_ENV_PREFIX)
412 .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
413 .unwrap_or_else(|| name.to_string())
414}
415
416fn github_variable_name_to_env_name(name: &str) -> String {
417 name.strip_prefix(GITHUB_STORAGE_PREFIX)
418 .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
419 .unwrap_or_else(|| name.to_string())
420}
421
422fn canonical_github_variable_name(name: &str) -> String {
423 name.to_ascii_uppercase()
424}
425
426fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
427 match (
428 has_lowercase_ascii(candidate),
429 has_lowercase_ascii(current),
430 ) {
431 (false, true) => true,
432 (true, false) => false,
433 _ => candidate < current,
434 }
435}
436
437fn has_lowercase_ascii(value: &str) -> bool {
438 value.chars().any(|ch| ch.is_ascii_lowercase())
439}
440
441fn plan_variable_writes(
442 secrets: &HashMap<String, String>,
443 existing_names: &HashMap<String, String>,
444) -> Vec<(String, String, VariableWriteMode)> {
445 let mut plan = secrets
446 .iter()
447 .map(|(name, value)| {
448 if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
449 (
450 existing_name.clone(),
451 value.clone(),
452 VariableWriteMode::Update,
453 )
454 } else {
455 (
456 name.clone(),
457 value.clone(),
458 VariableWriteMode::Create,
459 )
460 }
461 })
462 .collect::<Vec<_>>();
463 plan.sort_by(|left, right| left.0.cmp(&right.0));
464 plan
465}
466
467#[derive(Debug, Deserialize)]
468struct ListVariablesResponse {
469 variables: Vec<VariableEntry>,
470}
471
472#[derive(Debug, Deserialize)]
473struct VariableEntry {
474 name: String,
475 value: serde_json::Value,
476}
477
478#[cfg(test)]
479mod tests {
480 use super::{
481 plan_variable_writes, variables_for_github, VariableWriteMode,
482 };
483 use std::collections::HashMap;
484
485 #[test]
486 fn variables_for_github_dedupes_case_insensitive_aliases() {
487 let mut variables = HashMap::new();
488 variables.insert(
489 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
490 "xbp".to_string(),
491 );
492 variables.insert(
493 "cloudflare_oauth_client_publisher".to_string(),
494 "xbp".to_string(),
495 );
496
497 let provider = variables_for_github(&variables).expect("dedupe should succeed");
498
499 assert_eq!(provider.len(), 1);
500 assert_eq!(
501 provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
502 Some(&"xbp".to_string())
503 );
504 }
505
506 #[test]
507 fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
508 let mut variables = HashMap::new();
509 variables.insert(
510 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
511 "one".to_string(),
512 );
513 variables.insert(
514 "cloudflare_oauth_client_publisher".to_string(),
515 "two".to_string(),
516 );
517
518 let error = variables_for_github(&variables).expect_err("conflict should be rejected");
519 assert!(error.contains("collapse to the same GitHub variable"));
520 }
521
522 #[test]
523 fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
524 let mut secrets = HashMap::new();
525 secrets.insert(
526 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
527 "xbp".to_string(),
528 );
529
530 let mut existing_names = HashMap::new();
531 existing_names.insert(
532 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
533 "cloudflare_oauth_client_publisher".to_string(),
534 );
535
536 let plan = plan_variable_writes(&secrets, &existing_names);
537
538 assert_eq!(
539 plan,
540 vec![(
541 "cloudflare_oauth_client_publisher".to_string(),
542 "xbp".to_string(),
543 VariableWriteMode::Update,
544 )]
545 );
546 }
547}