Skip to main content

git_sshripped_recipient/
lib.rs

1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use std::fs;
6use std::io::Write;
7use std::iter;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::str::FromStr;
11
12use age::Encryptor;
13use age::ssh::Recipient as SshRecipient;
14use anyhow::{Context, Result, bail};
15use base64::Engine;
16use git_sshripped_recipient_models::{RecipientKey, RecipientSource};
17use reqwest::StatusCode;
18use reqwest::header::{AUTHORIZATION, ETAG, HeaderMap, HeaderValue, IF_NONE_MATCH, USER_AGENT};
19use sha2::{Digest, Sha256};
20
21const SUPPORTED_KEY_TYPES: [&str; 2] = ["ssh-ed25519", "ssh-rsa"];
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum GithubBackend {
25    Gh,
26    Rest,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GithubAuthMode {
31    Auto,
32    Gh,
33    Token,
34    Anonymous,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct GithubFetchOptions {
39    pub api_base_url: String,
40    pub web_base_url: String,
41    pub auth_mode: GithubAuthMode,
42    pub private_source_hard_fail: bool,
43}
44
45impl Default for GithubFetchOptions {
46    fn default() -> Self {
47        Self {
48            api_base_url: "https://api.github.com".to_string(),
49            web_base_url: "https://github.com".to_string(),
50            auth_mode: GithubAuthMode::Auto,
51            private_source_hard_fail: true,
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct GithubFetchMetadata {
58    pub rate_limit_remaining: Option<u32>,
59    pub rate_limit_reset_unix: Option<u64>,
60    pub etag: Option<String>,
61    pub not_modified: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct GithubUserKeys {
66    pub username: String,
67    pub url: String,
68    pub keys: Vec<String>,
69    pub backend: GithubBackend,
70    pub authenticated: bool,
71    pub auth_mode: GithubAuthMode,
72    pub metadata: GithubFetchMetadata,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct GithubTeamMembers {
77    pub org: String,
78    pub team: String,
79    pub members: Vec<String>,
80    pub backend: GithubBackend,
81    pub authenticated: bool,
82    pub auth_mode: GithubAuthMode,
83    pub metadata: GithubFetchMetadata,
84}
85
86fn fingerprint_for_public_key(key_type: &str, key_body: &str) -> String {
87    let mut hasher = Sha256::new();
88    hasher.update(key_type.as_bytes());
89    hasher.update([b':']);
90    hasher.update(key_body.as_bytes());
91    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize())
92}
93
94#[must_use]
95pub fn fingerprint_for_public_key_line(public_key_line: &str) -> Option<String> {
96    let mut parts = public_key_line.split_whitespace();
97    let key_type = parts.next()?;
98    let key_body = parts.next()?;
99    Some(fingerprint_for_public_key(key_type, key_body))
100}
101
102fn gh_installed() -> bool {
103    Command::new("gh")
104        .arg("--version")
105        .output()
106        .is_ok_and(|out| out.status.success())
107}
108
109fn parse_auth_mode(raw: &str) -> Result<GithubAuthMode> {
110    match raw.trim().to_ascii_lowercase().as_str() {
111        "auto" => Ok(GithubAuthMode::Auto),
112        "gh" => Ok(GithubAuthMode::Gh),
113        "token" => Ok(GithubAuthMode::Token),
114        "anonymous" => Ok(GithubAuthMode::Anonymous),
115        other => bail!("unsupported github auth mode '{other}'; expected auto|gh|token|anonymous"),
116    }
117}
118
119fn env_bool(name: &str) -> Option<bool> {
120    let raw = std::env::var(name).ok()?;
121    match raw.trim().to_ascii_lowercase().as_str() {
122        "1" | "true" | "yes" | "on" => Some(true),
123        "0" | "false" | "no" | "off" => Some(false),
124        _ => None,
125    }
126}
127
128fn fetch_options_from_env() -> Result<GithubFetchOptions> {
129    let mut options = GithubFetchOptions::default();
130
131    if let Ok(api_base) = std::env::var("GSC_GITHUB_API_BASE")
132        && !api_base.trim().is_empty()
133    {
134        options.api_base_url = api_base.trim_end_matches('/').to_string();
135    }
136    if let Ok(web_base) = std::env::var("GSC_GITHUB_WEB_BASE")
137        && !web_base.trim().is_empty()
138    {
139        options.web_base_url = web_base.trim_end_matches('/').to_string();
140    }
141    if let Ok(mode) = std::env::var("GSC_GITHUB_AUTH_MODE")
142        && !mode.trim().is_empty()
143    {
144        options.auth_mode = parse_auth_mode(&mode)?;
145    }
146    if let Some(hard_fail) = env_bool("GSC_GITHUB_PRIVATE_SOURCE_HARD_FAIL") {
147        options.private_source_hard_fail = hard_fail;
148    }
149
150    Ok(options)
151}
152
153fn github_web_keys_url(web_base_url: &str, username: &str) -> String {
154    format!("{}/{username}.keys", web_base_url.trim_end_matches('/'))
155}
156
157fn parse_rate_limit(headers: &reqwest::header::HeaderMap) -> (Option<u32>, Option<u64>) {
158    let remaining = headers
159        .get("x-ratelimit-remaining")
160        .and_then(|value| value.to_str().ok())
161        .and_then(|s| s.parse::<u32>().ok());
162    let reset = headers
163        .get("x-ratelimit-reset")
164        .and_then(|value| value.to_str().ok())
165        .and_then(|s| s.parse::<u64>().ok());
166    (remaining, reset)
167}
168
169fn gh_api_lines(path: &str, jq: &str, paginate: bool) -> Result<Vec<String>> {
170    let mut cmd = Command::new("gh");
171    cmd.arg("api");
172    if paginate {
173        cmd.arg("--paginate");
174    }
175    cmd.arg(path).arg("--jq").arg(jq);
176
177    let output = cmd
178        .output()
179        .with_context(|| format!("failed to execute gh api {path}"))?;
180    if !output.status.success() {
181        bail!(
182            "gh api {} failed: {}",
183            path,
184            String::from_utf8_lossy(&output.stderr).trim()
185        );
186    }
187
188    let text = String::from_utf8(output.stdout).context("gh api output is not utf8")?;
189    Ok(text
190        .lines()
191        .map(str::trim)
192        .filter(|line| !line.is_empty())
193        .map(ToString::to_string)
194        .collect())
195}
196
197fn rest_headers(mode: GithubAuthMode, if_none_match: Option<&str>) -> Result<HeaderMap> {
198    let mut headers = HeaderMap::new();
199    headers.insert(USER_AGENT, HeaderValue::from_static("git-sshripped"));
200
201    if let Some(etag) = if_none_match
202        && !etag.trim().is_empty()
203    {
204        let hv =
205            HeaderValue::from_str(etag.trim()).context("invalid If-None-Match header value")?;
206        headers.insert(IF_NONE_MATCH, hv);
207    }
208
209    if mode != GithubAuthMode::Anonymous {
210        if let Ok(token) = std::env::var("GITHUB_TOKEN")
211            && !token.trim().is_empty()
212        {
213            let value = format!("Bearer {}", token.trim());
214            let hv = HeaderValue::from_str(&value).context("invalid GITHUB_TOKEN header value")?;
215            headers.insert(AUTHORIZATION, hv);
216        } else if mode == GithubAuthMode::Token {
217            bail!("github auth mode 'token' requires GITHUB_TOKEN");
218        }
219    }
220
221    Ok(headers)
222}
223
224fn rest_authenticated(mode: GithubAuthMode) -> bool {
225    if mode == GithubAuthMode::Anonymous {
226        return false;
227    }
228    std::env::var("GITHUB_TOKEN")
229        .map(|token| !token.trim().is_empty())
230        .unwrap_or(false)
231}
232
233fn rest_get_with_retry(
234    client: &reqwest::blocking::Client,
235    url: &str,
236    mode: GithubAuthMode,
237    if_none_match: Option<&str>,
238) -> Result<reqwest::blocking::Response> {
239    let mut attempts = 0_u8;
240    loop {
241        attempts = attempts.saturating_add(1);
242        let request = client.get(url).headers(rest_headers(mode, if_none_match)?);
243        let response = request.send();
244
245        match response {
246            Ok(resp)
247                if resp.status().is_server_error()
248                    || resp.status() == StatusCode::TOO_MANY_REQUESTS =>
249            {
250                if attempts >= 3 {
251                    bail!(
252                        "request to {} failed after retries with status {}",
253                        url,
254                        resp.status()
255                    );
256                }
257                std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(attempts)));
258            }
259            Ok(resp) => return Ok(resp),
260            Err(err) => {
261                if attempts >= 3 {
262                    return Err(err)
263                        .with_context(|| format!("request to {url} failed after retries"));
264                }
265                std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(attempts)));
266            }
267        }
268    }
269}
270
271fn parse_next_link(headers: &reqwest::header::HeaderMap) -> Option<String> {
272    let link = headers.get("link")?.to_str().ok()?;
273    for part in link.split(',') {
274        let trimmed = part.trim();
275        if !trimmed.contains("rel=\"next\"") {
276            continue;
277        }
278        let start = trimmed.find('<')?;
279        let end = trimmed.find('>')?;
280        if end > start + 1 {
281            return Some(trimmed[start + 1..end].to_string());
282        }
283    }
284    None
285}
286
287/// Fetch SSH public keys for a GitHub user using environment-based options.
288///
289/// # Errors
290///
291/// Returns an error if the GitHub API request fails or the response is invalid.
292pub fn fetch_github_user_keys(username: &str) -> Result<GithubUserKeys> {
293    fetch_github_user_keys_with_options(username, &fetch_options_from_env()?, None)
294}
295
296/// Paginate through the REST API for a user's keys, returning keys and metadata.
297fn rest_paginate_user_keys(
298    username: &str,
299    options: &GithubFetchOptions,
300    if_none_match: Option<&str>,
301) -> Result<(Vec<String>, GithubFetchMetadata, bool)> {
302    let client = reqwest::blocking::Client::builder()
303        .build()
304        .context("failed to build reqwest client")?;
305    let mut keys = Vec::new();
306    let mut next = Some(format!(
307        "{}/users/{username}/keys?per_page=100",
308        options.api_base_url.trim_end_matches('/'),
309    ));
310    let mut metadata = GithubFetchMetadata::default();
311    let mut applied_etag = false;
312
313    while let Some(url) = next {
314        let current_if_none_match = if applied_etag { None } else { if_none_match };
315        let resp = rest_get_with_retry(&client, &url, options.auth_mode, current_if_none_match)
316            .with_context(|| format!("failed to fetch GitHub user keys for {username}"))?;
317        if resp.status() == StatusCode::NOT_MODIFIED {
318            metadata.not_modified = true;
319            return Ok((Vec::new(), metadata, true));
320        }
321        if options.private_source_hard_fail
322            && (resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN)
323        {
324            bail!(
325                "GitHub user keys request failed for {username} (status {}); provide GITHUB_TOKEN or gh auth",
326                resp.status()
327            );
328        }
329        if !options.private_source_hard_fail
330            && (resp.status() == StatusCode::UNAUTHORIZED
331                || resp.status() == StatusCode::FORBIDDEN
332                || resp.status() == StatusCode::NOT_FOUND)
333        {
334            return Ok((Vec::new(), metadata, false));
335        }
336
337        let headers = resp.headers().clone();
338        if !applied_etag {
339            metadata.etag = headers
340                .get(ETAG)
341                .and_then(|value| value.to_str().ok())
342                .map(ToString::to_string);
343            applied_etag = true;
344        }
345        let (remaining, reset) = parse_rate_limit(&headers);
346        metadata.rate_limit_remaining = remaining;
347        metadata.rate_limit_reset_unix = reset;
348
349        let resp = resp
350            .error_for_status()
351            .with_context(|| format!("GitHub user keys request failed for {username}"))?;
352        let text = resp
353            .text()
354            .context("failed to read GitHub user keys response")?;
355        let parsed: Vec<serde_json::Value> =
356            serde_json::from_str(&text).context("invalid GitHub user keys JSON")?;
357        keys.extend(
358            parsed
359                .iter()
360                .filter_map(|item| item.get("key").and_then(serde_json::Value::as_str))
361                .map(ToString::to_string),
362        );
363        next = parse_next_link(&headers);
364    }
365
366    Ok((keys, metadata, false))
367}
368
369/// Fetch SSH public keys for a GitHub user with explicit options.
370///
371/// # Errors
372///
373/// Returns an error if the GitHub API request fails, the `gh` CLI is required
374/// but not installed, or the response cannot be parsed.
375pub fn fetch_github_user_keys_with_options(
376    username: &str,
377    options: &GithubFetchOptions,
378    if_none_match: Option<&str>,
379) -> Result<GithubUserKeys> {
380    let use_gh = match options.auth_mode {
381        GithubAuthMode::Auto => gh_installed(),
382        GithubAuthMode::Gh => {
383            if !gh_installed() {
384                bail!("github auth mode 'gh' requested but gh is not installed");
385            }
386            true
387        }
388        GithubAuthMode::Token | GithubAuthMode::Anonymous => false,
389    };
390
391    if use_gh {
392        let keys = gh_api_lines(&format!("users/{username}/keys"), ".[].key", true)?;
393        return Ok(GithubUserKeys {
394            username: username.to_string(),
395            url: github_web_keys_url(&options.web_base_url, username),
396            keys,
397            backend: GithubBackend::Gh,
398            authenticated: true,
399            auth_mode: options.auth_mode,
400            metadata: GithubFetchMetadata::default(),
401        });
402    }
403
404    let (keys, metadata, _early_return) =
405        rest_paginate_user_keys(username, options, if_none_match)?;
406
407    Ok(GithubUserKeys {
408        username: username.to_string(),
409        url: github_web_keys_url(&options.web_base_url, username),
410        keys,
411        backend: GithubBackend::Rest,
412        authenticated: rest_authenticated(options.auth_mode),
413        auth_mode: options.auth_mode,
414        metadata,
415    })
416}
417
418/// Fetch members of a GitHub team using environment-based options.
419///
420/// # Errors
421///
422/// Returns an error if the GitHub API request fails or the response is invalid.
423pub fn fetch_github_team_members(
424    org: &str,
425    team: &str,
426) -> Result<(Vec<String>, GithubBackend, bool)> {
427    let fetched =
428        fetch_github_team_members_with_options(org, team, &fetch_options_from_env()?, None)?;
429    Ok((fetched.members, fetched.backend, fetched.authenticated))
430}
431
432/// Fetch members of a GitHub team with explicit options.
433///
434/// # Errors
435///
436/// Returns an error if the GitHub API request fails, the `gh` CLI is required
437/// but not installed, or the response cannot be parsed.
438pub fn fetch_github_team_members_with_options(
439    org: &str,
440    team: &str,
441    options: &GithubFetchOptions,
442    if_none_match: Option<&str>,
443) -> Result<GithubTeamMembers> {
444    let use_gh = match options.auth_mode {
445        GithubAuthMode::Auto => gh_installed(),
446        GithubAuthMode::Gh => {
447            if !gh_installed() {
448                bail!("github auth mode 'gh' requested but gh is not installed");
449            }
450            true
451        }
452        GithubAuthMode::Token | GithubAuthMode::Anonymous => false,
453    };
454
455    if use_gh {
456        let members = gh_api_lines(
457            &format!("orgs/{org}/teams/{team}/members"),
458            ".[].login",
459            true,
460        )?;
461        return Ok(GithubTeamMembers {
462            org: org.to_string(),
463            team: team.to_string(),
464            members,
465            backend: GithubBackend::Gh,
466            authenticated: true,
467            auth_mode: options.auth_mode,
468            metadata: GithubFetchMetadata::default(),
469        });
470    }
471
472    let client = reqwest::blocking::Client::builder()
473        .build()
474        .context("failed to build reqwest client")?;
475    let mut members = Vec::new();
476    let mut next = Some(format!(
477        "{}/orgs/{org}/teams/{team}/members?per_page=100",
478        options.api_base_url.trim_end_matches('/'),
479    ));
480    let authenticated = rest_authenticated(options.auth_mode);
481    let mut metadata = GithubFetchMetadata::default();
482    let mut applied_etag = false;
483
484    while let Some(url) = next {
485        let current_if_none_match = if applied_etag { None } else { if_none_match };
486        let resp = rest_get_with_retry(&client, &url, options.auth_mode, current_if_none_match)
487            .with_context(|| format!("failed to fetch GitHub team members for {org}/{team}"))?;
488
489        if resp.status() == StatusCode::NOT_MODIFIED {
490            metadata.not_modified = true;
491            return Ok(GithubTeamMembers {
492                org: org.to_string(),
493                team: team.to_string(),
494                members: Vec::new(),
495                backend: GithubBackend::Rest,
496                authenticated,
497                auth_mode: options.auth_mode,
498                metadata,
499            });
500        }
501
502        if options.private_source_hard_fail
503            && (resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN)
504        {
505            bail!(
506                "GitHub team members request failed for {org}/{team} (status {}); this requires authenticated access via GITHUB_TOKEN or gh auth",
507                resp.status()
508            );
509        }
510
511        let headers = resp.headers().clone();
512        if !applied_etag {
513            metadata.etag = headers
514                .get(ETAG)
515                .and_then(|value| value.to_str().ok())
516                .map(ToString::to_string);
517            applied_etag = true;
518        }
519        let (remaining, reset) = parse_rate_limit(&headers);
520        metadata.rate_limit_remaining = remaining;
521        metadata.rate_limit_reset_unix = reset;
522        let text = resp
523            .text()
524            .context("failed to read GitHub team members response")?;
525        let parsed: Vec<serde_json::Value> =
526            serde_json::from_str(&text).context("invalid GitHub team members JSON")?;
527        members.extend(
528            parsed
529                .iter()
530                .filter_map(|item| item.get("login").and_then(serde_json::Value::as_str))
531                .map(ToString::to_string),
532        );
533        next = parse_next_link(&headers);
534    }
535
536    Ok(GithubTeamMembers {
537        org: org.to_string(),
538        team: team.to_string(),
539        members,
540        backend: GithubBackend::Rest,
541        authenticated,
542        auth_mode: options.auth_mode,
543        metadata,
544    })
545}
546
547#[must_use]
548pub fn recipient_store_dir(repo_root: &Path) -> PathBuf {
549    repo_root.join(".git-sshripped").join("recipients")
550}
551
552#[must_use]
553pub fn wrapped_store_dir(repo_root: &Path) -> PathBuf {
554    repo_root.join(".git-sshripped").join("wrapped")
555}
556
557/// List all registered recipients for a repository.
558///
559/// # Errors
560///
561/// Returns an error if the recipient directory cannot be read or a recipient
562/// file cannot be parsed.
563pub fn list_recipients(repo_root: &Path) -> Result<Vec<RecipientKey>> {
564    let dir = recipient_store_dir(repo_root);
565    if !dir.exists() {
566        return Ok(Vec::new());
567    }
568
569    let mut recipients = Vec::new();
570    for entry in fs::read_dir(&dir)
571        .with_context(|| format!("failed to read recipient dir {}", dir.display()))?
572    {
573        let entry = entry.with_context(|| format!("failed to read entry in {}", dir.display()))?;
574        if !entry
575            .file_type()
576            .with_context(|| format!("failed to read entry type for {}", entry.path().display()))?
577            .is_file()
578        {
579            continue;
580        }
581        let text = fs::read_to_string(entry.path())
582            .with_context(|| format!("failed to read recipient file {}", entry.path().display()))?;
583        let recipient: RecipientKey = toml::from_str(&text).with_context(|| {
584            format!("failed to parse recipient file {}", entry.path().display())
585        })?;
586        recipients.push(recipient);
587    }
588
589    recipients.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
590    Ok(recipients)
591}
592
593/// Add a recipient from an SSH public key line.
594///
595/// # Errors
596///
597/// Returns an error if the key line is empty, the key type is unsupported,
598/// or the recipient file cannot be written.
599pub fn add_recipient_from_public_key(
600    repo_root: &Path,
601    public_key_line: &str,
602    source: RecipientSource,
603) -> Result<RecipientKey> {
604    let trimmed = public_key_line.trim();
605    if trimmed.is_empty() {
606        bail!("empty SSH public key line");
607    }
608
609    let mut parts = trimmed.split_whitespace();
610    let key_type = parts
611        .next()
612        .context("SSH public key is missing key type")?
613        .to_string();
614    let key_body = parts
615        .next()
616        .context("SSH public key is missing key material")?;
617
618    if !SUPPORTED_KEY_TYPES
619        .iter()
620        .any(|supported| *supported == key_type)
621    {
622        bail!(
623            "unsupported SSH key type '{key_type}'; supported types: {}",
624            SUPPORTED_KEY_TYPES.join(", ")
625        );
626    }
627
628    let fingerprint = fingerprint_for_public_key(&key_type, key_body);
629
630    let recipient = RecipientKey {
631        fingerprint: fingerprint.clone(),
632        key_type,
633        public_key_line: trimmed.to_string(),
634        source,
635    };
636
637    let dir = recipient_store_dir(repo_root);
638    fs::create_dir_all(&dir)
639        .with_context(|| format!("failed to create recipient dir {}", dir.display()))?;
640    let file = dir.join(format!("{fingerprint}.toml"));
641    let content = toml::to_string_pretty(&recipient)
642        .with_context(|| format!("failed to serialize recipient {}", recipient.fingerprint))?;
643    fs::write(&file, content)
644        .with_context(|| format!("failed to write recipient file {}", file.display()))?;
645
646    Ok(recipient)
647}
648
649/// Add recipients from a GitHub keys URL.
650///
651/// # Errors
652///
653/// Returns an error if the URL cannot be fetched or a key cannot be added.
654pub fn add_recipients_from_github_keys(repo_root: &Path, url: &str) -> Result<Vec<RecipientKey>> {
655    add_recipients_from_github_source(repo_root, url, None)
656}
657
658/// Add recipients from a GitHub username using environment-based options.
659///
660/// # Errors
661///
662/// Returns an error if the user's keys cannot be fetched or a key cannot be
663/// added.
664pub fn add_recipients_from_github_username(
665    repo_root: &Path,
666    username: &str,
667) -> Result<Vec<RecipientKey>> {
668    add_recipients_from_github_username_with_options(
669        repo_root,
670        username,
671        &fetch_options_from_env()?,
672    )
673}
674
675/// Add recipients from a GitHub username with explicit options.
676///
677/// # Errors
678///
679/// Returns an error if the user's keys cannot be fetched or a key cannot be
680/// added.
681pub fn add_recipients_from_github_username_with_options(
682    repo_root: &Path,
683    username: &str,
684    options: &GithubFetchOptions,
685) -> Result<Vec<RecipientKey>> {
686    let fetched = fetch_github_user_keys_with_options(username, options, None)?;
687    let mut added = Vec::new();
688    for line in fetched.keys.iter().filter(|line| !line.trim().is_empty()) {
689        let recipient = add_recipient_from_public_key(
690            repo_root,
691            line,
692            RecipientSource::GithubKeys {
693                url: fetched.url.clone(),
694                username: Some(username.to_string()),
695            },
696        )
697        .with_context(|| format!("failed to add recipient from key line '{line}'"))?;
698        added.push(recipient);
699    }
700    Ok(added)
701}
702
703/// Add recipients from a GitHub source URL or username.
704///
705/// # Errors
706///
707/// Returns an error if the source cannot be fetched or a key cannot be added.
708pub fn add_recipients_from_github_source(
709    repo_root: &Path,
710    url: &str,
711    username: Option<&str>,
712) -> Result<Vec<RecipientKey>> {
713    add_recipients_from_github_source_with_options(
714        repo_root,
715        url,
716        username,
717        &fetch_options_from_env()?,
718    )
719}
720
721/// Add recipients from a GitHub source with explicit options.
722///
723/// # Errors
724///
725/// Returns an error if the source cannot be fetched or a key cannot be added.
726pub fn add_recipients_from_github_source_with_options(
727    repo_root: &Path,
728    url: &str,
729    username: Option<&str>,
730    options: &GithubFetchOptions,
731) -> Result<Vec<RecipientKey>> {
732    if let Some(user) = username {
733        return add_recipients_from_github_username_with_options(repo_root, user, options);
734    }
735
736    let text = reqwest::blocking::Client::builder()
737        .build()
738        .context("failed to build reqwest client")?
739        .get(url)
740        .headers(rest_headers(options.auth_mode, None)?)
741        .send()
742        .with_context(|| format!("failed to GET {url}"))?
743        .error_for_status()
744        .with_context(|| format!("GitHub keys request returned error for {url}"))?
745        .text()
746        .context("failed to read GitHub keys body")?;
747
748    let mut added = Vec::new();
749    for line in text.lines().filter(|line| !line.trim().is_empty()) {
750        let recipient = add_recipient_from_public_key(
751            repo_root,
752            line,
753            RecipientSource::GithubKeys {
754                url: url.to_string(),
755                username: username.map(ToString::to_string),
756            },
757        )
758        .with_context(|| format!("failed to add recipient from key line '{line}'"))?;
759        added.push(recipient);
760    }
761
762    Ok(added)
763}
764
765/// Remove recipients by their fingerprints.
766///
767/// # Errors
768///
769/// Returns an error if a recipient or wrapped key file cannot be removed.
770pub fn remove_recipients_by_fingerprints(
771    repo_root: &Path,
772    fingerprints: &[String],
773) -> Result<usize> {
774    let mut removed = 0;
775    for fingerprint in fingerprints {
776        if remove_recipient_by_fingerprint(repo_root, fingerprint)? {
777            removed += 1;
778        }
779    }
780    Ok(removed)
781}
782
783/// Wrap the repo key for a single recipient using age encryption.
784///
785/// # Errors
786///
787/// Returns an error if the public key is invalid, age encryption fails, or
788/// the wrapped key file cannot be written.
789pub fn wrap_repo_key_for_recipient(
790    repo_root: &Path,
791    recipient: &RecipientKey,
792    repo_key: &[u8],
793) -> Result<PathBuf> {
794    let ssh_recipient = SshRecipient::from_str(&recipient.public_key_line).map_err(|err| {
795        anyhow::anyhow!(
796            "invalid ssh public key for {}: {:?}",
797            recipient.fingerprint,
798            err
799        )
800    })?;
801
802    let encryptor = Encryptor::with_recipients(iter::once(&ssh_recipient as _))
803        .context("failed to initialize age encryptor")?;
804
805    let mut wrapped = Vec::new();
806    {
807        let mut writer = encryptor
808            .wrap_output(&mut wrapped)
809            .context("failed to start age wrapping")?;
810        writer
811            .write_all(repo_key)
812            .context("failed to write repo key to wrapper")?;
813        writer.finish().context("failed to finish age wrapping")?;
814    }
815
816    let dir = wrapped_store_dir(repo_root);
817    fs::create_dir_all(&dir)
818        .with_context(|| format!("failed to create wrapped dir {}", dir.display()))?;
819    let wrapped_file = dir.join(format!("{}.age", recipient.fingerprint));
820    fs::write(&wrapped_file, wrapped)
821        .with_context(|| format!("failed to write wrapped key {}", wrapped_file.display()))?;
822    Ok(wrapped_file)
823}
824
825/// Wrap the repo key for all registered recipients.
826///
827/// # Errors
828///
829/// Returns an error if no recipients are configured, or if wrapping fails
830/// for any recipient.
831pub fn wrap_repo_key_for_all_recipients(repo_root: &Path, repo_key: &[u8]) -> Result<Vec<PathBuf>> {
832    let recipients = list_recipients(repo_root)?;
833    if recipients.is_empty() {
834        bail!("no recipients configured; add at least one recipient first");
835    }
836
837    let mut wrapped_files = Vec::new();
838    for recipient in recipients {
839        let wrapped_file = wrap_repo_key_for_recipient(repo_root, &recipient, repo_key)?;
840        wrapped_files.push(wrapped_file);
841    }
842    Ok(wrapped_files)
843}
844
845/// Remove a single recipient and its wrapped key by fingerprint.
846///
847/// # Errors
848///
849/// Returns an error if a file exists but cannot be removed.
850pub fn remove_recipient_by_fingerprint(repo_root: &Path, fingerprint: &str) -> Result<bool> {
851    let recipient_file = recipient_store_dir(repo_root).join(format!("{fingerprint}.toml"));
852    let wrapped_file = wrapped_store_dir(repo_root).join(format!("{fingerprint}.age"));
853
854    let removed_recipient = if recipient_file.exists() {
855        fs::remove_file(&recipient_file).with_context(|| {
856            format!(
857                "failed to remove recipient file {}",
858                recipient_file.display()
859            )
860        })?;
861        true
862    } else {
863        false
864    };
865    let removed_wrapped = if wrapped_file.exists() {
866        fs::remove_file(&wrapped_file)
867            .with_context(|| format!("failed to remove wrapped file {}", wrapped_file.display()))?;
868        true
869    } else {
870        false
871    };
872
873    Ok(removed_recipient || removed_wrapped)
874}