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
287pub fn fetch_github_user_keys(username: &str) -> Result<GithubUserKeys> {
293 fetch_github_user_keys_with_options(username, &fetch_options_from_env()?, None)
294}
295
296fn 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
369pub 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
418pub 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
432pub 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
557pub 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
593pub 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
649pub 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
658pub 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
675pub 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
703pub 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
721pub 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
765pub 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
783pub 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
825pub 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
845pub 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}