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::collections::BTreeMap;
6use std::fs;
7use std::io::{BufReader, BufWriter, Read, Write};
8use std::path::PathBuf;
9
10use anyhow::{Context, Result};
11use base64::Engine;
12use clap::{Parser, Subcommand, ValueEnum};
13use git_sshripped_cli_models::InitOptions;
14use git_sshripped_encryption_models::{ENCRYPTED_MAGIC, EncryptionAlgorithm};
15use git_sshripped_filter::{clean, diff, smudge};
16use git_sshripped_recipient::{
17 GithubAuthMode, GithubFetchOptions, add_recipient_from_public_key,
18 add_recipients_from_github_source_with_options,
19 add_recipients_from_github_username_with_options, fetch_github_team_members_with_options,
20 fetch_github_user_keys_with_options, list_recipients, remove_recipient_by_fingerprint,
21 remove_recipients_by_fingerprints, wrap_repo_key_for_all_recipients,
22 wrap_repo_key_for_recipient, wrapped_store_dir,
23};
24use git_sshripped_recipient_models::{RecipientKey, RecipientSource};
25use git_sshripped_repository::{
26 install_git_filters, install_gitattributes, read_github_sources, read_local_config,
27 read_manifest, write_github_sources, write_local_config, write_manifest,
28};
29use git_sshripped_repository_models::{
30 GithubSourceRegistry, GithubTeamSource, GithubUserSource, RepositoryLocalConfig,
31 RepositoryManifest,
32};
33use git_sshripped_ssh_agent::{
34 agent_unwrap_repo_key, agent_wrap_repo_key, list_agent_ed25519_keys,
35};
36use git_sshripped_ssh_identity::{
37 default_private_key_candidates, detect_identity, discover_ssh_key_files,
38 private_keys_matching_agent, unwrap_repo_key_from_wrapped_files,
39 unwrap_repo_key_with_agent_helper, well_known_public_key_paths,
40};
41use git_sshripped_ssh_identity_models::IdentityDescriptor;
42use git_sshripped_worktree::{
43 clear_unlock_session, git_common_dir, git_toplevel, is_linked_worktree, read_unlock_session,
44 write_unlock_session,
45};
46use git_sshripped_worktree_models::UnlockSession;
47use rand::RngCore;
48use sha2::{Digest, Sha256};
49
50#[derive(Debug, Clone, Copy, ValueEnum)]
51enum CliAlgorithm {
52 AesSiv,
53}
54
55impl From<CliAlgorithm> for EncryptionAlgorithm {
56 fn from(value: CliAlgorithm) -> Self {
57 match value {
58 CliAlgorithm::AesSiv => Self::AesSivV1,
59 }
60 }
61}
62
63#[derive(Debug, Parser)]
64#[command(name = "git-sshripped", version)]
65#[command(about = "Git-transparent encryption using SSH-oriented workflows")]
66struct Cli {
67 #[command(subcommand)]
68 command: Command,
69}
70
71#[derive(Debug, Subcommand)]
72enum ConfigCommand {
73 SetAgentHelper { path: String },
74 SetGithubApiBase { url: String },
75 SetGithubWebBase { url: String },
76 SetGithubAuthMode { mode: String },
77 SetGithubPrivateSourceHardFail { enabled: String },
78 Show,
79}
80
81#[derive(Debug, Subcommand)]
82enum PolicyCommand {
83 Show {
84 #[arg(long)]
85 json: bool,
86 },
87 Verify {
88 #[arg(long)]
89 json: bool,
90 },
91 Set {
92 #[arg(long)]
93 min_recipients: Option<usize>,
94 #[arg(long = "allow-key-type")]
95 allow_key_types: Vec<String>,
96 #[arg(long)]
97 require_doctor_clean_for_rotate: Option<bool>,
98 #[arg(long)]
99 require_verify_strict_clean_for_rotate_revoke: Option<bool>,
100 #[arg(long)]
101 max_source_staleness_hours: Option<u64>,
102 },
103}
104
105#[derive(Debug, Subcommand)]
106enum Command {
107 Init {
108 #[arg(long = "pattern")]
109 patterns: Vec<String>,
110 #[arg(long, value_enum, default_value_t = CliAlgorithm::AesSiv)]
111 algorithm: CliAlgorithm,
112 #[arg(long = "recipient-key")]
113 recipient_keys: Vec<String>,
114 #[arg(long = "github-keys-url")]
115 github_keys_urls: Vec<String>,
116 #[arg(long)]
117 strict: bool,
118 },
119 Unlock {
120 #[arg(long)]
121 key_hex: Option<String>,
122 #[arg(long = "identity")]
123 identities: Vec<String>,
124 #[arg(long = "github-user")]
125 github_user: Option<String>,
126 #[arg(long)]
127 prefer_agent: bool,
128 #[arg(long)]
129 no_agent: bool,
130 #[arg(long)]
133 soft: bool,
134 },
135 Lock {
136 #[arg(long)]
137 force: bool,
138 #[arg(long)]
139 no_scrub: bool,
140 },
141 Status {
142 #[arg(long)]
143 json: bool,
144 },
145 Doctor {
146 #[arg(long)]
147 json: bool,
148 },
149 Rewrap,
150 RotateKey {
151 #[arg(long = "auto-reencrypt")]
152 auto_reencrypt: bool,
153 },
154 Reencrypt,
155 AddUser {
156 #[arg(long)]
157 key: Option<String>,
158 #[arg(long)]
159 github_keys_url: Option<String>,
160 #[arg(long)]
161 github_user: Option<String>,
162 },
163 ListUsers {
164 #[arg(long)]
165 verbose: bool,
166 },
167 AddGithubUser {
168 #[arg(long)]
169 username: String,
170 #[arg(long)]
171 auto_wrap: bool,
172 #[arg(long, conflicts_with = "auto_wrap")]
173 no_auto_wrap: bool,
174 #[arg(long, conflicts_with_all = ["key", "key_file"])]
176 all: bool,
177 #[arg(long, conflicts_with_all = ["all", "key_file"])]
179 key: Option<String>,
180 #[arg(long, conflicts_with_all = ["all", "key"])]
182 key_file: Option<String>,
183 },
184 ListGithubUsers {
185 #[arg(long)]
186 verbose: bool,
187 },
188 RemoveGithubUser {
189 #[arg(long)]
190 username: String,
191 #[arg(long)]
192 force: bool,
193 },
194 RefreshGithubKeys {
195 #[arg(long)]
196 username: Option<String>,
197 #[arg(long)]
198 dry_run: bool,
199 #[arg(long)]
200 fail_on_drift: bool,
201 #[arg(long)]
202 json: bool,
203 },
204 AddGithubTeam {
205 #[arg(long)]
206 org: String,
207 #[arg(long)]
208 team: String,
209 #[arg(long)]
210 auto_wrap: bool,
211 #[arg(long, conflicts_with = "auto_wrap")]
212 no_auto_wrap: bool,
213 },
214 ListGithubTeams,
215 RemoveGithubTeam {
216 #[arg(long)]
217 org: String,
218 #[arg(long)]
219 team: String,
220 },
221 RefreshGithubTeams {
222 #[arg(long)]
223 org: Option<String>,
224 #[arg(long)]
225 team: Option<String>,
226 #[arg(long)]
227 dry_run: bool,
228 #[arg(long)]
229 fail_on_drift: bool,
230 #[arg(long)]
231 json: bool,
232 },
233 AccessAudit {
234 #[arg(long = "identity")]
235 identities: Vec<String>,
236 #[arg(long)]
237 json: bool,
238 },
239 RemoveUser {
240 #[arg(long)]
241 fingerprint: String,
242 #[arg(long)]
243 force: bool,
244 },
245 RevokeUser {
246 #[arg(long)]
247 fingerprint: Option<String>,
248 #[arg(long = "github-user")]
249 github_user: Option<String>,
250 #[arg(long)]
251 org: Option<String>,
252 #[arg(long)]
253 team: Option<String>,
254 #[arg(long)]
255 all_keys_for_user: bool,
256 #[arg(long)]
257 force: bool,
258 #[arg(long = "auto-reencrypt")]
259 auto_reencrypt: bool,
260 #[arg(long)]
261 json: bool,
262 },
263 Install,
264 MigrateFromGitCrypt {
265 #[arg(long)]
266 dry_run: bool,
267 #[arg(long)]
268 reencrypt: bool,
269 #[arg(long)]
270 verify: bool,
271 #[arg(long)]
272 json: bool,
273 #[arg(long = "write-report")]
274 write_report: Option<String>,
275 },
276 ExportRepoKey {
277 #[arg(long)]
278 out: String,
279 },
280 ImportRepoKey {
281 #[arg(long)]
282 input: String,
283 },
284 Verify {
285 #[arg(long)]
286 strict: bool,
287 #[arg(long)]
288 json: bool,
289 },
290 Clean {
291 #[arg(long)]
292 path: String,
293 },
294 Smudge {
295 #[arg(long)]
296 path: String,
297 },
298 Diff {
299 #[arg(long)]
300 path: String,
301 file: Option<String>,
303 },
304 FilterProcess,
305 Policy {
306 #[command(subcommand)]
307 command: PolicyCommand,
308 },
309 Config {
310 #[command(subcommand)]
311 command: ConfigCommand,
312 },
313}
314
315#[allow(clippy::struct_excessive_bools)]
316struct RevokeUserOptions {
317 fingerprint: Option<String>,
318 github_user: Option<String>,
319 org: Option<String>,
320 team: Option<String>,
321 all_keys_for_user: bool,
322 force: bool,
323 auto_reencrypt: bool,
324 json: bool,
325}
326
327#[allow(clippy::struct_excessive_bools)]
328struct MigrateOptions {
329 dry_run: bool,
330 reencrypt: bool,
331 verify: bool,
332 json: bool,
333 write_report: Option<String>,
334}
335
336pub fn run() -> Result<()> {
343 let cli = Cli::parse();
344 dispatch_command(cli.command)
345}
346
347fn dispatch_command(command: Command) -> Result<()> {
348 match command {
349 Command::Init {
350 patterns,
351 algorithm,
352 recipient_keys,
353 github_keys_urls,
354 strict,
355 } => cmd_init(
356 &patterns,
357 algorithm,
358 recipient_keys,
359 github_keys_urls,
360 strict,
361 ),
362 Command::Unlock {
363 key_hex,
364 identities,
365 github_user,
366 prefer_agent,
367 no_agent,
368 soft,
369 } => {
370 let result = cmd_unlock(key_hex, identities, github_user, prefer_agent, no_agent);
371 if soft && let Err(e) = result {
372 eprintln!(
373 "warning: git-sshripped unlock failed (--soft mode, continuing anyway): {e:#}"
374 );
375 return Ok(());
376 }
377 result
378 }
379 Command::Lock { force, no_scrub } => cmd_lock(force, no_scrub),
380 Command::Status { json } => cmd_status(json),
381 Command::Doctor { json } => cmd_doctor(json),
382 Command::Rewrap => cmd_rewrap(),
383 Command::RotateKey { auto_reencrypt } => cmd_rotate_key(auto_reencrypt),
384 Command::Reencrypt => cmd_reencrypt(),
385 Command::AddUser {
386 key,
387 github_keys_url,
388 github_user,
389 } => cmd_add_user(key, github_keys_url, github_user),
390 Command::ListUsers { verbose } => cmd_list_users(verbose),
391 Command::RemoveUser { fingerprint, force } => cmd_remove_user(&fingerprint, force),
392 Command::RevokeUser {
393 fingerprint,
394 github_user,
395 org,
396 team,
397 all_keys_for_user,
398 force,
399 auto_reencrypt,
400 json,
401 } => cmd_revoke_user(&RevokeUserOptions {
402 fingerprint,
403 github_user,
404 org,
405 team,
406 all_keys_for_user,
407 force,
408 auto_reencrypt,
409 json,
410 }),
411 Command::Install => cmd_install(),
412 Command::ExportRepoKey { out } => cmd_export_repo_key(&out),
413 Command::ImportRepoKey { input } => cmd_import_repo_key(&input),
414 Command::Verify { strict, json } => cmd_verify(strict, json),
415 Command::Clean { path } => cmd_clean(&path),
416 Command::Smudge { path } => cmd_smudge(&path),
417 Command::Diff { path, file } => cmd_diff(&path, file.as_deref()),
418 Command::FilterProcess => cmd_filter_process(),
419 Command::Policy { command } => cmd_policy(command),
420 Command::Config { command } => cmd_config(command),
421 Command::AccessAudit { identities, json } => cmd_access_audit(identities, json),
422 cmd => dispatch_github_command(cmd),
423 }
424}
425
426fn dispatch_github_command(command: Command) -> Result<()> {
427 match command {
428 Command::AddGithubUser {
429 username,
430 auto_wrap,
431 no_auto_wrap,
432 all,
433 key,
434 key_file,
435 } => cmd_add_github_user(&username, auto_wrap || !no_auto_wrap, all, key, key_file),
436 Command::ListGithubUsers { verbose } => cmd_list_github_users(verbose),
437 Command::RemoveGithubUser { username, force } => cmd_remove_github_user(&username, force),
438 Command::RefreshGithubKeys {
439 username,
440 dry_run,
441 fail_on_drift,
442 json,
443 } => cmd_refresh_github_keys(username.as_deref(), dry_run, fail_on_drift, json),
444 Command::AddGithubTeam {
445 org,
446 team,
447 auto_wrap,
448 no_auto_wrap,
449 } => cmd_add_github_team(&org, &team, auto_wrap || !no_auto_wrap),
450 Command::ListGithubTeams => cmd_list_github_teams(),
451 Command::RemoveGithubTeam { org, team } => cmd_remove_github_team(&org, &team),
452 Command::RefreshGithubTeams {
453 org,
454 team,
455 dry_run,
456 fail_on_drift,
457 json,
458 } => cmd_refresh_github_teams(
459 org.as_deref(),
460 team.as_deref(),
461 dry_run,
462 fail_on_drift,
463 json,
464 ),
465 Command::MigrateFromGitCrypt {
466 dry_run,
467 reencrypt,
468 verify,
469 json,
470 write_report,
471 } => cmd_migrate_from_git_crypt(&MigrateOptions {
472 dry_run,
473 reencrypt,
474 verify,
475 json,
476 write_report,
477 }),
478 _ => unreachable!(),
479 }
480}
481
482fn current_repo_root() -> Result<PathBuf> {
483 let cwd = std::env::current_dir().context("failed to read current dir")?;
484 resolve_repo_root_for_command(&cwd)
485}
486
487fn current_common_dir() -> Result<PathBuf> {
488 let cwd = std::env::current_dir().context("failed to read current dir")?;
489 resolve_common_dir_for_command(&cwd)
490}
491
492fn current_bin_path(repo_root: Option<&std::path::Path>) -> String {
500 if let Ok(val) = std::env::var("GIT_SSHRIPPED_BIN")
502 && !val.is_empty()
503 {
504 return val;
505 }
506
507 if let Some(root) = repo_root
509 && let Ok(Some(val)) = git_effective_config(root, "git-sshripped.binPath")
510 && !val.is_empty()
511 {
512 return val;
513 }
514
515 std::env::current_exe()
517 .ok()
518 .and_then(|p| p.to_str().map(ToString::to_string))
519 .unwrap_or_else(|| "git-sshripped".to_string())
520}
521
522fn current_is_linked_worktree(repo_root: &std::path::Path) -> bool {
523 is_linked_worktree(repo_root).unwrap_or(false)
524}
525
526fn wrapped_key_files(repo_root: &std::path::Path) -> Result<Vec<PathBuf>> {
527 let dir = wrapped_store_dir(repo_root);
528 if !dir.exists() {
529 return Ok(Vec::new());
530 }
531
532 let mut files = Vec::new();
533 for entry in fs::read_dir(&dir)
534 .with_context(|| format!("failed to read wrapped dir {}", dir.display()))?
535 {
536 let entry = entry.with_context(|| format!("failed reading entry in {}", dir.display()))?;
537 if entry
538 .file_type()
539 .with_context(|| format!("failed to read file type for {}", entry.path().display()))?
540 .is_file()
541 {
542 files.push(entry.path());
543 }
544 }
545 files.sort();
546 Ok(files)
547}
548
549fn snapshot_wrapped_files(repo_root: &std::path::Path) -> Result<BTreeMap<String, Vec<u8>>> {
550 let dir = wrapped_store_dir(repo_root);
551 let mut snapshot = BTreeMap::new();
552 if !dir.exists() {
553 return Ok(snapshot);
554 }
555
556 for file in wrapped_key_files(repo_root)? {
557 let Some(name) = file.file_name().and_then(|name| name.to_str()) else {
558 continue;
559 };
560 let bytes = fs::read(&file)
561 .with_context(|| format!("failed to read wrapped file {}", file.display()))?;
562 snapshot.insert(name.to_string(), bytes);
563 }
564
565 Ok(snapshot)
566}
567
568fn restore_wrapped_files(
569 repo_root: &std::path::Path,
570 snapshot: &BTreeMap<String, Vec<u8>>,
571) -> Result<()> {
572 let dir = wrapped_store_dir(repo_root);
573 fs::create_dir_all(&dir)
574 .with_context(|| format!("failed to create wrapped directory {}", dir.display()))?;
575
576 for file in wrapped_key_files(repo_root)? {
577 fs::remove_file(&file)
578 .with_context(|| format!("failed to remove wrapped file {}", file.display()))?;
579 }
580
581 for (name, bytes) in snapshot {
582 let path = dir.join(name);
583 fs::write(&path, bytes)
584 .with_context(|| format!("failed to restore wrapped file {}", path.display()))?;
585 }
586
587 Ok(())
588}
589
590fn is_executable(path: &std::path::Path) -> bool {
591 if !path.is_file() {
592 return false;
593 }
594 #[cfg(unix)]
595 {
596 use std::os::unix::fs::PermissionsExt;
597 if let Ok(meta) = fs::metadata(path) {
598 return meta.permissions().mode() & 0o111 != 0;
599 }
600 false
601 }
602 #[cfg(not(unix))]
603 {
604 true
605 }
606}
607
608fn find_helper_in_path(name: &str) -> Option<PathBuf> {
609 let path_var = std::env::var_os("PATH")?;
610 for dir in std::env::split_paths(&path_var) {
611 let candidate = dir.join(name);
612 if is_executable(&candidate) {
613 return Some(candidate);
614 }
615 }
616 None
617}
618
619fn resolve_agent_helper(repo_root: &std::path::Path) -> Result<Option<(PathBuf, String)>> {
620 if let Ok(path) = std::env::var("GSC_SSH_AGENT_HELPER") {
621 let candidate = PathBuf::from(path);
622 if is_executable(&candidate) {
623 return Ok(Some((candidate, "env".to_string())));
624 }
625 }
626
627 if let Some(cfg_value) = git_effective_config(repo_root, "git-sshripped.agentHelper")? {
628 let candidate = PathBuf::from(cfg_value);
629 if is_executable(&candidate) {
630 return Ok(Some((candidate, "git-config".to_string())));
631 }
632 }
633
634 let local_cfg = read_local_config(repo_root)?;
635 if let Some(helper) = local_cfg.agent_helper {
636 let candidate = PathBuf::from(helper);
637 if is_executable(&candidate) {
638 return Ok(Some((candidate, "repo-config".to_string())));
639 }
640 }
641
642 for name in [
643 "git-sshripped-agent-helper",
644 "age-plugin-ssh-agent",
645 "age-plugin-ssh",
646 ] {
647 if let Some(path) = find_helper_in_path(name) {
648 return Ok(Some((path, "path".to_string())));
649 }
650 }
651
652 Ok(None)
653}
654
655fn now_unix() -> u64 {
656 std::time::SystemTime::now()
657 .duration_since(std::time::UNIX_EPOCH)
658 .map_or(0, |d| d.as_secs())
659}
660
661fn classify_github_refresh_error(err: &anyhow::Error) -> &'static str {
662 let text = format!("{err:#}").to_ascii_lowercase();
663 if text.contains("status 401") || text.contains("unauthorized") {
664 return "auth_missing";
665 }
666 if text.contains("requires github_token") {
667 return "auth_missing";
668 }
669 if text.contains("status 403") || text.contains("forbidden") {
670 return "permission_denied";
671 }
672 if text.contains("status 404") || text.contains("not found") {
673 return "not_found";
674 }
675 if text.contains("status 429") || text.contains("rate limit") {
676 return "rate_limited";
677 }
678 if text.contains("timed out") || text.contains("connection") || text.contains("dns") {
679 return "backend_unavailable";
680 }
681 "unknown"
682}
683
684fn enforce_verify_clean_for_sensitive_actions(
685 repo_root: &std::path::Path,
686 manifest: &RepositoryManifest,
687 action: &str,
688) -> Result<()> {
689 if !manifest.require_verify_strict_clean_for_rotate_revoke {
690 return Ok(());
691 }
692 let failures = verify_failures(repo_root)?;
693 if failures.is_empty() {
694 return Ok(());
695 }
696 anyhow::bail!(
697 "{action} blocked by manifest policy require_verify_strict_clean_for_rotate_revoke=true; run `git-sshripped verify --strict` and fix: {}",
698 failures.join("; ")
699 )
700}
701
702fn parse_github_auth_mode(raw: &str) -> Result<GithubAuthMode> {
703 match raw.trim().to_ascii_lowercase().as_str() {
704 "auto" => Ok(GithubAuthMode::Auto),
705 "gh" => Ok(GithubAuthMode::Gh),
706 "token" => Ok(GithubAuthMode::Token),
707 "anonymous" => Ok(GithubAuthMode::Anonymous),
708 other => anyhow::bail!(
709 "unsupported github auth mode '{other}'; expected auto|gh|token|anonymous"
710 ),
711 }
712}
713
714fn normalize_https_url(url: &str, label: &str) -> Result<String> {
715 let trimmed = url.trim();
716 if trimmed.is_empty() {
717 anyhow::bail!("{label} cannot be empty");
718 }
719
720 let parsed =
721 reqwest::Url::parse(trimmed).with_context(|| format!("{label} must be an absolute URL"))?;
722 if parsed.scheme() != "https" {
723 anyhow::bail!("{label} must use https");
724 }
725 if parsed.host_str().is_none() {
726 anyhow::bail!("{label} must include a host");
727 }
728 if !parsed.username().is_empty() || parsed.password().is_some() {
729 anyhow::bail!("{label} must not include user info");
730 }
731
732 Ok(parsed.to_string())
733}
734
735fn normalize_https_base_url(url: &str, label: &str) -> Result<String> {
736 Ok(normalize_https_url(url, label)?
737 .trim_end_matches('/')
738 .to_string())
739}
740
741fn github_fetch_options(repo_root: &std::path::Path) -> Result<GithubFetchOptions> {
742 let cfg = read_local_config(repo_root)?;
743 let mut options = GithubFetchOptions::default();
744 if let Some(api_base) = cfg.github_api_base {
745 options.api_base_url = normalize_https_base_url(&api_base, "github_api_base")?;
746 }
747 if let Some(web_base) = cfg.github_web_base {
748 options.web_base_url = normalize_https_base_url(&web_base, "github_web_base")?;
749 }
750 if let Some(mode) = cfg.github_auth_mode {
751 options.auth_mode = parse_github_auth_mode(&mode)?;
752 }
753 if let Some(hard_fail) = cfg.github_private_source_hard_fail {
754 options.private_source_hard_fail = hard_fail;
755 }
756 Ok(options)
757}
758
759const fn github_auth_mode_label(mode: GithubAuthMode) -> &'static str {
760 match mode {
761 GithubAuthMode::Auto => "auto",
762 GithubAuthMode::Gh => "gh",
763 GithubAuthMode::Token => "token",
764 GithubAuthMode::Anonymous => "anonymous",
765 }
766}
767
768fn repo_key_id_from_bytes(key: &[u8]) -> String {
769 let mut hasher = Sha256::new();
770 hasher.update(key);
771 hex::encode(hasher.finalize())
772}
773
774fn allowed_key_types_set(manifest: &RepositoryManifest) -> std::collections::HashSet<&str> {
775 manifest
776 .allowed_key_types
777 .iter()
778 .map(String::as_str)
779 .collect()
780}
781
782fn enforce_allowed_key_types_for_added_recipients(
783 repo_root: &std::path::Path,
784 manifest: &RepositoryManifest,
785 existing_fingerprints: &std::collections::HashSet<String>,
786 added: &[RecipientKey],
787 action: &str,
788) -> Result<()> {
789 let allowed = allowed_key_types_set(manifest);
790 let mut invalid_existing = Vec::new();
791 let mut invalid_new = Vec::new();
792
793 for recipient in added {
794 if allowed.contains(recipient.key_type.as_str()) {
795 continue;
796 }
797 if existing_fingerprints.contains(&recipient.fingerprint) {
798 invalid_existing.push(format!(
799 "{} ({})",
800 recipient.fingerprint, recipient.key_type
801 ));
802 } else {
803 invalid_new.push(recipient.fingerprint.clone());
804 }
805 }
806
807 if !invalid_new.is_empty() {
808 let _ = remove_recipients_by_fingerprints(repo_root, &invalid_new)?;
809 }
810
811 if invalid_existing.is_empty() && invalid_new.is_empty() {
812 return Ok(());
813 }
814
815 let mut invalid_all = invalid_existing;
816 invalid_all.extend(invalid_new.into_iter().map(|fingerprint| {
817 let key_type = added
818 .iter()
819 .find(|recipient| recipient.fingerprint == fingerprint)
820 .map_or_else(
821 || "unknown".to_string(),
822 |recipient| recipient.key_type.clone(),
823 );
824 format!("{fingerprint} ({key_type})")
825 }));
826
827 anyhow::bail!(
828 "{action} blocked by manifest policy: disallowed key types [{}]; allowed key types are [{}]",
829 invalid_all.join(", "),
830 manifest.allowed_key_types.join(", ")
831 );
832}
833
834fn enforce_min_recipients(
835 manifest: &RepositoryManifest,
836 resulting_count: usize,
837 action: &str,
838) -> Result<()> {
839 if resulting_count < manifest.min_recipients {
840 anyhow::bail!(
841 "{action} blocked by manifest policy: min_recipients={} but resulting recipients would be {}",
842 manifest.min_recipients,
843 resulting_count
844 );
845 }
846 Ok(())
847}
848
849fn enforce_existing_recipient_policy(
850 repo_root: &std::path::Path,
851 manifest: &RepositoryManifest,
852 action: &str,
853) -> Result<()> {
854 let recipients = list_recipients(repo_root)?;
855 enforce_min_recipients(manifest, recipients.len(), action)?;
856 let allowed = allowed_key_types_set(manifest);
857 let disallowed: Vec<String> = recipients
858 .iter()
859 .filter(|recipient| !allowed.contains(recipient.key_type.as_str()))
860 .map(|recipient| format!("{} ({})", recipient.fingerprint, recipient.key_type))
861 .collect();
862 if disallowed.is_empty() {
863 return Ok(());
864 }
865 anyhow::bail!(
866 "{action} blocked by manifest policy: disallowed existing recipient key types [{}]; allowed key types are [{}]",
867 disallowed.join(", "),
868 manifest.allowed_key_types.join(", ")
869 )
870}
871
872fn collect_doctor_failures(
873 repo_root: &std::path::Path,
874 common_dir: &std::path::Path,
875 manifest: &RepositoryManifest,
876) -> Result<Vec<String>> {
877 let mut failures = Vec::new();
878
879 if manifest.min_recipients == 0 {
880 failures.push("manifest min_recipients must be at least 1".to_string());
881 }
882 if manifest.allowed_key_types.is_empty() {
883 failures.push("manifest allowed_key_types cannot be empty".to_string());
884 }
885 if manifest.max_source_staleness_hours == Some(0) {
886 failures.push("manifest max_source_staleness_hours must be > 0 when set".to_string());
887 }
888 if manifest.repo_key_id.is_none() {
889 failures.push(
890 "manifest repo_key_id is missing; run `git-sshripped unlock` to bind current key"
891 .to_string(),
892 );
893 }
894
895 let process_cfg = git_effective_config(repo_root, "filter.git-sshripped.process")?;
896 if !process_cfg
897 .as_ref()
898 .is_some_and(|value| value.contains("filter-process"))
899 {
900 failures.push("filter.git-sshripped.process is missing or invalid".to_string());
901 }
902
903 let required_cfg = git_effective_config(repo_root, "filter.git-sshripped.required")?;
904 if required_cfg.as_deref() != Some("true") {
905 failures.push("filter.git-sshripped.required should be true".to_string());
906 }
907
908 let gitattributes = repo_root.join(".gitattributes");
909 match fs::read_to_string(&gitattributes) {
910 Ok(text) if text.contains("filter=git-sshripped") => {}
911 Ok(_) => failures.push(".gitattributes has no filter=git-sshripped entries".to_string()),
912 Err(err) => failures.push(format!("cannot read {}: {err}", gitattributes.display())),
913 }
914
915 let recipients = list_recipients(repo_root)?;
916 if recipients.is_empty() {
917 failures.push("no recipients configured".to_string());
918 }
919 if recipients.len() < manifest.min_recipients {
920 failures.push(format!(
921 "recipient count {} is below manifest min_recipients {}",
922 recipients.len(),
923 manifest.min_recipients
924 ));
925 }
926
927 let allowed_types = allowed_key_types_set(manifest);
928 for recipient in &recipients {
929 if !allowed_types.contains(recipient.key_type.as_str()) {
930 failures.push(format!(
931 "recipient {} uses disallowed key type {}",
932 recipient.fingerprint, recipient.key_type
933 ));
934 }
935 }
936
937 if let Some(max_hours) = manifest.max_source_staleness_hours {
938 failures.extend(check_source_staleness_failures(repo_root, max_hours)?);
939 }
940
941 failures.extend(check_wrapped_key_and_session_failures(
942 repo_root,
943 common_dir,
944 manifest,
945 &recipients,
946 )?);
947
948 Ok(failures)
949}
950
951fn check_source_staleness_failures(
952 repo_root: &std::path::Path,
953 max_hours: u64,
954) -> Result<Vec<String>> {
955 let mut failures = Vec::new();
956 let registry = read_github_sources(repo_root)?;
957 let max_age_secs = max_hours.saturating_mul(3600);
958 let now = now_unix();
959 for user in ®istry.users {
960 if user.last_refreshed_unix == 0 {
961 failures.push(format!(
962 "github user source {} has never been refreshed",
963 user.username
964 ));
965 continue;
966 }
967 let age = now.saturating_sub(user.last_refreshed_unix);
968 if age > max_age_secs {
969 failures.push(format!(
970 "github user source {} is stale ({}s > {}s)",
971 user.username, age, max_age_secs
972 ));
973 }
974 }
975 for team in ®istry.teams {
976 if team.last_refreshed_unix == 0 {
977 failures.push(format!(
978 "github team source {}/{} has never been refreshed",
979 team.org, team.team
980 ));
981 continue;
982 }
983 let age = now.saturating_sub(team.last_refreshed_unix);
984 if age > max_age_secs {
985 failures.push(format!(
986 "github team source {}/{} is stale ({}s > {}s)",
987 team.org, team.team, age, max_age_secs
988 ));
989 }
990 }
991 Ok(failures)
992}
993
994fn check_wrapped_key_and_session_failures(
995 repo_root: &std::path::Path,
996 common_dir: &std::path::Path,
997 manifest: &RepositoryManifest,
998 recipients: &[RecipientKey],
999) -> Result<Vec<String>> {
1000 let mut failures = Vec::new();
1001 let wrapped_files = wrapped_key_files(repo_root)?;
1002 if wrapped_files.is_empty() {
1003 failures.push("no wrapped keys found".to_string());
1004 }
1005 for recipient in recipients {
1006 let wrapped = wrapped_store_dir(repo_root).join(format!("{}.age", recipient.fingerprint));
1007 if !wrapped.exists() {
1008 failures.push(format!(
1009 "missing wrapped key for recipient {}",
1010 recipient.fingerprint
1011 ));
1012 }
1013 }
1014 if let Some(session) = read_unlock_session(common_dir)? {
1015 let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
1016 .decode(session.key_b64)
1017 .context("unlock session key is invalid base64")?;
1018 if decoded.len() != 32 {
1019 failures.push(format!(
1020 "unlock session key length is {}, expected 32",
1021 decoded.len()
1022 ));
1023 }
1024 if let Some(expected) = &manifest.repo_key_id {
1025 let actual = repo_key_id_from_bytes(&decoded);
1026 if &actual != expected {
1027 failures.push(format!(
1028 "unlock session repo key mismatch: expected {expected}, got {actual}"
1029 ));
1030 }
1031 if session.repo_key_id.as_deref() != Some(expected.as_str()) {
1032 failures.push(format!(
1033 "unlock session metadata repo_key_id mismatch: expected {expected}, got {}",
1034 session.repo_key_id.as_deref().unwrap_or("missing")
1035 ));
1036 }
1037 }
1038 }
1039 Ok(failures)
1040}
1041
1042fn fingerprint_in_other_sources(
1043 registry: &GithubSourceRegistry,
1044 fingerprint: &str,
1045 skip_user: Option<&str>,
1046 skip_team: Option<(&str, &str)>,
1047) -> bool {
1048 let in_users = registry.users.iter().any(|source| {
1049 if skip_user.is_some_and(|u| u == source.username) {
1050 return false;
1051 }
1052 source.fingerprints.iter().any(|f| f == fingerprint)
1053 });
1054 if in_users {
1055 return true;
1056 }
1057 registry.teams.iter().any(|source| {
1058 if skip_team.is_some_and(|(org, team)| org == source.org && team == source.team) {
1059 return false;
1060 }
1061 source.fingerprints.iter().any(|f| f == fingerprint)
1062 })
1063}
1064
1065fn cmd_init(
1066 patterns: &[String],
1067 algorithm: CliAlgorithm,
1068 recipient_keys: Vec<String>,
1069 github_keys_urls: Vec<String>,
1070 strict: bool,
1071) -> Result<()> {
1072 let repo_root = current_repo_root()?;
1073 let github_options = github_fetch_options(&repo_root)?;
1074
1075 let init = InitOptions {
1076 algorithm: algorithm.into(),
1077 strict_mode: strict,
1078 };
1079
1080 let manifest = RepositoryManifest {
1081 manifest_version: 1,
1082 encryption_algorithm: init.algorithm,
1083 strict_mode: init.strict_mode,
1084 ..RepositoryManifest::default()
1085 };
1086
1087 write_manifest(&repo_root, &manifest)?;
1088 install_gitattributes(&repo_root, patterns)?;
1089 install_git_filters(
1090 &repo_root,
1091 ¤t_bin_path(Some(&repo_root)),
1092 current_is_linked_worktree(&repo_root),
1093 )?;
1094
1095 let mut added_recipients = Vec::new();
1096
1097 for key in recipient_keys {
1098 let key_line = if std::path::Path::new(&key)
1099 .extension()
1100 .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
1101 {
1102 fs::read_to_string(&key)
1103 .with_context(|| format!("failed to read recipient key file {key}"))?
1104 } else {
1105 key
1106 };
1107 let recipient =
1108 add_recipient_from_public_key(&repo_root, &key_line, RecipientSource::LocalFile)?;
1109 added_recipients.push(recipient);
1110 }
1111
1112 for url in github_keys_urls {
1113 let recipients = add_recipients_from_github_source_with_options(
1114 &repo_root,
1115 &url,
1116 None,
1117 &github_options,
1118 )?;
1119 added_recipients.extend(recipients);
1120 }
1121
1122 for path in well_known_public_key_paths() {
1123 if !path.exists() {
1124 continue;
1125 }
1126 let key_line = fs::read_to_string(&path)
1127 .with_context(|| format!("failed to read default public key {}", path.display()))?;
1128 let recipient =
1129 add_recipient_from_public_key(&repo_root, &key_line, RecipientSource::LocalFile)?;
1130 added_recipients.push(recipient);
1131 }
1132
1133 let recipients = list_recipients(&repo_root)?;
1134
1135 let mut key = [0_u8; 32];
1136 rand::rng().fill_bytes(&mut key);
1137 let mut manifest = manifest;
1138 manifest.repo_key_id = Some(repo_key_id_from_bytes(&key));
1139 write_manifest(&repo_root, &manifest)?;
1140
1141 let wrapped_count = if recipients.is_empty() {
1142 let common_dir = current_common_dir()?;
1145 let key_id = repo_key_id_from_bytes(&key);
1146 write_unlock_session(&common_dir, &key, "init", Some(key_id))?;
1147 0
1148 } else {
1149 let wrapped = wrap_repo_key_for_all_recipients(&repo_root, &key)?;
1150 wrapped.len()
1151 };
1152
1153 println!("initialized git-sshripped in {}", repo_root.display());
1154 println!("algorithm: {:?}", manifest.encryption_algorithm);
1155 println!("strict_mode: {}", manifest.strict_mode);
1156 println!("patterns: {}", patterns.join(", "));
1157 println!("recipients: {}", recipients.len());
1158 println!("wrapped keys written: {wrapped_count}");
1159 if added_recipients.is_empty() && !recipients.is_empty() {
1160 println!("note: reused existing recipient definitions");
1161 }
1162 if recipients.is_empty() {
1163 eprintln!(
1164 "warning: no recipients configured; the repo key exists only in your local session"
1165 );
1166 eprintln!(
1167 "warning: add a recipient before the session is lost (e.g. git-sshripped add-github-user --username <user>)"
1168 );
1169 }
1170 Ok(())
1171}
1172
1173fn try_agent_wrap_unlock(common_dir: &std::path::Path) -> Result<Option<(Vec<u8>, String)>> {
1185 let agent_keys = match list_agent_ed25519_keys() {
1186 Ok(keys) if !keys.is_empty() => keys,
1187 _ => return Ok(None),
1188 };
1189
1190 let wrap_files = git_sshripped_repository::list_agent_wrap_files(common_dir)?;
1191 if wrap_files.is_empty() {
1192 return Ok(None);
1193 }
1194
1195 for wrap_path in &wrap_files {
1196 let text = std::fs::read_to_string(wrap_path)?;
1197 let wrapped = git_sshripped_repository::parse_agent_wrap(&text)?;
1198
1199 for agent_key in &agent_keys {
1200 if agent_key.fingerprint != wrapped.fingerprint {
1201 continue;
1202 }
1203 if let Some(repo_key) = agent_unwrap_repo_key(agent_key, &wrapped)? {
1204 return Ok(Some((
1205 repo_key,
1206 format!("agent-wrap: {}", agent_key.fingerprint),
1207 )));
1208 }
1209 }
1210 }
1211
1212 Ok(None)
1213}
1214
1215fn generate_missing_agent_wraps(
1221 repo_root: &std::path::Path,
1222 common_dir: &std::path::Path,
1223 repo_key: &[u8],
1224) {
1225 let agent_keys = match list_agent_ed25519_keys() {
1226 Ok(keys) if !keys.is_empty() => keys,
1227 _ => return,
1228 };
1229
1230 let Ok(recipients) = list_recipients(repo_root) else {
1231 return;
1232 };
1233
1234 let mut generated = Vec::new();
1235
1236 for recipient in &recipients {
1237 let wrap_file =
1239 git_sshripped_repository::agent_wrap_file(common_dir, &recipient.fingerprint);
1240 if wrap_file.exists() {
1241 continue;
1242 }
1243
1244 let Some(agent_key) = agent_keys
1246 .iter()
1247 .find(|k| k.fingerprint == recipient.fingerprint)
1248 else {
1249 continue;
1250 };
1251
1252 match agent_wrap_repo_key(agent_key, repo_key) {
1254 Ok(wrapped) => {
1255 if let Err(e) = git_sshripped_repository::write_agent_wrap(common_dir, &wrapped) {
1256 eprintln!(
1257 "warning: failed to write agent-wrap for {}: {e}",
1258 recipient.fingerprint
1259 );
1260 } else {
1261 generated.push(recipient.fingerprint.clone());
1262 }
1263 }
1264 Err(e) => {
1265 eprintln!(
1266 "warning: failed to generate agent-wrap for {}: {e}",
1267 recipient.fingerprint
1268 );
1269 }
1270 }
1271 }
1272
1273 if !generated.is_empty() {
1274 eprintln!(
1275 "generated agent-wrap key(s) for {} (fast unlock now available)",
1276 generated.join(", ")
1277 );
1278 }
1279}
1280
1281fn unwrap_repo_key_from_identities(
1282 repo_root: &std::path::Path,
1283 common_dir: &std::path::Path,
1284 identities: Vec<String>,
1285 github_user: Option<String>,
1286 prefer_agent: bool,
1287 no_agent: bool,
1288) -> Result<(Vec<u8>, String)> {
1289 let explicit_identities: Vec<PathBuf> = identities.into_iter().map(PathBuf::from).collect();
1290 let interactive_set: std::collections::HashSet<PathBuf> =
1291 explicit_identities.iter().cloned().collect();
1292 let mut identity_files = Vec::new();
1293
1294 if !no_agent {
1295 let mut agent_matches = private_keys_matching_agent()?;
1296 identity_files.append(&mut agent_matches);
1297 }
1298
1299 if explicit_identities.is_empty() {
1300 identity_files.extend(default_private_key_candidates());
1301 } else if prefer_agent {
1302 identity_files.extend(explicit_identities);
1303 } else {
1304 let mut merged = explicit_identities;
1305 merged.extend(identity_files);
1306 identity_files = merged;
1307 }
1308
1309 identity_files.sort();
1310 identity_files.dedup();
1311
1312 let mut wrapped_files = wrapped_key_files(repo_root)?;
1313 if let Some(user) = github_user {
1314 let recipients = list_recipients(repo_root)?;
1315 let allowed: std::collections::HashSet<String> = recipients
1316 .iter()
1317 .filter_map(|recipient| match &recipient.source {
1318 RecipientSource::GithubKeys { username, .. }
1319 if username.as_deref() == Some(&user) =>
1320 {
1321 Some(format!("{}.age", recipient.fingerprint))
1322 }
1323 _ => None,
1324 })
1325 .collect();
1326 wrapped_files.retain(|path| {
1327 path.file_name()
1328 .and_then(|name| name.to_str())
1329 .is_some_and(|name| allowed.contains(name))
1330 });
1331 }
1332 if wrapped_files.is_empty() {
1333 anyhow::bail!(
1334 "no wrapped key files found in {}; run init or rewrap first",
1335 wrapped_store_dir(repo_root).display()
1336 );
1337 }
1338
1339 if !no_agent && let Some((key, source)) = try_agent_wrap_unlock(common_dir)? {
1341 return Ok((key, source));
1342 }
1343
1344 let resolved_helper = if no_agent {
1345 None
1346 } else {
1347 resolve_agent_helper(repo_root)?
1348 };
1349
1350 if let Some((helper_path, source)) = resolved_helper
1351 && let Some((unwrapped, descriptor)) =
1352 unwrap_repo_key_with_agent_helper(&wrapped_files, &helper_path, 3000)?
1353 {
1354 Ok((
1355 unwrapped,
1356 format!("agent-helper[{source}]: {}", descriptor.label),
1357 ))
1358 } else {
1359 let Some((unwrapped, descriptor)) =
1360 unwrap_repo_key_from_wrapped_files(&wrapped_files, &identity_files, &interactive_set)?
1361 else {
1362 anyhow::bail!(
1363 "could not decrypt any wrapped key with agent helper or provided identity files; set GSC_SSH_AGENT_HELPER for true ssh-agent decrypt, or pass --identity"
1364 );
1365 };
1366 Ok((unwrapped, descriptor.label))
1367 }
1368}
1369
1370fn cmd_unlock(
1371 key_hex: Option<String>,
1372 identities: Vec<String>,
1373 github_user: Option<String>,
1374 prefer_agent: bool,
1375 no_agent: bool,
1376) -> Result<()> {
1377 let repo_root = current_repo_root()?;
1378 let common_dir = current_common_dir()?;
1379 let mut manifest = read_manifest(&repo_root)?;
1380
1381 if key_hex.is_none()
1385 && let Ok(Some(_)) = repo_key_from_session_in(&common_dir, Some(&manifest))
1386 {
1387 install_git_filters(
1388 &repo_root,
1389 ¤t_bin_path(Some(&repo_root)),
1390 current_is_linked_worktree(&repo_root),
1391 )?;
1392 let decrypted_count = checkout_encrypted_worktree_files(&repo_root);
1393 if decrypted_count > 0 {
1394 println!("decrypted {decrypted_count} protected files in working tree");
1395 }
1396 println!("repository is already unlocked");
1397 return Ok(());
1398 }
1399
1400 let (key, key_source) = if let Some(hex_value) = key_hex {
1401 (
1402 hex::decode(hex_value.trim()).context("--key-hex must be valid hex")?,
1403 "key-hex".to_string(),
1404 )
1405 } else {
1406 unwrap_repo_key_from_identities(
1407 &repo_root,
1408 &common_dir,
1409 identities,
1410 github_user,
1411 prefer_agent,
1412 no_agent,
1413 )?
1414 };
1415
1416 let key_id = repo_key_id_from_bytes(&key);
1417 if let Some(expected) = &manifest.repo_key_id {
1418 if expected != &key_id {
1419 anyhow::bail!(
1420 "unlock failed: derived repo key does not match manifest repo_key_id; expected {expected}, got {key_id}; run from the correct branch/worktree or re-import/rotate key"
1421 );
1422 }
1423 } else {
1424 manifest.repo_key_id = Some(key_id.clone());
1425 write_manifest(&repo_root, &manifest)?;
1426 }
1427
1428 write_unlock_session(&common_dir, &key, &key_source, Some(key_id))?;
1429 install_git_filters(
1430 &repo_root,
1431 ¤t_bin_path(Some(&repo_root)),
1432 current_is_linked_worktree(&repo_root),
1433 )?;
1434
1435 generate_missing_agent_wraps(&repo_root, &common_dir, &key);
1438
1439 let decrypted_count = checkout_encrypted_worktree_files(&repo_root);
1440
1441 println!(
1442 "unlocked repository across worktrees via {}",
1443 common_dir.display()
1444 );
1445 if decrypted_count > 0 {
1446 println!("decrypted {decrypted_count} protected files in working tree");
1447 }
1448 Ok(())
1449}
1450
1451const GIT_CHECKOUT_BATCH_SIZE: usize = 100;
1454
1455fn checkout_encrypted_worktree_files(repo_root: &std::path::Path) -> usize {
1464 let Ok(protected) = protected_tracked_files(repo_root) else {
1465 return 0;
1466 };
1467
1468 let encrypted: Vec<&str> = protected
1470 .iter()
1471 .filter(|path| {
1472 let full = repo_root.join(path);
1473 fs::read(&full)
1474 .map(|c| c.starts_with(&ENCRYPTED_MAGIC))
1475 .unwrap_or(false)
1476 })
1477 .map(String::as_str)
1478 .collect();
1479
1480 if encrypted.is_empty() {
1481 return 0;
1482 }
1483
1484 println!(
1485 "decrypting {} protected files in working tree...",
1486 encrypted.len()
1487 );
1488
1489 for path in &encrypted {
1494 let full = repo_root.join(path);
1495 if let Ok(f) = std::fs::File::open(&full) {
1496 let _ = f.set_modified(std::time::SystemTime::now());
1497 }
1498 }
1499
1500 let mut decrypted = 0usize;
1501 for batch in encrypted.chunks(GIT_CHECKOUT_BATCH_SIZE) {
1502 let mut cmd = std::process::Command::new("git");
1503 cmd.current_dir(repo_root)
1504 .args(["-c", "core.hooksPath=", "checkout", "--"]);
1505 for path in batch {
1506 cmd.arg(path);
1507 }
1508 match cmd.output() {
1509 Ok(output) if output.status.success() => {
1510 decrypted += batch.len();
1511 }
1512 Ok(output) => {
1513 let stderr = String::from_utf8_lossy(&output.stderr);
1514 eprintln!("warning: git checkout failed for batch: {stderr}");
1515 }
1516 Err(e) => {
1517 eprintln!("warning: failed to run git checkout: {e}");
1518 }
1519 }
1520 }
1521 decrypted
1522}
1523
1524fn cmd_lock(force: bool, no_scrub: bool) -> Result<()> {
1525 let repo_root = current_repo_root()?;
1526 let common_dir = current_common_dir()?;
1527 let previous_session = read_unlock_session(&common_dir)?;
1528
1529 let protected = if no_scrub {
1530 Vec::new()
1531 } else {
1532 println!("scanning protected files...");
1533 let protected = protected_tracked_files(&repo_root)?;
1534 if protected.is_empty() {
1535 println!("no protected tracked files found");
1536 } else {
1537 println!("found {} protected tracked files", protected.len());
1538 }
1539 let dirty = protected_dirty_paths(&repo_root, &protected)?;
1540 if !dirty.is_empty() && !force {
1541 let preview = dirty.iter().take(8).cloned().collect::<Vec<_>>().join(", ");
1542 anyhow::bail!(
1543 "lock refused: protected files have local changes ({preview}); commit/stash/reset them or re-run with --force"
1544 );
1545 }
1546 protected
1547 };
1548
1549 clear_unlock_session(&common_dir)?;
1550 if !no_scrub {
1551 println!("scrubbing protected files in working tree...");
1552 if let Err(scrub_err) = scrub_protected_paths(&repo_root, &protected) {
1553 if let Some(previous_session) = previous_session {
1554 let rollback = base64::engine::general_purpose::STANDARD_NO_PAD
1555 .decode(previous_session.key_b64)
1556 .context("failed to decode previous unlock session key while rolling back lock")
1557 .and_then(|key| {
1558 write_unlock_session(
1559 &common_dir,
1560 &key,
1561 &previous_session.key_source,
1562 previous_session.repo_key_id,
1563 )
1564 });
1565
1566 if let Err(rollback_err) = rollback {
1567 anyhow::bail!(
1568 "lock scrub failed: {scrub_err:#}; failed to restore previous unlock session: {rollback_err:#}"
1569 );
1570 }
1571 }
1572 anyhow::bail!("lock scrub failed: {scrub_err:#}; previous session restored");
1573 }
1574 }
1575
1576 if no_scrub {
1577 println!("locked repository across worktrees (no scrub)");
1578 } else {
1579 println!("locked repository across worktrees; scrubbed protected files in this worktree");
1580 }
1581 Ok(())
1582}
1583
1584fn cmd_status(json: bool) -> Result<()> {
1585 let repo_root = current_repo_root()?;
1586 let common_dir = current_common_dir()?;
1587 let manifest = read_manifest(&repo_root)?;
1588 let identity = detect_identity()?;
1589 let session = read_unlock_session(&common_dir)?;
1590 let recipients = list_recipients(&repo_root)?;
1591 let wrapped_files = wrapped_key_files(&repo_root)?;
1592 let helper = resolve_agent_helper(&repo_root)?;
1593 let protected_count = protected_tracked_files(&repo_root)?.len();
1594 let drift_count = verify_failures(&repo_root)?.len();
1595 let session_matches_manifest = session.as_ref().is_none_or(|s| {
1596 manifest
1597 .repo_key_id
1598 .as_ref()
1599 .is_none_or(|expected| s.repo_key_id.as_deref() == Some(expected.as_str()))
1600 });
1601
1602 if json {
1603 let payload = serde_json::json!({
1604 "repo": repo_root.display().to_string(),
1605 "common_dir": common_dir.display().to_string(),
1606 "state": if session.is_some() { "UNLOCKED" } else { "LOCKED" },
1607 "algorithm": format!("{:?}", manifest.encryption_algorithm),
1608 "strict_mode": manifest.strict_mode,
1609 "repo_key_id": manifest.repo_key_id,
1610 "min_recipients": manifest.min_recipients,
1611 "allowed_key_types": manifest.allowed_key_types,
1612 "require_doctor_clean_for_rotate": manifest.require_doctor_clean_for_rotate,
1613 "require_verify_strict_clean_for_rotate_revoke": manifest.require_verify_strict_clean_for_rotate_revoke,
1614 "max_source_staleness_hours": manifest.max_source_staleness_hours,
1615 "identity": {"label": identity.label, "source": format!("{:?}", identity.source)},
1616 "recipients": recipients.len(),
1617 "wrapped_keys": wrapped_files.len(),
1618 "protected_tracked_files": protected_count,
1619 "drift_failures": drift_count,
1620 "unlock_source": session.as_ref().map(|s| s.key_source.clone()),
1621 "unlock_repo_key_id": session.as_ref().and_then(|s| s.repo_key_id.clone()),
1622 "session_matches_manifest": session_matches_manifest,
1623 "agent_helper_resolved": helper.as_ref().map(|(path, _)| path.display().to_string()),
1624 "agent_helper_source": helper.as_ref().map(|(_, source)| source.clone()),
1625 "protected_patterns": read_gitattributes_patterns(&repo_root),
1626 });
1627 println!("{}", serde_json::to_string_pretty(&payload)?);
1628 return Ok(());
1629 }
1630
1631 print_status_text(
1632 &repo_root,
1633 &common_dir,
1634 &manifest,
1635 &identity,
1636 session,
1637 &recipients,
1638 &wrapped_files,
1639 helper,
1640 protected_count,
1641 drift_count,
1642 session_matches_manifest,
1643 );
1644 Ok(())
1645}
1646
1647#[allow(clippy::too_many_arguments)]
1648fn print_status_text(
1649 repo_root: &std::path::Path,
1650 common_dir: &std::path::Path,
1651 manifest: &RepositoryManifest,
1652 identity: &IdentityDescriptor,
1653 session: Option<UnlockSession>,
1654 recipients: &[RecipientKey],
1655 wrapped_files: &[PathBuf],
1656 helper: Option<(PathBuf, String)>,
1657 protected_count: usize,
1658 drift_count: usize,
1659 session_matches_manifest: bool,
1660) {
1661 println!("repo: {}", repo_root.display());
1662 println!(
1663 "state: {}",
1664 if session.is_some() {
1665 "UNLOCKED"
1666 } else {
1667 "LOCKED"
1668 }
1669 );
1670 println!("scope: all worktrees via {}", common_dir.display());
1671 println!("algorithm: {:?}", manifest.encryption_algorithm);
1672 println!("strict_mode: {}", manifest.strict_mode);
1673 println!(
1674 "repo_key_id: {}",
1675 manifest.repo_key_id.as_deref().unwrap_or("missing")
1676 );
1677 println!("min_recipients: {}", manifest.min_recipients);
1678 println!(
1679 "allowed_key_types: {}",
1680 manifest.allowed_key_types.join(", ")
1681 );
1682 println!(
1683 "require_doctor_clean_for_rotate: {}",
1684 manifest.require_doctor_clean_for_rotate
1685 );
1686 println!(
1687 "require_verify_strict_clean_for_rotate_revoke: {}",
1688 manifest.require_verify_strict_clean_for_rotate_revoke
1689 );
1690 println!(
1691 "max_source_staleness_hours: {}",
1692 manifest
1693 .max_source_staleness_hours
1694 .map_or_else(|| "none".to_string(), |v| v.to_string())
1695 );
1696 println!("identity: {} ({:?})", identity.label, identity.source);
1697 println!("recipients: {}", recipients.len());
1698 println!("wrapped keys: {}", wrapped_files.len());
1699 println!("protected tracked files: {protected_count}");
1700 println!("drift failures: {drift_count}");
1701 if let Some(session) = session {
1702 println!("unlock source: {}", session.key_source);
1703 println!(
1704 "unlock repo_key_id: {}",
1705 session.repo_key_id.as_deref().unwrap_or("missing")
1706 );
1707 if !session_matches_manifest {
1708 println!("unlock session: stale (run `git-sshripped unlock` in this worktree)");
1709 }
1710 }
1711 match helper {
1712 Some((path, source)) => println!("agent helper: {} ({})", path.display(), source),
1713 None => println!("agent helper: none"),
1714 }
1715 println!(
1716 "protected patterns: {}",
1717 read_gitattributes_patterns(repo_root).join(", ")
1718 );
1719}
1720
1721fn cmd_add_user(
1722 key: Option<String>,
1723 github_keys_url: Option<String>,
1724 github_user: Option<String>,
1725) -> Result<()> {
1726 let repo_root = current_repo_root()?;
1727 let github_options = github_fetch_options(&repo_root)?;
1728 let manifest = read_manifest(&repo_root)?;
1729 let session_key = repo_key_from_session()?;
1730 let existing_fingerprints: std::collections::HashSet<String> = list_recipients(&repo_root)?
1731 .into_iter()
1732 .map(|recipient| recipient.fingerprint)
1733 .collect();
1734
1735 let mut new_recipients = Vec::new();
1736
1737 if let Some(url) = github_keys_url {
1738 let added = add_recipients_from_github_source_with_options(
1739 &repo_root,
1740 &url,
1741 None,
1742 &github_options,
1743 )?;
1744 new_recipients.extend(added);
1745 println!("added {} recipients from {}", new_recipients.len(), url);
1746 }
1747
1748 if let Some(username) = github_user {
1749 let added = add_recipients_from_github_username_with_options(
1750 &repo_root,
1751 &username,
1752 &github_options,
1753 )?;
1754 println!("added {} recipients from github user source", added.len());
1755 new_recipients.extend(added);
1756 }
1757
1758 if let Some(key_input) = key {
1759 let key_line = if std::path::Path::new(&key_input)
1760 .extension()
1761 .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
1762 {
1763 fs::read_to_string(&key_input)
1764 .with_context(|| format!("failed to read key file {key_input}"))?
1765 } else {
1766 key_input
1767 };
1768
1769 let recipient =
1770 add_recipient_from_public_key(&repo_root, &key_line, RecipientSource::LocalFile)?;
1771 println!(
1772 "added recipient {} ({})",
1773 recipient.fingerprint, recipient.key_type
1774 );
1775 new_recipients.push(recipient);
1776 }
1777
1778 if new_recipients.is_empty() {
1779 anyhow::bail!(
1780 "provide --key <pubkey|path.pub>, --github-keys-url <url>, or --github-user <username>"
1781 );
1782 }
1783
1784 enforce_allowed_key_types_for_added_recipients(
1785 &repo_root,
1786 &manifest,
1787 &existing_fingerprints,
1788 &new_recipients,
1789 "add-user",
1790 )?;
1791
1792 if let Some(key) = session_key {
1793 let mut wrapped_count = 0;
1794 for recipient in &new_recipients {
1795 wrap_repo_key_for_recipient(&repo_root, recipient, &key)?;
1796 wrapped_count += 1;
1797 }
1798 println!("wrapped repo key for {wrapped_count} new recipients");
1799 } else {
1800 println!(
1801 "warning: repository is locked; run `git-sshripped unlock` then `git-sshripped rewrap` to grant access"
1802 );
1803 }
1804
1805 Ok(())
1806}
1807
1808fn cmd_list_users(verbose: bool) -> Result<()> {
1809 let repo_root = current_repo_root()?;
1810 let recipients = list_recipients(&repo_root)?;
1811 if recipients.is_empty() {
1812 println!("no recipients configured");
1813 return Ok(());
1814 }
1815
1816 let wrapped = wrapped_key_files(&repo_root)?;
1817 let wrapped_names: std::collections::HashSet<String> = wrapped
1818 .iter()
1819 .filter_map(|p| {
1820 p.file_name()
1821 .and_then(|n| n.to_str())
1822 .map(ToString::to_string)
1823 })
1824 .collect();
1825
1826 for recipient in recipients {
1827 let wrapped_name = format!("{}.age", recipient.fingerprint);
1828 let has_wrapped = wrapped_names.contains(&wrapped_name);
1829 if verbose {
1830 println!(
1831 "{} key_type={} wrapped={} source={:?}",
1832 recipient.fingerprint, recipient.key_type, has_wrapped, recipient.source
1833 );
1834 } else {
1835 println!("{} {}", recipient.fingerprint, recipient.key_type);
1836 }
1837 }
1838 Ok(())
1839}
1840
1841fn cmd_remove_user(fingerprint: &str, force: bool) -> Result<()> {
1842 let repo_root = current_repo_root()?;
1843 let manifest = read_manifest(&repo_root)?;
1844 let recipients = list_recipients(&repo_root)?;
1845
1846 let exists = recipients
1847 .iter()
1848 .any(|recipient| recipient.fingerprint == fingerprint);
1849 if !exists {
1850 anyhow::bail!("recipient not found: {fingerprint}");
1851 }
1852
1853 if recipients.len() <= 1 && !force {
1854 anyhow::bail!(
1855 "refusing to remove the last recipient; pass --force to override (risking lockout)"
1856 );
1857 }
1858
1859 enforce_min_recipients(&manifest, recipients.len().saturating_sub(1), "remove-user")?;
1860
1861 let removed = remove_recipient_by_fingerprint(&repo_root, fingerprint)?;
1862 if !removed {
1863 anyhow::bail!("no files were removed for recipient {fingerprint}");
1864 }
1865
1866 println!("removed recipient {fingerprint}");
1867 Ok(())
1868}
1869
1870fn revoke_github_user_all_keys(
1871 repo_root: &std::path::Path,
1872 manifest: &RepositoryManifest,
1873 username: &str,
1874 registry: &GithubSourceRegistry,
1875 force: bool,
1876) -> Result<Vec<String>> {
1877 let user_fingerprints: Vec<String> = list_recipients(repo_root)?
1878 .into_iter()
1879 .filter_map(|recipient| {
1880 if let RecipientSource::GithubKeys {
1881 username: Some(ref u),
1882 ..
1883 } = recipient.source
1884 && *u == username
1885 {
1886 return Some(recipient.fingerprint);
1887 }
1888 None
1889 })
1890 .collect();
1891 if user_fingerprints.is_empty() {
1892 anyhow::bail!("no recipient keys found for github user {username}");
1893 }
1894 let recipients_count = list_recipients(repo_root)?.len();
1895 let would_remove = user_fingerprints
1896 .iter()
1897 .filter(|fp| !fingerprint_in_other_sources(registry, fp, Some(username), None))
1898 .count();
1899 enforce_min_recipients(
1900 manifest,
1901 recipients_count.saturating_sub(would_remove),
1902 "revoke-user",
1903 )?;
1904
1905 let mut removed = Vec::new();
1906 for fp in &user_fingerprints {
1907 if !fingerprint_in_other_sources(registry, fp, Some(username), None) {
1908 let _ = remove_recipient_by_fingerprint(repo_root, fp)?;
1909 removed.push(fp.clone());
1910 }
1911 }
1912
1913 let mut updated_registry = registry.clone();
1914 updated_registry
1915 .users
1916 .retain(|entry| entry.username != username);
1917 write_github_sources(repo_root, &updated_registry)?;
1918 let _ = force;
1919 Ok(removed)
1920}
1921
1922fn cmd_revoke_user(opts: &RevokeUserOptions) -> Result<()> {
1923 let repo_root = current_repo_root()?;
1924 let manifest = read_manifest(&repo_root)?;
1925 enforce_verify_clean_for_sensitive_actions(&repo_root, &manifest, "revoke-user")?;
1926 let mut removed_fingerprints = Vec::new();
1927
1928 let selectors = usize::from(opts.fingerprint.is_some())
1929 + usize::from(opts.github_user.is_some())
1930 + usize::from(opts.org.is_some() || opts.team.is_some());
1931 if selectors != 1 {
1932 anyhow::bail!(
1933 "revoke-user requires exactly one selector: --fingerprint, --github-user, or --org/--team"
1934 );
1935 }
1936 if opts.org.is_some() != opts.team.is_some() {
1937 anyhow::bail!("--org and --team must be specified together");
1938 }
1939
1940 let target = if let Some(fingerprint) = &opts.fingerprint {
1941 cmd_remove_user(fingerprint, opts.force)?;
1942 removed_fingerprints.push(fingerprint.clone());
1943 format!("fingerprint:{fingerprint}")
1944 } else if let Some(username) = &opts.github_user {
1945 let registry = read_github_sources(&repo_root)?;
1946 if opts.all_keys_for_user {
1947 removed_fingerprints = revoke_github_user_all_keys(
1948 &repo_root, &manifest, username, ®istry, opts.force,
1949 )?;
1950 } else {
1951 let source = registry
1952 .users
1953 .iter()
1954 .find(|entry| entry.username == *username)
1955 .cloned()
1956 .ok_or_else(|| anyhow::anyhow!("github user source not found: {username}"))?;
1957 removed_fingerprints = source.fingerprints;
1958 cmd_remove_github_user(username, opts.force)?;
1959 }
1960 format!("github-user:{username}")
1961 } else {
1962 let Some(org) = &opts.org else {
1963 anyhow::bail!("--org is required with --team")
1964 };
1965 let Some(team) = &opts.team else {
1966 anyhow::bail!("--team is required with --org")
1967 };
1968 let registry = read_github_sources(&repo_root)?;
1969 let source = registry
1970 .teams
1971 .iter()
1972 .find(|entry| entry.org == *org && entry.team == *team)
1973 .cloned()
1974 .ok_or_else(|| anyhow::anyhow!("github team source not found: {org}/{team}"))?;
1975 removed_fingerprints = source.fingerprints;
1976 cmd_remove_github_team(org, team)?;
1977 format!("github-team:{org}/{team}")
1978 };
1979
1980 let refreshed = if opts.auto_reencrypt {
1981 reencrypt_with_current_session(&repo_root)?
1982 } else {
1983 0usize
1984 };
1985 if opts.json {
1986 println!(
1987 "{}",
1988 serde_json::to_string_pretty(&serde_json::json!({
1989 "ok": true,
1990 "target": target,
1991 "removed_fingerprints": removed_fingerprints,
1992 "auto_reencrypt": opts.auto_reencrypt,
1993 "reencrypted_files": refreshed,
1994 }))?
1995 );
1996 } else if opts.auto_reencrypt {
1997 println!("revoke-user: auto-reencrypt refreshed {refreshed} protected files");
1998 } else {
1999 println!("revoke-user: run `git-sshripped reencrypt` and commit to complete offboarding");
2000 }
2001
2002 Ok(())
2003}
2004
2005fn cmd_add_github_user(
2006 username: &str,
2007 auto_wrap: bool,
2008 all: bool,
2009 key: Option<String>,
2010 key_file: Option<String>,
2011) -> Result<()> {
2012 let effective_key = match (key, key_file) {
2013 (Some(k), _) => Some(k),
2014 (_, Some(source))
2015 if {
2016 let lower = source.to_lowercase();
2017 lower.starts_with("http://") || lower.starts_with("https://")
2018 } =>
2019 {
2020 let source_url = normalize_https_url(&source, "--key-file URL")?;
2021 let contents = reqwest::blocking::Client::builder()
2022 .build()
2023 .context("failed to build reqwest client")?
2024 .get(&source_url)
2025 .send()
2026 .with_context(|| format!("failed to fetch key from '{source_url}'"))?
2027 .error_for_status()
2028 .with_context(|| format!("HTTP error fetching key from '{source_url}'"))?
2029 .text()
2030 .with_context(|| format!("failed to read response body from '{source_url}'"))?;
2031 Some(contents.trim().to_string())
2032 }
2033 (_, Some(path)) => {
2034 let contents = fs::read_to_string(&path)
2035 .with_context(|| format!("failed to read key file '{path}'"))?;
2036 Some(contents.trim().to_string())
2037 }
2038 _ => None,
2039 };
2040
2041 let repo_root = current_repo_root()?;
2042 let github_options = github_fetch_options(&repo_root)?;
2043 let manifest = read_manifest(&repo_root)?;
2044 let mut registry = read_github_sources(&repo_root)?;
2045 let session_key = repo_key_from_session()?;
2046 let existing_fingerprints: std::collections::HashSet<String> = list_recipients(&repo_root)?
2047 .into_iter()
2048 .map(|recipient| recipient.fingerprint)
2049 .collect();
2050
2051 let fetched = fetch_github_user_keys_with_options(username, &github_options, None)?;
2052 let fetched_keys: Vec<&str> = fetched
2053 .keys
2054 .iter()
2055 .filter(|line| !line.trim().is_empty())
2056 .map(String::as_str)
2057 .collect();
2058
2059 println!(
2060 "add-github-user: fetched {} keys from github source",
2061 fetched_keys.len()
2062 );
2063
2064 let keys_to_add =
2065 select_github_keys_to_add(username, all, effective_key.as_ref(), &fetched_keys)?;
2066
2067 let mut recipients = Vec::new();
2068 for line in &keys_to_add {
2069 let recipient = add_recipient_from_public_key(
2070 &repo_root,
2071 line,
2072 RecipientSource::GithubKeys {
2073 url: fetched.url.clone(),
2074 username: Some(username.to_string()),
2075 },
2076 )
2077 .with_context(|| format!("failed to add recipient from key line '{line}'"))?;
2078 recipients.push(recipient);
2079 }
2080
2081 enforce_allowed_key_types_for_added_recipients(
2082 &repo_root,
2083 &manifest,
2084 &existing_fingerprints,
2085 &recipients,
2086 "add-github-user",
2087 )?;
2088
2089 finalize_github_user_source(
2090 &repo_root,
2091 username,
2092 auto_wrap,
2093 session_key.as_deref(),
2094 &recipients,
2095 &mut registry,
2096 &github_options,
2097 )?;
2098
2099 println!(
2100 "add-github-user: added source ({} recipient(s))",
2101 recipients.len()
2102 );
2103 Ok(())
2104}
2105
2106fn select_github_keys_to_add<'a>(
2107 username: &str,
2108 all: bool,
2109 effective_key: Option<&String>,
2110 fetched_keys: &[&'a str],
2111) -> Result<Vec<&'a str>> {
2112 if all {
2113 return Ok(fetched_keys.to_vec());
2114 }
2115 if let Some(provided_key) = effective_key {
2116 let provided_prefix = ssh_key_prefix(provided_key);
2117 let matched: Vec<&str> = fetched_keys
2118 .iter()
2119 .filter(|github_key| ssh_key_prefix(github_key) == provided_prefix)
2120 .copied()
2121 .collect();
2122 if matched.is_empty() {
2123 anyhow::bail!("the provided key does not match any of {username}'s GitHub keys");
2124 }
2125 return Ok(matched);
2126 }
2127 let local_pub_keys = local_public_key_contents();
2130 let matched: Vec<&str> = fetched_keys
2131 .iter()
2132 .filter(|github_key| {
2133 let github_prefix = ssh_key_prefix(github_key);
2134 local_pub_keys
2135 .iter()
2136 .any(|local_key| ssh_key_prefix(local_key) == github_prefix)
2137 })
2138 .copied()
2139 .collect();
2140 let skipped = fetched_keys.len() - matched.len();
2141 if matched.is_empty() {
2142 anyhow::bail!(
2143 "none of {username}'s GitHub keys match a local private key in ~/.ssh/; pass --all to add all keys, or --key to specify one"
2144 );
2145 }
2146 if skipped > 0 {
2147 println!(
2148 "add-github-user: matched {} key(s) to local private keys (skipped {skipped})",
2149 matched.len()
2150 );
2151 }
2152 Ok(matched)
2153}
2154
2155fn finalize_github_user_source(
2156 repo_root: &std::path::Path,
2157 username: &str,
2158 auto_wrap: bool,
2159 session_key: Option<&[u8]>,
2160 recipients: &[RecipientKey],
2161 registry: &mut GithubSourceRegistry,
2162 github_options: &GithubFetchOptions,
2163) -> Result<()> {
2164 let fingerprints: Vec<String> = recipients
2165 .iter()
2166 .map(|recipient| recipient.fingerprint.clone())
2167 .collect();
2168 if auto_wrap {
2169 if let Some(key) = session_key {
2170 for recipient in recipients {
2171 wrap_repo_key_for_recipient(repo_root, recipient, key)?;
2172 }
2173 } else {
2174 println!(
2175 "add-github-user: repository is locked; recipients were added but not wrapped (run unlock + rewrap)"
2176 );
2177 }
2178 }
2179 registry.users.retain(|source| source.username != username);
2180 registry.users.push(GithubUserSource {
2181 username: username.to_string(),
2182 url: format!("{}/{}.keys", github_options.web_base_url, username),
2183 fingerprints,
2184 last_refreshed_unix: now_unix(),
2185 etag: None,
2186 last_refresh_status_code: Some("ok".to_string()),
2187 last_refresh_message: Some("added source".to_string()),
2188 });
2189 write_github_sources(repo_root, registry)?;
2190 Ok(())
2191}
2192
2193fn cmd_list_github_users(verbose: bool) -> Result<()> {
2194 let repo_root = current_repo_root()?;
2195 let registry = read_github_sources(&repo_root)?;
2196 if registry.users.is_empty() {
2197 println!("no github user sources configured");
2198 return Ok(());
2199 }
2200 for source in registry.users {
2201 if verbose {
2202 println!(
2203 "url={} fingerprints={} refreshed={} status={} message={}",
2204 source.url,
2205 source.fingerprints.len(),
2206 source.last_refreshed_unix,
2207 source
2208 .last_refresh_status_code
2209 .as_deref()
2210 .unwrap_or("unknown"),
2211 source.last_refresh_message.as_deref().unwrap_or("none")
2212 );
2213 } else {
2214 println!("{}", source.url);
2215 }
2216 }
2217 Ok(())
2218}
2219
2220fn cmd_remove_github_user(username: &str, force: bool) -> Result<()> {
2221 let repo_root = current_repo_root()?;
2222 let manifest = read_manifest(&repo_root)?;
2223 let mut registry = read_github_sources(&repo_root)?;
2224 let Some(source) = registry
2225 .users
2226 .iter()
2227 .find(|source| source.username == username)
2228 .cloned()
2229 else {
2230 anyhow::bail!("github user source not found: {username}");
2231 };
2232
2233 let recipients = list_recipients(&repo_root)?;
2234 if recipients.len() <= 1 && !force {
2235 anyhow::bail!("refusing to remove final recipient/source without --force");
2236 }
2237
2238 let would_remove = source
2239 .fingerprints
2240 .iter()
2241 .filter(|fingerprint| {
2242 !fingerprint_in_other_sources(®istry, fingerprint, Some(username), None)
2243 })
2244 .count();
2245 enforce_min_recipients(
2246 &manifest,
2247 recipients.len().saturating_sub(would_remove),
2248 "remove-github-user",
2249 )?;
2250
2251 for fingerprint in &source.fingerprints {
2252 if !fingerprint_in_other_sources(®istry, fingerprint, Some(username), None) {
2253 let _ = remove_recipient_by_fingerprint(&repo_root, fingerprint)?;
2254 }
2255 }
2256
2257 registry.users.retain(|entry| entry.username != username);
2258 write_github_sources(&repo_root, ®istry)?;
2259 println!("remove-github-user: removed github user source");
2260 Ok(())
2261}
2262
2263fn cmd_add_github_team(org: &str, team: &str, auto_wrap: bool) -> Result<()> {
2264 let repo_root = current_repo_root()?;
2265 let github_options = github_fetch_options(&repo_root)?;
2266 let manifest = read_manifest(&repo_root)?;
2267 let mut registry = read_github_sources(&repo_root)?;
2268 let session_key = repo_key_from_session()?;
2269 let fetched_team = fetch_github_team_members_with_options(org, team, &github_options, None)?;
2270 let team_etag = fetched_team.metadata.etag.clone();
2271 let members = fetched_team.members;
2272
2273 let mut fingerprints = std::collections::BTreeSet::new();
2274 for member in &members {
2275 let existing_fingerprints: std::collections::HashSet<String> = list_recipients(&repo_root)?
2276 .into_iter()
2277 .map(|recipient| recipient.fingerprint)
2278 .collect();
2279 let recipients =
2280 add_recipients_from_github_username_with_options(&repo_root, member, &github_options)?;
2281 enforce_allowed_key_types_for_added_recipients(
2282 &repo_root,
2283 &manifest,
2284 &existing_fingerprints,
2285 &recipients,
2286 "add-github-team",
2287 )?;
2288 if auto_wrap {
2289 if let Some(key) = session_key.as_deref() {
2290 for recipient in &recipients {
2291 wrap_repo_key_for_recipient(&repo_root, recipient, key)?;
2292 }
2293 } else {
2294 println!(
2295 "add-github-team: repository is locked; recipients were added but not wrapped (run unlock + rewrap)"
2296 );
2297 }
2298 }
2299 for recipient in recipients {
2300 fingerprints.insert(recipient.fingerprint);
2301 }
2302 }
2303
2304 registry
2305 .teams
2306 .retain(|source| !(source.org == org && source.team == team));
2307 registry.teams.push(GithubTeamSource {
2308 org: org.to_string(),
2309 team: team.to_string(),
2310 member_usernames: members,
2311 fingerprints: fingerprints.into_iter().collect(),
2312 last_refreshed_unix: now_unix(),
2313 etag: team_etag,
2314 last_refresh_status_code: Some("ok".to_string()),
2315 last_refresh_message: Some("added source".to_string()),
2316 });
2317 write_github_sources(&repo_root, ®istry)?;
2318 println!("add-github-team: added source for {org}/{team}");
2319 Ok(())
2320}
2321
2322fn cmd_list_github_teams() -> Result<()> {
2323 let repo_root = current_repo_root()?;
2324 let registry = read_github_sources(&repo_root)?;
2325 if registry.teams.is_empty() {
2326 println!("no github team sources configured");
2327 return Ok(());
2328 }
2329 for source in registry.teams {
2330 println!(
2331 "{}/{} refreshed={} status={} message={}",
2332 source.org,
2333 source.team,
2334 source.last_refreshed_unix,
2335 source
2336 .last_refresh_status_code
2337 .as_deref()
2338 .unwrap_or("unknown"),
2339 source.last_refresh_message.as_deref().unwrap_or("none")
2340 );
2341 }
2342 Ok(())
2343}
2344
2345fn cmd_remove_github_team(org: &str, team: &str) -> Result<()> {
2346 let repo_root = current_repo_root()?;
2347 let manifest = read_manifest(&repo_root)?;
2348 let mut registry = read_github_sources(&repo_root)?;
2349 let Some(source) = registry
2350 .teams
2351 .iter()
2352 .find(|source| source.org == org && source.team == team)
2353 .cloned()
2354 else {
2355 anyhow::bail!("github team source not found: {org}/{team}");
2356 };
2357
2358 let recipients = list_recipients(&repo_root)?;
2359 let would_remove = source
2360 .fingerprints
2361 .iter()
2362 .filter(|fingerprint| {
2363 !fingerprint_in_other_sources(®istry, fingerprint, None, Some((org, team)))
2364 })
2365 .count();
2366 enforce_min_recipients(
2367 &manifest,
2368 recipients.len().saturating_sub(would_remove),
2369 "remove-github-team",
2370 )?;
2371
2372 for fingerprint in &source.fingerprints {
2373 if !fingerprint_in_other_sources(®istry, fingerprint, None, Some((org, team))) {
2374 let _ = remove_recipient_by_fingerprint(&repo_root, fingerprint)?;
2375 }
2376 }
2377
2378 registry
2379 .teams
2380 .retain(|entry| !(entry.org == org && entry.team == team));
2381 write_github_sources(&repo_root, ®istry)?;
2382 println!("remove-github-team: removed source for {org}/{team}");
2383 Ok(())
2384}
2385
2386fn refresh_output_results(
2387 label: &str,
2388 events: Vec<serde_json::Value>,
2389 drift_detected: bool,
2390 fail_on_drift: bool,
2391 refresh_errors: &[String],
2392 json: bool,
2393) -> Result<()> {
2394 if json {
2395 println!(
2396 "{}",
2397 serde_json::to_string_pretty(&serde_json::json!({
2398 "events": events,
2399 "drift_detected": drift_detected,
2400 }))?
2401 );
2402 } else {
2403 for event in events {
2404 println!("{label}: {event}");
2405 }
2406 }
2407
2408 if fail_on_drift && drift_detected {
2409 anyhow::bail!("{label} detected access drift");
2410 }
2411
2412 if !refresh_errors.is_empty() {
2413 anyhow::bail!(
2414 "{label} failed for {} source(s): {}",
2415 refresh_errors.len(),
2416 refresh_errors.join(" | ")
2417 );
2418 }
2419
2420 Ok(())
2421}
2422
2423fn refresh_github_keys_handle_not_modified(
2424 source: &GithubUserSource,
2425 fetched_user: &git_sshripped_recipient::GithubUserKeys,
2426 before_set: &std::collections::HashSet<String>,
2427 dry_run: bool,
2428 registry: &mut GithubSourceRegistry,
2429 events: &mut Vec<serde_json::Value>,
2430) {
2431 if !dry_run
2432 && let Some(entry) = registry
2433 .users
2434 .iter_mut()
2435 .find(|entry| entry.username == source.username)
2436 {
2437 entry.last_refresh_status_code = Some("not_modified".to_string());
2438 entry.last_refresh_message = Some("source unchanged (etag)".to_string());
2439 entry.last_refreshed_unix = now_unix();
2440 }
2441 events.push(serde_json::json!({
2442 "username": source.username,
2443 "ok": true,
2444 "added": Vec::<String>::new(),
2445 "removed": Vec::<String>::new(),
2446 "unchanged": before_set.len(),
2447 "dry_run": dry_run,
2448 "backend": format!("{:?}", fetched_user.backend),
2449 "authenticated": fetched_user.authenticated,
2450 "auth_mode": github_auth_mode_label(fetched_user.auth_mode),
2451 "not_modified": true,
2452 "rate_limit_remaining": fetched_user.metadata.rate_limit_remaining,
2453 "rate_limit_reset_unix": fetched_user.metadata.rate_limit_reset_unix,
2454 }));
2455}
2456
2457fn refresh_github_keys_handle_error(
2458 source: &GithubUserSource,
2459 err: &anyhow::Error,
2460 dry_run: bool,
2461 registry: &mut GithubSourceRegistry,
2462 refresh_errors: &mut Vec<String>,
2463 events: &mut Vec<serde_json::Value>,
2464) {
2465 let code = classify_github_refresh_error(err).to_string();
2466 let message = format!("{err:#}");
2467 if !dry_run
2468 && let Some(entry) = registry
2469 .users
2470 .iter_mut()
2471 .find(|entry| entry.username == source.username)
2472 {
2473 entry.last_refresh_status_code = Some(code.clone());
2474 entry.last_refresh_message = Some(message.clone());
2475 entry.last_refreshed_unix = now_unix();
2476 }
2477 refresh_errors.push(format!("{}({}): {}", source.username, code, message));
2478 events.push(serde_json::json!({
2479 "username": source.username,
2480 "ok": false,
2481 "error_code": code,
2482 "error": message,
2483 "dry_run": dry_run,
2484 }));
2485}
2486
2487#[allow(clippy::too_many_arguments)]
2488fn refresh_github_keys_apply_changes(
2489 repo_root: &std::path::Path,
2490 manifest: &RepositoryManifest,
2491 source: &GithubUserSource,
2492 fetched_user: &git_sshripped_recipient::GithubUserKeys,
2493 github_options: &GithubFetchOptions,
2494 session_key: Option<&[u8]>,
2495 dry_run: bool,
2496 registry: &mut GithubSourceRegistry,
2497) -> Result<(Vec<String>, Vec<String>, usize, bool)> {
2498 let existing_fingerprints: std::collections::HashSet<String> = list_recipients(repo_root)?
2499 .into_iter()
2500 .map(|recipient| recipient.fingerprint)
2501 .collect();
2502 let before_set: std::collections::HashSet<String> =
2503 source.fingerprints.iter().cloned().collect();
2504 let _ = github_options;
2505
2506 let fetched = fetched_user
2507 .keys
2508 .iter()
2509 .filter(|line| !line.trim().is_empty())
2510 .map(|line| {
2511 add_recipient_from_public_key(
2512 repo_root,
2513 line,
2514 RecipientSource::GithubKeys {
2515 url: source.url.clone(),
2516 username: Some(source.username.clone()),
2517 },
2518 )
2519 })
2520 .collect::<Result<Vec<_>>>()?;
2521 let after_set: std::collections::HashSet<String> = fetched
2522 .iter()
2523 .map(|recipient| recipient.fingerprint.clone())
2524 .collect();
2525
2526 enforce_allowed_key_types_for_added_recipients(
2527 repo_root,
2528 manifest,
2529 &existing_fingerprints,
2530 &fetched,
2531 "refresh-github-keys",
2532 )?;
2533
2534 let added: Vec<String> = after_set.difference(&before_set).cloned().collect();
2535 let removed: Vec<String> = before_set.difference(&after_set).cloned().collect();
2536 let unchanged = before_set.intersection(&after_set).count();
2537 let drift = !added.is_empty() || !removed.is_empty();
2538
2539 if !dry_run {
2540 let mut safe_remove = Vec::new();
2541 for fingerprint in &removed {
2542 if !fingerprint_in_other_sources(registry, fingerprint, Some(&source.username), None) {
2543 safe_remove.push(fingerprint.clone());
2544 }
2545 }
2546 let current_count = list_recipients(repo_root)?.len();
2547 enforce_min_recipients(
2548 manifest,
2549 current_count.saturating_sub(safe_remove.len()),
2550 "refresh-github-keys",
2551 )?;
2552 let _ = remove_recipients_by_fingerprints(repo_root, &safe_remove)?;
2553
2554 if let Some(key) = session_key {
2555 for recipient in &fetched {
2556 wrap_repo_key_for_recipient(repo_root, recipient, key)?;
2557 }
2558 }
2559
2560 if let Some(entry) = registry
2561 .users
2562 .iter_mut()
2563 .find(|entry| entry.username == source.username)
2564 {
2565 entry.fingerprints = after_set.iter().cloned().collect();
2566 entry.last_refreshed_unix = now_unix();
2567 entry.etag.clone_from(&fetched_user.metadata.etag);
2568 entry.last_refresh_status_code = Some("ok".to_string());
2569 entry.last_refresh_message = Some("refresh succeeded".to_string());
2570 }
2571 }
2572
2573 Ok((added, removed, unchanged, drift))
2574}
2575
2576fn cmd_refresh_github_keys(
2577 username: Option<&str>,
2578 dry_run: bool,
2579 fail_on_drift: bool,
2580 json: bool,
2581) -> Result<()> {
2582 let repo_root = current_repo_root()?;
2583 let github_options = github_fetch_options(&repo_root)?;
2584 let manifest = read_manifest(&repo_root)?;
2585 let mut registry = read_github_sources(&repo_root)?;
2586 let mut targets: Vec<_> = registry.users.clone();
2587 if let Some(user) = username {
2588 targets.retain(|source| source.username == user);
2589 }
2590
2591 if targets.is_empty() {
2592 println!("refresh-github-keys: no matching GitHub user sources configured");
2593 return Ok(());
2594 }
2595
2596 let session_key = repo_key_from_session()?;
2597 let mut events = Vec::new();
2598 let mut drift_detected = false;
2599 let mut refresh_errors = Vec::new();
2600
2601 for source in targets {
2602 let before_set: std::collections::HashSet<String> =
2603 source.fingerprints.iter().cloned().collect();
2604 let fetched_user = match fetch_github_user_keys_with_options(
2605 &source.username,
2606 &github_options,
2607 source.etag.as_deref(),
2608 ) {
2609 Ok(value) => value,
2610 Err(err) => {
2611 refresh_github_keys_handle_error(
2612 &source,
2613 &err,
2614 dry_run,
2615 &mut registry,
2616 &mut refresh_errors,
2617 &mut events,
2618 );
2619 continue;
2620 }
2621 };
2622
2623 if fetched_user.metadata.not_modified {
2624 refresh_github_keys_handle_not_modified(
2625 &source,
2626 &fetched_user,
2627 &before_set,
2628 dry_run,
2629 &mut registry,
2630 &mut events,
2631 );
2632 continue;
2633 }
2634
2635 let (added, removed, unchanged, drift) = refresh_github_keys_apply_changes(
2636 &repo_root,
2637 &manifest,
2638 &source,
2639 &fetched_user,
2640 &github_options,
2641 session_key.as_deref(),
2642 dry_run,
2643 &mut registry,
2644 )?;
2645 if drift {
2646 drift_detected = true;
2647 }
2648
2649 events.push(serde_json::json!({
2650 "username": source.username,
2651 "ok": true,
2652 "added": added,
2653 "removed": removed,
2654 "unchanged": unchanged,
2655 "dry_run": dry_run,
2656 "backend": format!("{:?}", fetched_user.backend),
2657 "authenticated": fetched_user.authenticated,
2658 "auth_mode": github_auth_mode_label(fetched_user.auth_mode),
2659 "not_modified": false,
2660 "rate_limit_remaining": fetched_user.metadata.rate_limit_remaining,
2661 "rate_limit_reset_unix": fetched_user.metadata.rate_limit_reset_unix,
2662 }));
2663 }
2664
2665 if !dry_run {
2666 write_github_sources(&repo_root, ®istry)?;
2667 }
2668
2669 refresh_output_results(
2670 "refresh-github-keys",
2671 events,
2672 drift_detected,
2673 fail_on_drift,
2674 &refresh_errors,
2675 json,
2676 )
2677}
2678
2679#[allow(clippy::too_many_arguments)]
2680fn refresh_github_teams_handle_not_modified(
2681 source: &GithubTeamSource,
2682 fetched_team_metadata: &git_sshripped_recipient::GithubFetchMetadata,
2683 backend: git_sshripped_recipient::GithubBackend,
2684 authenticated: bool,
2685 fetched_team_auth_mode: git_sshripped_recipient::GithubAuthMode,
2686 before_set: &std::collections::HashSet<String>,
2687 dry_run: bool,
2688 registry: &mut GithubSourceRegistry,
2689 events: &mut Vec<serde_json::Value>,
2690) {
2691 if !dry_run
2692 && let Some(entry) = registry
2693 .teams
2694 .iter_mut()
2695 .find(|entry| entry.org == source.org && entry.team == source.team)
2696 {
2697 entry.last_refresh_status_code = Some("not_modified".to_string());
2698 entry.last_refresh_message = Some("source unchanged (etag)".to_string());
2699 entry.last_refreshed_unix = now_unix();
2700 }
2701 events.push(serde_json::json!({
2702 "org": source.org,
2703 "team": source.team,
2704 "ok": true,
2705 "added": Vec::<String>::new(),
2706 "removed": Vec::<String>::new(),
2707 "unchanged": before_set.len(),
2708 "dry_run": dry_run,
2709 "backend": format!("{:?}", backend),
2710 "authenticated": authenticated,
2711 "auth_mode": github_auth_mode_label(fetched_team_auth_mode),
2712 "not_modified": true,
2713 "rate_limit_remaining": fetched_team_metadata.rate_limit_remaining,
2714 "rate_limit_reset_unix": fetched_team_metadata.rate_limit_reset_unix,
2715 }));
2716}
2717
2718fn refresh_github_teams_handle_error(
2719 source: &GithubTeamSource,
2720 err: &anyhow::Error,
2721 dry_run: bool,
2722 registry: &mut GithubSourceRegistry,
2723 refresh_errors: &mut Vec<String>,
2724 events: &mut Vec<serde_json::Value>,
2725) {
2726 let code = classify_github_refresh_error(err).to_string();
2727 let message = format!("{err:#}");
2728 if !dry_run
2729 && let Some(entry) = registry
2730 .teams
2731 .iter_mut()
2732 .find(|entry| entry.org == source.org && entry.team == source.team)
2733 {
2734 entry.last_refresh_status_code = Some(code.clone());
2735 entry.last_refresh_message = Some(message.clone());
2736 entry.last_refreshed_unix = now_unix();
2737 }
2738 refresh_errors.push(format!(
2739 "{}/{}({}): {}",
2740 source.org, source.team, code, message
2741 ));
2742 events.push(serde_json::json!({
2743 "org": source.org,
2744 "team": source.team,
2745 "ok": false,
2746 "error_code": code,
2747 "error": message,
2748 "dry_run": dry_run,
2749 }));
2750}
2751
2752#[allow(clippy::too_many_arguments)]
2753fn refresh_github_teams_fetch_members(
2754 repo_root: &std::path::Path,
2755 manifest: &RepositoryManifest,
2756 members: &[String],
2757 github_options: &GithubFetchOptions,
2758 session_key: Option<&[u8]>,
2759 dry_run: bool,
2760) -> Result<std::collections::HashSet<String>> {
2761 let mut fetched_fingerprints = std::collections::HashSet::new();
2762 for member in members {
2763 let existing_fingerprints: std::collections::HashSet<String> = list_recipients(repo_root)?
2764 .into_iter()
2765 .map(|recipient| recipient.fingerprint)
2766 .collect();
2767 let imported =
2768 add_recipients_from_github_username_with_options(repo_root, member, github_options)?;
2769 enforce_allowed_key_types_for_added_recipients(
2770 repo_root,
2771 manifest,
2772 &existing_fingerprints,
2773 &imported,
2774 "refresh-github-teams",
2775 )?;
2776 if let Some(key) = session_key
2777 && !dry_run
2778 {
2779 for recipient in &imported {
2780 wrap_repo_key_for_recipient(repo_root, recipient, key)?;
2781 }
2782 }
2783 for recipient in imported {
2784 fetched_fingerprints.insert(recipient.fingerprint);
2785 }
2786 }
2787 Ok(fetched_fingerprints)
2788}
2789
2790#[allow(clippy::too_many_arguments)]
2791fn refresh_github_teams_apply_removals(
2792 repo_root: &std::path::Path,
2793 manifest: &RepositoryManifest,
2794 source: &GithubTeamSource,
2795 members: Vec<String>,
2796 fetched_fingerprints: &std::collections::HashSet<String>,
2797 fetched_team_metadata: &git_sshripped_recipient::GithubFetchMetadata,
2798 removed: &[String],
2799 registry: &mut GithubSourceRegistry,
2800) -> Result<()> {
2801 let mut safe_remove = Vec::new();
2802 for fingerprint in removed {
2803 if !fingerprint_in_other_sources(
2804 registry,
2805 fingerprint,
2806 None,
2807 Some((&source.org, &source.team)),
2808 ) {
2809 safe_remove.push(fingerprint.clone());
2810 }
2811 }
2812 let current_count = list_recipients(repo_root)?.len();
2813 enforce_min_recipients(
2814 manifest,
2815 current_count.saturating_sub(safe_remove.len()),
2816 "refresh-github-teams",
2817 )?;
2818 let _ = remove_recipients_by_fingerprints(repo_root, &safe_remove)?;
2819
2820 if let Some(entry) = registry
2821 .teams
2822 .iter_mut()
2823 .find(|entry| entry.org == source.org && entry.team == source.team)
2824 {
2825 entry.member_usernames = members;
2826 entry.fingerprints = fetched_fingerprints.iter().cloned().collect();
2827 entry.last_refreshed_unix = now_unix();
2828 entry.etag.clone_from(&fetched_team_metadata.etag);
2829 entry.last_refresh_status_code = Some("ok".to_string());
2830 entry.last_refresh_message = Some("refresh succeeded".to_string());
2831 }
2832 Ok(())
2833}
2834
2835#[allow(clippy::too_many_arguments)]
2836fn refresh_github_teams_process_source(
2837 source: &GithubTeamSource,
2838 repo_root: &std::path::Path,
2839 manifest: &RepositoryManifest,
2840 github_options: &GithubFetchOptions,
2841 session_key: Option<&[u8]>,
2842 dry_run: bool,
2843 registry: &mut GithubSourceRegistry,
2844 events: &mut Vec<serde_json::Value>,
2845 drift_detected: &mut bool,
2846 refresh_errors: &mut Vec<String>,
2847) -> Result<()> {
2848 let fetched_team = match fetch_github_team_members_with_options(
2849 &source.org,
2850 &source.team,
2851 github_options,
2852 source.etag.as_deref(),
2853 ) {
2854 Ok(value) => value,
2855 Err(err) => {
2856 refresh_github_teams_handle_error(
2857 source,
2858 &err,
2859 dry_run,
2860 registry,
2861 refresh_errors,
2862 events,
2863 );
2864 return Ok(());
2865 }
2866 };
2867 let fetched_team_metadata = fetched_team.metadata.clone();
2868 let fetched_team_auth_mode = fetched_team.auth_mode;
2869 let members = fetched_team.members;
2870 let backend = fetched_team.backend;
2871 let authenticated = fetched_team.authenticated;
2872
2873 let before_set: std::collections::HashSet<String> =
2874 source.fingerprints.iter().cloned().collect();
2875 if fetched_team_metadata.not_modified {
2876 refresh_github_teams_handle_not_modified(
2877 source,
2878 &fetched_team_metadata,
2879 backend,
2880 authenticated,
2881 fetched_team_auth_mode,
2882 &before_set,
2883 dry_run,
2884 registry,
2885 events,
2886 );
2887 return Ok(());
2888 }
2889
2890 let fetched_fingerprints = refresh_github_teams_fetch_members(
2891 repo_root,
2892 manifest,
2893 &members,
2894 github_options,
2895 session_key,
2896 dry_run,
2897 )?;
2898
2899 let added: Vec<String> = fetched_fingerprints
2900 .difference(&before_set)
2901 .cloned()
2902 .collect();
2903 let removed: Vec<String> = before_set
2904 .difference(&fetched_fingerprints)
2905 .cloned()
2906 .collect();
2907 let unchanged = before_set.intersection(&fetched_fingerprints).count();
2908 if !added.is_empty() || !removed.is_empty() {
2909 *drift_detected = true;
2910 }
2911
2912 if !dry_run {
2913 refresh_github_teams_apply_removals(
2914 repo_root,
2915 manifest,
2916 source,
2917 members,
2918 &fetched_fingerprints,
2919 &fetched_team_metadata,
2920 &removed,
2921 registry,
2922 )?;
2923 }
2924
2925 events.push(serde_json::json!({
2926 "org": source.org,
2927 "team": source.team,
2928 "ok": true,
2929 "added": added,
2930 "removed": removed,
2931 "unchanged": unchanged,
2932 "dry_run": dry_run,
2933 "backend": format!("{:?}", backend),
2934 "authenticated": authenticated,
2935 "auth_mode": github_auth_mode_label(fetched_team_auth_mode),
2936 "not_modified": false,
2937 "rate_limit_remaining": fetched_team_metadata.rate_limit_remaining,
2938 "rate_limit_reset_unix": fetched_team_metadata.rate_limit_reset_unix,
2939 }));
2940 Ok(())
2941}
2942
2943fn cmd_refresh_github_teams(
2944 org: Option<&str>,
2945 team: Option<&str>,
2946 dry_run: bool,
2947 fail_on_drift: bool,
2948 json: bool,
2949) -> Result<()> {
2950 let repo_root = current_repo_root()?;
2951 let github_options = github_fetch_options(&repo_root)?;
2952 let manifest = read_manifest(&repo_root)?;
2953 let mut registry = read_github_sources(&repo_root)?;
2954 let mut targets = registry.teams.clone();
2955 if let Some(org) = org {
2956 targets.retain(|source| source.org == org);
2957 }
2958 if let Some(team) = team {
2959 targets.retain(|source| source.team == team);
2960 }
2961 if targets.is_empty() {
2962 println!("refresh-github-teams: no matching team sources configured");
2963 return Ok(());
2964 }
2965
2966 let session_key = repo_key_from_session()?;
2967 let mut events = Vec::new();
2968 let mut drift_detected = false;
2969 let mut refresh_errors = Vec::new();
2970
2971 for source in targets {
2972 refresh_github_teams_process_source(
2973 &source,
2974 &repo_root,
2975 &manifest,
2976 &github_options,
2977 session_key.as_deref(),
2978 dry_run,
2979 &mut registry,
2980 &mut events,
2981 &mut drift_detected,
2982 &mut refresh_errors,
2983 )?;
2984 }
2985
2986 if !dry_run {
2987 write_github_sources(&repo_root, ®istry)?;
2988 }
2989
2990 refresh_output_results(
2991 "refresh-github-teams",
2992 events,
2993 drift_detected,
2994 fail_on_drift,
2995 &refresh_errors,
2996 json,
2997 )
2998}
2999
3000fn cmd_access_audit(identities: Vec<String>, json: bool) -> Result<()> {
3001 let repo_root = current_repo_root()?;
3002 let recipients = list_recipients(&repo_root)?;
3003 let identities = if identities.is_empty() {
3004 default_private_key_candidates()
3005 } else {
3006 identities.into_iter().map(PathBuf::from).collect()
3007 };
3008
3009 let mut accessible = 0usize;
3010 let mut rows = Vec::new();
3011 for recipient in recipients {
3012 let wrapped = wrapped_store_dir(&repo_root).join(format!("{}.age", recipient.fingerprint));
3013 let can = unwrap_repo_key_from_wrapped_files(
3014 &[wrapped],
3015 &identities,
3016 &std::collections::HashSet::new(),
3017 )?
3018 .is_some();
3019 if can {
3020 accessible += 1;
3021 }
3022 rows.push(serde_json::json!({
3023 "fingerprint": recipient.fingerprint,
3024 "key_type": recipient.key_type,
3025 "source": format!("{:?}", recipient.source),
3026 "accessible": can,
3027 }));
3028 }
3029
3030 if json {
3031 println!(
3032 "{}",
3033 serde_json::to_string_pretty(&serde_json::json!({
3034 "accessible": accessible,
3035 "rows": rows,
3036 }))?
3037 );
3038 } else {
3039 for row in &rows {
3040 println!("{row}");
3041 }
3042 println!("access-audit: accessible recipients={accessible}");
3043 }
3044
3045 Ok(())
3046}
3047
3048fn cmd_install() -> Result<()> {
3049 let repo_root = current_repo_root()?;
3050 let _manifest = read_manifest(&repo_root)?;
3051 install_git_filters(
3052 &repo_root,
3053 ¤t_bin_path(Some(&repo_root)),
3054 current_is_linked_worktree(&repo_root),
3055 )?;
3056 println!("install: refreshed git filter configuration");
3057 Ok(())
3058}
3059
3060fn cmd_config(command: ConfigCommand) -> Result<()> {
3061 let repo_root = current_repo_root()?;
3062 match command {
3063 ConfigCommand::SetAgentHelper { path } => {
3064 let helper = PathBuf::from(&path);
3065 if !is_executable(&helper) {
3066 anyhow::bail!("agent helper path is not executable: {}", helper.display());
3067 }
3068 let mut cfg: RepositoryLocalConfig = read_local_config(&repo_root)?;
3069 cfg.agent_helper = Some(path);
3070 write_local_config(&repo_root, &cfg)?;
3071 println!("config: set agent helper to {}", helper.display());
3072 Ok(())
3073 }
3074 ConfigCommand::SetGithubApiBase { url } => {
3075 let mut cfg: RepositoryLocalConfig = read_local_config(&repo_root)?;
3076 let normalized = normalize_https_base_url(&url, "set-github-api-base")?;
3077 cfg.github_api_base = Some(normalized);
3078 write_local_config(&repo_root, &cfg)?;
3079 println!(
3080 "config: set github api base to {}",
3081 cfg.github_api_base.as_deref().unwrap_or_default()
3082 );
3083 Ok(())
3084 }
3085 ConfigCommand::SetGithubWebBase { url } => {
3086 let mut cfg: RepositoryLocalConfig = read_local_config(&repo_root)?;
3087 let normalized = normalize_https_base_url(&url, "set-github-web-base")?;
3088 cfg.github_web_base = Some(normalized);
3089 write_local_config(&repo_root, &cfg)?;
3090 println!(
3091 "config: set github web base to {}",
3092 cfg.github_web_base.as_deref().unwrap_or_default()
3093 );
3094 Ok(())
3095 }
3096 ConfigCommand::SetGithubAuthMode { mode } => {
3097 let normalized = mode.trim().to_ascii_lowercase();
3098 let _ = parse_github_auth_mode(&normalized)?;
3099 let mut cfg: RepositoryLocalConfig = read_local_config(&repo_root)?;
3100 cfg.github_auth_mode = Some(normalized.clone());
3101 write_local_config(&repo_root, &cfg)?;
3102 println!("config: set github auth mode to {normalized}");
3103 Ok(())
3104 }
3105 ConfigCommand::SetGithubPrivateSourceHardFail { enabled } => {
3106 let enabled = match enabled.trim().to_ascii_lowercase().as_str() {
3107 "1" | "true" | "yes" | "on" => true,
3108 "0" | "false" | "no" | "off" => false,
3109 other => anyhow::bail!(
3110 "invalid boolean value '{other}' for set-github-private-source-hard-fail"
3111 ),
3112 };
3113 let mut cfg: RepositoryLocalConfig = read_local_config(&repo_root)?;
3114 cfg.github_private_source_hard_fail = Some(enabled);
3115 write_local_config(&repo_root, &cfg)?;
3116 println!("config: set github private-source hard-fail to {enabled}");
3117 Ok(())
3118 }
3119 ConfigCommand::Show => {
3120 let cfg: RepositoryLocalConfig = read_local_config(&repo_root)?;
3121 println!("{}", serde_json::to_string_pretty(&cfg)?);
3122 Ok(())
3123 }
3124 }
3125}
3126
3127fn cmd_policy(command: PolicyCommand) -> Result<()> {
3128 let repo_root = current_repo_root()?;
3129 match command {
3130 PolicyCommand::Show { json } => {
3131 let manifest = read_manifest(&repo_root)?;
3132 if json {
3133 println!("{}", serde_json::to_string_pretty(&manifest)?);
3134 } else {
3135 println!("policy: min_recipients {}", manifest.min_recipients);
3136 println!(
3137 "policy: allowed_key_types {}",
3138 manifest.allowed_key_types.join(", ")
3139 );
3140 println!(
3141 "policy: require_doctor_clean_for_rotate {}",
3142 manifest.require_doctor_clean_for_rotate
3143 );
3144 println!(
3145 "policy: require_verify_strict_clean_for_rotate_revoke {}",
3146 manifest.require_verify_strict_clean_for_rotate_revoke
3147 );
3148 println!(
3149 "policy: max_source_staleness_hours {}",
3150 manifest
3151 .max_source_staleness_hours
3152 .map_or_else(|| "none".to_string(), |v| v.to_string())
3153 );
3154 }
3155 Ok(())
3156 }
3157 PolicyCommand::Verify { json } => {
3158 let manifest = read_manifest(&repo_root)?;
3159 let common_dir = current_common_dir()?;
3160 let failures = collect_doctor_failures(&repo_root, &common_dir, &manifest)?;
3161 let ok = failures.is_empty();
3162 if json {
3163 println!(
3164 "{}",
3165 serde_json::to_string_pretty(&serde_json::json!({
3166 "ok": ok,
3167 "failures": failures,
3168 }))?
3169 );
3170 } else if ok {
3171 println!("policy verify: OK");
3172 } else {
3173 println!("policy verify: FAIL ({})", failures.len());
3174 for failure in &failures {
3175 eprintln!("- {failure}");
3176 }
3177 }
3178 if ok {
3179 Ok(())
3180 } else {
3181 anyhow::bail!("policy verification failed")
3182 }
3183 }
3184 PolicyCommand::Set {
3185 min_recipients,
3186 allow_key_types,
3187 require_doctor_clean_for_rotate,
3188 require_verify_strict_clean_for_rotate_revoke,
3189 max_source_staleness_hours,
3190 } => {
3191 let mut manifest = read_manifest(&repo_root)?;
3192 if let Some(min) = min_recipients {
3193 manifest.min_recipients = min;
3194 }
3195 if !allow_key_types.is_empty() {
3196 manifest.allowed_key_types = allow_key_types;
3197 }
3198 if let Some(required) = require_doctor_clean_for_rotate {
3199 manifest.require_doctor_clean_for_rotate = required;
3200 }
3201 if let Some(required) = require_verify_strict_clean_for_rotate_revoke {
3202 manifest.require_verify_strict_clean_for_rotate_revoke = required;
3203 }
3204 if let Some(hours) = max_source_staleness_hours {
3205 if hours == 0 {
3206 anyhow::bail!("policy set rejected: max_source_staleness_hours must be > 0");
3207 }
3208 manifest.max_source_staleness_hours = Some(hours);
3209 }
3210 if manifest.min_recipients == 0 {
3211 anyhow::bail!("policy set rejected: min_recipients must be at least 1");
3212 }
3213 if manifest.allowed_key_types.is_empty() {
3214 anyhow::bail!("policy set rejected: allowed_key_types cannot be empty");
3215 }
3216 write_manifest(&repo_root, &manifest)?;
3217 println!("policy set: updated manifest policy");
3218 Ok(())
3219 }
3220 }
3221}
3222
3223#[derive(Debug)]
3224struct GitattributesMigrationPlan {
3225 rewritten_text: String,
3226 patterns: Vec<String>,
3227 legacy_lines_found: usize,
3228 legacy_lines_replaced: usize,
3229 duplicate_lines_removed: usize,
3230 rewritable_lines: usize,
3231 ambiguous_lines: Vec<String>,
3232 manual_intervention_lines: Vec<String>,
3233 idempotent_rewrite: bool,
3234}
3235
3236fn build_gitattributes_migration_plan(text: &str) -> GitattributesMigrationPlan {
3237 let mut output_lines = Vec::new();
3238 let mut seen_lines = std::collections::HashSet::new();
3239 let mut patterns = std::collections::BTreeSet::new();
3240 let mut legacy_lines_found = 0usize;
3241 let mut legacy_lines_replaced = 0usize;
3242 let mut duplicate_lines_removed = 0usize;
3243 let mut rewritable_lines = 0usize;
3244 let mut ambiguous_lines = Vec::new();
3245 let mut manual_intervention_lines = Vec::new();
3246
3247 for line in text.lines() {
3248 let trimmed = line.trim();
3249 if trimmed.is_empty() || trimmed.starts_with('#') {
3250 output_lines.push(line.to_string());
3251 continue;
3252 }
3253
3254 let Some(pattern) = trimmed.split_whitespace().next().filter(|p| !p.is_empty()) else {
3255 output_lines.push(line.to_string());
3256 continue;
3257 };
3258
3259 if trimmed.contains("filter=git-crypt") {
3260 legacy_lines_found += 1;
3261 let mut tokens = trimmed.split_whitespace();
3262 let _ = tokens.next();
3263 let attrs: Vec<&str> = tokens.collect();
3264 let non_standard_attrs = attrs
3265 .iter()
3266 .filter(|attr| {
3267 !attr.starts_with("filter=")
3268 && !attr.starts_with("diff=")
3269 && !attr.starts_with("text")
3270 })
3271 .count();
3272 if non_standard_attrs > 0 {
3273 ambiguous_lines.push(trimmed.to_string());
3274 } else {
3275 rewritable_lines += 1;
3276 }
3277
3278 legacy_lines_replaced += 1;
3279 patterns.insert(pattern.to_string());
3280 let normalized = format!("{pattern} filter=git-sshripped diff=git-sshripped");
3281 if seen_lines.insert(normalized.clone()) {
3282 output_lines.push(normalized);
3283 } else {
3284 duplicate_lines_removed += 1;
3285 }
3286 continue;
3287 }
3288
3289 if trimmed.contains("filter=git-sshripped") {
3290 patterns.insert(pattern.to_string());
3291 if seen_lines.insert(trimmed.to_string()) {
3292 output_lines.push(trimmed.to_string());
3293 } else {
3294 duplicate_lines_removed += 1;
3295 }
3296 continue;
3297 }
3298
3299 if trimmed.contains("!filter") || trimmed.contains("!diff") {
3300 patterns.insert(format!("!{pattern}"));
3301 output_lines.push(line.to_string());
3302 continue;
3303 }
3304
3305 if trimmed.contains("git-crypt") {
3306 manual_intervention_lines.push(trimmed.to_string());
3307 }
3308 output_lines.push(line.to_string());
3309 }
3310
3311 let mut rewritten_text = output_lines.join("\n");
3312 if !rewritten_text.ends_with('\n') {
3313 rewritten_text.push('\n');
3314 }
3315 let idempotent_rewrite = rewritten_text == text;
3316
3317 GitattributesMigrationPlan {
3318 rewritten_text,
3319 patterns: patterns.into_iter().collect(),
3320 legacy_lines_found,
3321 legacy_lines_replaced,
3322 duplicate_lines_removed,
3323 rewritable_lines,
3324 ambiguous_lines,
3325 manual_intervention_lines,
3326 idempotent_rewrite,
3327 }
3328}
3329
3330fn build_migration_report(
3331 plan: &GitattributesMigrationPlan,
3332 manifest_after: &RepositoryManifest,
3333 opts: &MigrateOptions,
3334 imported_patterns: usize,
3335 changed_patterns: bool,
3336 reencrypted_files: usize,
3337 verify_failures_list: &[String],
3338) -> serde_json::Value {
3339 let empty_vec: Vec<String> = Vec::new();
3340 serde_json::json!({
3341 "ok": true,
3342 "dry_run": opts.dry_run,
3343 "gitattributes": {
3344 "legacy_lines_found": plan.legacy_lines_found,
3345 "legacy_lines_replaced": plan.legacy_lines_replaced,
3346 "duplicate_lines_removed": plan.duplicate_lines_removed,
3347 },
3348 "migration_analysis": {
3349 "rewritable_lines": plan.rewritable_lines,
3350 "ambiguous": plan.ambiguous_lines,
3351 "manual_intervention": plan.manual_intervention_lines,
3352 "idempotent_rewrite": plan.idempotent_rewrite,
3353 },
3354 "imported_patterns": imported_patterns,
3355 "changed_patterns": changed_patterns,
3356 "repo_key_id": manifest_after.repo_key_id,
3357 "reencrypt_requested": opts.reencrypt,
3358 "reencrypted_files": reencrypted_files,
3359 "verify_requested": opts.verify,
3360 "files_requiring_reencrypt": if opts.dry_run { verify_failures_list } else { &empty_vec },
3361 "verify_failures": if opts.dry_run { &empty_vec } else { verify_failures_list },
3362 })
3363}
3364
3365fn migrate_check_verify(repo_root: &std::path::Path, opts: &MigrateOptions) -> Result<Vec<String>> {
3366 let mut verify_failures_list = Vec::new();
3367 if opts.verify {
3368 verify_failures_list = verify_failures(repo_root)?;
3369 if !verify_failures_list.is_empty() && !opts.dry_run {
3370 if opts.json {
3371 println!(
3372 "{}",
3373 serde_json::to_string_pretty(&serde_json::json!({
3374 "ok": false,
3375 "dry_run": opts.dry_run,
3376 "verify_failures": verify_failures_list,
3377 }))?
3378 );
3379 } else {
3380 println!("migrate-from-git-crypt: verify failed");
3381 for failure in &verify_failures_list {
3382 eprintln!("- {failure}");
3383 }
3384 }
3385 anyhow::bail!("migration verification failed");
3386 }
3387 }
3388 Ok(verify_failures_list)
3389}
3390
3391fn cmd_migrate_from_git_crypt(opts: &MigrateOptions) -> Result<()> {
3392 let repo_root = current_repo_root()?;
3393 let manifest_policy = read_manifest(&repo_root).unwrap_or_default();
3394 enforce_existing_recipient_policy(&repo_root, &manifest_policy, "migrate-from-git-crypt")?;
3395 let path = repo_root.join(".gitattributes");
3396 let text =
3397 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
3398
3399 let plan = build_gitattributes_migration_plan(&text);
3400 if plan.patterns.is_empty() {
3401 let payload = serde_json::json!({
3402 "ok": true,
3403 "dry_run": opts.dry_run,
3404 "noop": true,
3405 "reason": "no git-crypt or git-sshripped patterns found",
3406 "migration_analysis": {
3407 "rewritable_lines": plan.rewritable_lines,
3408 "ambiguous": plan.ambiguous_lines,
3409 "manual_intervention": plan.manual_intervention_lines,
3410 "idempotent_rewrite": plan.idempotent_rewrite,
3411 }
3412 });
3413 if opts.json {
3414 println!("{}", serde_json::to_string_pretty(&payload)?);
3415 } else {
3416 println!("migrate-from-git-crypt: no matching patterns found; nothing to do");
3417 }
3418 return Ok(());
3419 }
3420
3421 let mut manifest_after = read_manifest(&repo_root).unwrap_or_default();
3422 if let Some(key) = repo_key_from_session().ok().flatten() {
3423 manifest_after.repo_key_id = Some(repo_key_id_from_bytes(&key));
3424 }
3425 let imported_patterns = plan.patterns.len();
3426 let changed_patterns = true;
3427
3428 if !opts.dry_run {
3429 fs::write(&path, &plan.rewritten_text)
3430 .with_context(|| format!("failed to rewrite {}", path.display()))?;
3431 write_manifest(&repo_root, &manifest_after)?;
3432 install_gitattributes(&repo_root, &plan.patterns)?;
3433 install_git_filters(
3434 &repo_root,
3435 ¤t_bin_path(Some(&repo_root)),
3436 current_is_linked_worktree(&repo_root),
3437 )?;
3438 }
3439
3440 let reencrypted_files = if opts.reencrypt {
3441 if opts.dry_run {
3442 protected_tracked_files(&repo_root)?.len()
3443 } else {
3444 reencrypt_with_current_session(&repo_root)?
3445 }
3446 } else {
3447 0usize
3448 };
3449
3450 let verify_failures_list = migrate_check_verify(&repo_root, opts)?;
3451
3452 let mut report = build_migration_report(
3453 &plan,
3454 &manifest_after,
3455 opts,
3456 imported_patterns,
3457 changed_patterns,
3458 reencrypted_files,
3459 &verify_failures_list,
3460 );
3461
3462 if let Some(ref path) = opts.write_report {
3463 let report_text = format!("{}\n", serde_json::to_string_pretty(&report)?);
3464 fs::write(path, report_text)
3465 .with_context(|| format!("failed to write migration report {path}"))?;
3466 if let Some(object) = report.as_object_mut() {
3467 object.insert("report_written_to".to_string(), serde_json::json!(path));
3468 }
3469 }
3470
3471 if opts.json {
3472 println!("{}", serde_json::to_string_pretty(&report)?);
3473 } else {
3474 println!(
3475 "migrate-from-git-crypt: patterns={} changed={} legacy_replaced={} duplicates_removed={} rewritable={} ambiguous={} manual={} dry_run={} reencrypted={} verify={}",
3476 imported_patterns,
3477 changed_patterns,
3478 plan.legacy_lines_replaced,
3479 plan.duplicate_lines_removed,
3480 plan.rewritable_lines,
3481 plan.ambiguous_lines.len(),
3482 plan.manual_intervention_lines.len(),
3483 opts.dry_run,
3484 reencrypted_files,
3485 opts.verify
3486 );
3487 }
3488
3489 Ok(())
3490}
3491
3492fn cmd_export_repo_key(out: &str) -> Result<()> {
3493 let Some(key) = repo_key_from_session()? else {
3494 anyhow::bail!("repository is locked; run `git-sshripped unlock` first");
3495 };
3496 let encoded = hex::encode(key);
3497 fs::write(out, format!("{encoded}\n")).with_context(|| format!("failed to write {out}"))?;
3498
3499 #[cfg(unix)]
3500 {
3501 use std::os::unix::fs::PermissionsExt;
3502 let mut perms = fs::metadata(out)
3503 .with_context(|| format!("failed to read metadata for {out}"))?
3504 .permissions();
3505 perms.set_mode(0o600);
3506 fs::set_permissions(out, perms)
3507 .with_context(|| format!("failed to set secure permissions on {out}"))?;
3508 }
3509
3510 println!("export-repo-key: wrote key material to {out}");
3511 Ok(())
3512}
3513
3514fn cmd_import_repo_key(input: &str) -> Result<()> {
3515 let repo_root = current_repo_root()?;
3516 let common_dir = current_common_dir()?;
3517 let mut manifest = read_manifest(&repo_root)?;
3518 enforce_existing_recipient_policy(&repo_root, &manifest, "import-repo-key")?;
3519 let text = fs::read_to_string(input).with_context(|| format!("failed to read {input}"))?;
3520 let key = hex::decode(text.trim()).context("import key file must contain hex key bytes")?;
3521 if key.len() != 32 {
3522 anyhow::bail!("imported key length must be 32 bytes, got {}", key.len());
3523 }
3524
3525 let key_id = repo_key_id_from_bytes(&key);
3526 manifest.repo_key_id = Some(key_id.clone());
3527 write_manifest(&repo_root, &manifest)?;
3528
3529 let wrapped = wrap_repo_key_for_all_recipients(&repo_root, &key)?;
3530 write_unlock_session(&common_dir, &key, "import", Some(key_id))?;
3531 println!(
3532 "import-repo-key: imported key and wrapped for {} recipients",
3533 wrapped.len()
3534 );
3535 Ok(())
3536}
3537
3538fn git_show_index_path(repo_root: &std::path::Path, path: &str) -> Result<Vec<u8>> {
3539 let output = std::process::Command::new("git")
3540 .current_dir(repo_root)
3541 .args(["show", &format!(":{path}")])
3542 .output()
3543 .with_context(|| format!("failed to run git show :{path}"))?;
3544
3545 if !output.status.success() {
3546 anyhow::bail!("git show :{path} failed");
3547 }
3548
3549 Ok(output.stdout)
3550}
3551
3552fn git_add_paths(repo_root: &std::path::Path, paths: &[String], renormalize: bool) -> Result<()> {
3553 if paths.is_empty() {
3554 return Ok(());
3555 }
3556
3557 let mut args = vec!["add".to_string()];
3558 if renormalize {
3559 args.push("--renormalize".to_string());
3560 }
3561 args.push("--".to_string());
3562 args.extend(paths.iter().cloned());
3563
3564 let output = std::process::Command::new("git")
3565 .current_dir(repo_root)
3566 .args(args)
3567 .output()
3568 .context("failed to run git add")?;
3569
3570 if !output.status.success() {
3571 anyhow::bail!(
3572 "git add failed: {}",
3573 String::from_utf8_lossy(&output.stderr).trim()
3574 );
3575 }
3576
3577 Ok(())
3578}
3579
3580fn git_changed_paths(
3581 repo_root: &std::path::Path,
3582 paths: &[String],
3583 cached: bool,
3584) -> Result<std::collections::BTreeSet<String>> {
3585 const CHUNK: usize = 100;
3586 if paths.is_empty() {
3587 return Ok(std::collections::BTreeSet::new());
3588 }
3589
3590 let mut dirty = std::collections::BTreeSet::new();
3591
3592 for chunk in paths.chunks(CHUNK) {
3593 let mut args = vec!["diff".to_string(), "--name-only".to_string()];
3594 if cached {
3595 args.push("--cached".to_string());
3596 }
3597 args.push("--".to_string());
3598 args.extend(chunk.iter().cloned());
3599
3600 let output = std::process::Command::new("git")
3601 .current_dir(repo_root)
3602 .args(args)
3603 .output()
3604 .context("failed to run git diff --name-only")?;
3605
3606 if !output.status.success() {
3607 anyhow::bail!(
3608 "git diff --name-only failed: {}",
3609 String::from_utf8_lossy(&output.stderr).trim()
3610 );
3611 }
3612
3613 let text = String::from_utf8(output.stdout).context("git diff output is not utf8")?;
3614 for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
3615 dirty.insert(line.to_string());
3616 }
3617 }
3618
3619 Ok(dirty)
3620}
3621
3622fn protected_dirty_paths(
3623 repo_root: &std::path::Path,
3624 protected: &[String],
3625) -> Result<std::collections::BTreeSet<String>> {
3626 let mut dirty = git_changed_paths(repo_root, protected, false)?;
3627 dirty.extend(git_changed_paths(repo_root, protected, true)?);
3628 Ok(dirty)
3629}
3630
3631fn scrub_protected_paths(repo_root: &std::path::Path, protected: &[String]) -> Result<()> {
3632 for path in protected {
3633 let blob = git_show_index_path(repo_root, path)
3634 .with_context(|| format!("failed reading index blob for protected path {path}"))?;
3635 let full_path = repo_root.join(path);
3636 if let Some(parent) = full_path.parent() {
3637 fs::create_dir_all(parent)
3638 .with_context(|| format!("failed creating parent dir {}", parent.display()))?;
3639 }
3640 fs::write(&full_path, blob).with_context(|| {
3641 format!(
3642 "failed writing scrubbed protected file {}",
3643 full_path.display()
3644 )
3645 })?;
3646 }
3647
3648 Ok(())
3649}
3650
3651fn read_gitattributes_patterns(repo_root: &std::path::Path) -> Vec<String> {
3652 let path = repo_root.join(".gitattributes");
3653 let Ok(text) = fs::read_to_string(&path) else {
3654 return Vec::new();
3655 };
3656 let mut patterns = Vec::new();
3657 for line in text.lines() {
3658 let trimmed = line.trim();
3659 if trimmed.is_empty() || trimmed.starts_with('#') {
3660 continue;
3661 }
3662 if trimmed.contains("filter=git-sshripped") {
3663 if let Some(pattern) = trimmed.split_whitespace().next() {
3664 patterns.push(pattern.to_string());
3665 }
3666 } else if (trimmed.contains("!filter") || trimmed.contains("!diff"))
3667 && let Some(pattern) = trimmed.split_whitespace().next()
3668 {
3669 patterns.push(format!("!{pattern}"));
3670 }
3671 }
3672 patterns
3673}
3674
3675fn protected_tracked_files(repo_root: &std::path::Path) -> Result<Vec<String>> {
3676 let attr_patterns = read_gitattributes_patterns(repo_root);
3680 let positive_patterns: Vec<&str> = attr_patterns
3681 .iter()
3682 .filter(|p| !p.starts_with('!'))
3683 .map(String::as_str)
3684 .collect();
3685 if positive_patterns.is_empty() {
3686 return Ok(Vec::new());
3687 }
3688
3689 let pathspecs: Vec<String> = positive_patterns
3697 .iter()
3698 .map(|p| {
3699 if p.contains('/') {
3700 (*p).to_string()
3701 } else {
3702 format!("**/{p}")
3703 }
3704 })
3705 .collect();
3706
3707 let mut cmd = std::process::Command::new("git");
3708 cmd.current_dir(repo_root).args(["ls-files", "-z", "--"]);
3709 for pathspec in &pathspecs {
3710 cmd.arg(pathspec);
3711 }
3712 let ls_output = cmd.output().context("failed to run git ls-files")?;
3713 if !ls_output.status.success() {
3714 anyhow::bail!("git ls-files failed");
3715 }
3716
3717 let mut files = Vec::new();
3718 for raw in ls_output.stdout.split(|b| *b == 0) {
3719 if raw.is_empty() {
3720 continue;
3721 }
3722 let path = String::from_utf8(raw.to_vec()).context("non-utf8 path from git ls-files")?;
3723 files.push(path);
3724 }
3725
3726 if files.is_empty() {
3727 return Ok(Vec::new());
3728 }
3729
3730 let mut child = std::process::Command::new("git")
3731 .current_dir(repo_root)
3732 .args(["check-attr", "-z", "--stdin", "filter"])
3733 .stdin(std::process::Stdio::piped())
3734 .stdout(std::process::Stdio::piped())
3735 .spawn()
3736 .context("failed to spawn git check-attr")?;
3737
3738 {
3739 let stdin = child
3740 .stdin
3741 .as_mut()
3742 .context("failed to open check-attr stdin")?;
3743 for path in &files {
3744 std::io::Write::write_all(stdin, path.as_bytes())?;
3745 std::io::Write::write_all(stdin, b"\0")?;
3746 }
3747 }
3748
3749 let output = child.wait_with_output().context("git check-attr failed")?;
3750 if !output.status.success() {
3751 anyhow::bail!("git check-attr exited non-zero");
3752 }
3753
3754 let mut protected = Vec::new();
3755 let fields: Vec<&[u8]> = output.stdout.split(|b| *b == 0).collect();
3756 let mut i = 0;
3757 while i + 2 < fields.len() {
3758 let path = std::str::from_utf8(fields[i]).context("non-utf8 path from check-attr")?;
3759 let value =
3760 std::str::from_utf8(fields[i + 2]).context("non-utf8 attr value from check-attr")?;
3761 if value == "git-sshripped" {
3762 protected.push(path.to_string());
3763 }
3764 i += 3;
3765 }
3766
3767 Ok(protected)
3768}
3769
3770fn verify_failures(repo_root: &std::path::Path) -> Result<Vec<String>> {
3771 let files = protected_tracked_files(repo_root)?;
3772 let mut failures = Vec::new();
3773 for path in files {
3774 let blob = git_show_index_path(repo_root, &path)?;
3775 if !blob.starts_with(&ENCRYPTED_MAGIC) {
3776 failures.push(path);
3777 }
3778 }
3779 Ok(failures)
3780}
3781
3782fn cmd_verify(strict: bool, json: bool) -> Result<()> {
3783 let repo_root = current_repo_root()?;
3784 let manifest = read_manifest(&repo_root)?;
3785 let strict = strict || manifest.strict_mode;
3786
3787 let failures = verify_failures(&repo_root)?;
3788
3789 if !failures.is_empty() {
3790 if json {
3791 println!(
3792 "{}",
3793 serde_json::to_string_pretty(&serde_json::json!({
3794 "ok": false,
3795 "failures": failures,
3796 }))?
3797 );
3798 } else {
3799 println!("verify: FAIL ({})", failures.len());
3800 for file in &failures {
3801 eprintln!("- plaintext protected file in index: {file}");
3802 }
3803 }
3804 anyhow::bail!("verify failed");
3805 }
3806
3807 if strict {
3808 let process_cfg = git_effective_config(&repo_root, "filter.git-sshripped.process")?;
3809 let required_cfg = git_effective_config(&repo_root, "filter.git-sshripped.required")?;
3810 if process_cfg.is_none() || required_cfg.as_deref() != Some("true") {
3811 anyhow::bail!(
3812 "strict verify failed: filter.git-sshripped.process and required=true must be configured"
3813 );
3814 }
3815 }
3816
3817 if json {
3818 println!(
3819 "{}",
3820 serde_json::to_string_pretty(&serde_json::json!({"ok": true}))?
3821 );
3822 } else {
3823 println!("verify: OK");
3824 }
3825
3826 Ok(())
3827}
3828
3829fn cmd_rewrap() -> Result<()> {
3830 let repo_root = current_repo_root()?;
3831 let common_dir = current_common_dir()?;
3832 let Some(key) = repo_key_from_session()? else {
3833 anyhow::bail!("repository is locked; run `git-sshripped unlock` first");
3834 };
3835 let wrapped = wrap_repo_key_for_all_recipients(&repo_root, &key)?;
3836 println!("rewrapped repository key for {} recipients", wrapped.len());
3837
3838 generate_missing_agent_wraps(&repo_root, &common_dir, &key);
3841
3842 Ok(())
3843}
3844
3845fn reencrypt_with_current_session(repo_root: &std::path::Path) -> Result<usize> {
3846 const CHUNK: usize = 100;
3847 if repo_key_from_session()?.is_none() {
3848 anyhow::bail!("repository is locked; run `git-sshripped unlock` first");
3849 }
3850
3851 let protected = protected_tracked_files(repo_root)?;
3852 if protected.is_empty() {
3853 return Ok(0);
3854 }
3855
3856 for chunk in protected.chunks(CHUNK) {
3857 git_add_paths(repo_root, chunk, true)?;
3858 }
3859
3860 Ok(protected.len())
3861}
3862
3863fn cmd_reencrypt() -> Result<()> {
3864 let repo_root = current_repo_root()?;
3865
3866 let refreshed = reencrypt_with_current_session(&repo_root)?;
3867 if refreshed == 0 {
3868 println!("reencrypt: no protected tracked files found");
3869 return Ok(());
3870 }
3871
3872 println!("reencrypt: refreshed {refreshed} protected files");
3873 Ok(())
3874}
3875
3876fn cmd_rotate_key(auto_reencrypt: bool) -> Result<()> {
3877 let repo_root = current_repo_root()?;
3878 let common_dir = current_common_dir()?;
3879 let Some(previous_key) = repo_key_from_session()? else {
3880 anyhow::bail!("repository is locked; run `git-sshripped unlock` first");
3881 };
3882 let previous_key_id = repo_key_id_from_bytes(&previous_key);
3883 let mut manifest = read_manifest(&repo_root)?;
3884
3885 if manifest.require_doctor_clean_for_rotate {
3886 let failures = collect_doctor_failures(&repo_root, &common_dir, &manifest)?;
3887 if !failures.is_empty() {
3888 anyhow::bail!(
3889 "rotate-key blocked by manifest policy require_doctor_clean_for_rotate=true; run `git-sshripped doctor` and fix: {}",
3890 failures.join("; ")
3891 );
3892 }
3893 }
3894 enforce_verify_clean_for_sensitive_actions(&repo_root, &manifest, "rotate-key")?;
3895
3896 let recipients = list_recipients(&repo_root)?;
3897 if recipients.is_empty() {
3898 anyhow::bail!("no recipients configured; cannot rotate repository key");
3899 }
3900
3901 let wrapped_snapshot = snapshot_wrapped_files(&repo_root)?;
3902
3903 let mut key = [0_u8; 32];
3904 rand::rng().fill_bytes(&mut key);
3905 let key_id = repo_key_id_from_bytes(&key);
3906 let wrapped = match wrap_repo_key_for_all_recipients(&repo_root, &key) {
3907 Ok(wrapped) => wrapped,
3908 Err(err) => {
3909 restore_wrapped_files(&repo_root, &wrapped_snapshot)?;
3910 anyhow::bail!(
3911 "rotate-key failed while wrapping new key; previous wrapped files restored: {err:#}"
3912 );
3913 }
3914 };
3915
3916 manifest.repo_key_id = Some(key_id.clone());
3917 if let Err(err) = write_manifest(&repo_root, &manifest) {
3918 restore_wrapped_files(&repo_root, &wrapped_snapshot)?;
3919 anyhow::bail!(
3920 "rotate-key failed while writing updated manifest; previous wrapped files restored: {err:#}"
3921 );
3922 }
3923
3924 write_unlock_session(&common_dir, &key, "rotated", Some(key_id))?;
3925
3926 println!(
3927 "rotate-key: generated new repository key and wrapped for {} recipients",
3928 wrapped.len()
3929 );
3930 if auto_reencrypt {
3931 match reencrypt_with_current_session(&repo_root) {
3932 Ok(count) => {
3933 println!("rotate-key: auto-reencrypt refreshed {count} protected files");
3934 }
3935 Err(err) => {
3936 let mut rollback_errors = Vec::new();
3937 if let Err(rollback_wrap_err) = restore_wrapped_files(&repo_root, &wrapped_snapshot)
3938 {
3939 rollback_errors.push(format!(
3940 "wrapped-file rollback failed: {rollback_wrap_err:#}"
3941 ));
3942 }
3943 if let Err(rollback_session_err) = write_unlock_session(
3944 &common_dir,
3945 &previous_key,
3946 "rollback",
3947 Some(previous_key_id.clone()),
3948 ) {
3949 rollback_errors
3950 .push(format!("session rollback failed: {rollback_session_err:#}"));
3951 }
3952 manifest.repo_key_id = Some(previous_key_id);
3953 if let Err(manifest_rollback_err) = write_manifest(&repo_root, &manifest) {
3954 rollback_errors.push(format!(
3955 "manifest rollback failed: {manifest_rollback_err:#}"
3956 ));
3957 }
3958 if rollback_errors.is_empty()
3959 && let Err(restore_err) = reencrypt_with_current_session(&repo_root)
3960 {
3961 rollback_errors.push(format!("reencrypt rollback failed: {restore_err:#}"));
3962 }
3963
3964 if rollback_errors.is_empty() {
3965 anyhow::bail!("rotate-key auto-reencrypt failed and was rolled back: {err:#}");
3966 }
3967
3968 anyhow::bail!(
3969 "rotate-key auto-reencrypt failed: {err:#}; rollback encountered issues: {}",
3970 rollback_errors.join("; ")
3971 );
3972 }
3973 }
3974 } else {
3975 println!("rotate-key: run `git-sshripped reencrypt` and commit to complete rotation");
3976 }
3977
3978 Ok(())
3979}
3980
3981fn repo_key_from_session_in(
3982 common_dir: &std::path::Path,
3983 manifest: Option<&RepositoryManifest>,
3984) -> Result<Option<Vec<u8>>> {
3985 let maybe_session = read_unlock_session(common_dir)?;
3986 let Some(session) = maybe_session else {
3987 return Ok(None);
3988 };
3989 let key = base64::engine::general_purpose::STANDARD_NO_PAD
3990 .decode(session.key_b64)
3991 .context("invalid session key encoding")?;
3992 if key.len() != 32 {
3993 anyhow::bail!("unlock session key length is {}, expected 32", key.len());
3994 }
3995
3996 if let Some(manifest) = manifest
3997 && let Some(expected) = &manifest.repo_key_id
3998 {
3999 let actual = repo_key_id_from_bytes(&key);
4000 if &actual != expected {
4001 anyhow::bail!(
4002 "unlock session key does not match this worktree manifest (expected repo_key_id {expected}, got {actual}); run `git-sshripped unlock`",
4003 );
4004 }
4005 }
4006
4007 Ok(Some(key))
4008}
4009
4010fn repo_key_from_session() -> Result<Option<Vec<u8>>> {
4011 let repo_root = current_repo_root()?;
4012 let manifest = read_manifest(&repo_root)?;
4013 let common_dir = current_common_dir()?;
4014 repo_key_from_session_in(&common_dir, Some(&manifest))
4015}
4016
4017fn git_effective_config(repo_root: &std::path::Path, key: &str) -> Result<Option<String>> {
4022 let output = std::process::Command::new("git")
4023 .current_dir(repo_root)
4024 .args(["config", "--get", key])
4025 .output()
4026 .with_context(|| format!("failed to run git config --get {key}"))?;
4027
4028 if !output.status.success() {
4029 return Ok(None);
4030 }
4031
4032 let value = String::from_utf8(output.stdout)
4033 .with_context(|| format!("git config value for {key} is not utf8"))?
4034 .trim()
4035 .to_string();
4036 Ok(Some(value))
4037}
4038
4039fn doctor_check_filters(
4040 repo_root: &std::path::Path,
4041 json: bool,
4042 failures: &mut Vec<String>,
4043) -> Result<()> {
4044 let process_cfg = git_effective_config(repo_root, "filter.git-sshripped.process")?;
4045 if process_cfg
4046 .as_ref()
4047 .is_some_and(|value| value.contains("filter-process"))
4048 {
4049 if !json {
4050 println!("check filter.process: PASS");
4051 }
4052 } else {
4053 if !json {
4054 println!("check filter.process: FAIL");
4055 }
4056 failures.push("filter.git-sshripped.process is missing or invalid".to_string());
4057 }
4058
4059 let required_cfg = git_effective_config(repo_root, "filter.git-sshripped.required")?;
4060 if required_cfg.as_deref() == Some("true") {
4061 if !json {
4062 println!("check filter.required: PASS");
4063 }
4064 } else {
4065 if !json {
4066 println!("check filter.required: FAIL");
4067 }
4068 failures.push("filter.git-sshripped.required should be true".to_string());
4069 }
4070
4071 let gitattributes = repo_root.join(".gitattributes");
4072 match fs::read_to_string(&gitattributes) {
4073 Ok(text) if text.contains("filter=git-sshripped") => {
4074 if !json {
4075 println!("check gitattributes wiring: PASS");
4076 }
4077 }
4078 Ok(_) => {
4079 if !json {
4080 println!("check gitattributes wiring: FAIL");
4081 }
4082 failures.push(".gitattributes has no filter=git-sshripped entries".to_string());
4083 }
4084 Err(err) => {
4085 if !json {
4086 println!("check gitattributes wiring: FAIL");
4087 }
4088 failures.push(format!("cannot read {}: {err}", gitattributes.display()));
4089 }
4090 }
4091 Ok(())
4092}
4093
4094fn doctor_check_recipients(
4095 repo_root: &std::path::Path,
4096 manifest: &RepositoryManifest,
4097 json: bool,
4098 failures: &mut Vec<String>,
4099) -> Result<Vec<RecipientKey>> {
4100 let recipients = list_recipients(repo_root)?;
4101 if recipients.is_empty() {
4102 if !json {
4103 println!("check recipients: FAIL");
4104 }
4105 failures.push("no recipients configured".to_string());
4106 } else {
4107 if !json {
4108 println!("check recipients: PASS ({})", recipients.len());
4109 }
4110 let allowed_types = allowed_key_types_set(manifest);
4111 for recipient in &recipients {
4112 if !allowed_types.contains(recipient.key_type.as_str()) {
4113 failures.push(format!(
4114 "recipient {} uses disallowed key type {}",
4115 recipient.fingerprint, recipient.key_type
4116 ));
4117 }
4118 }
4119 }
4120 if recipients.len() < manifest.min_recipients {
4121 failures.push(format!(
4122 "recipient count {} is below manifest min_recipients {}",
4123 recipients.len(),
4124 manifest.min_recipients
4125 ));
4126 }
4127 Ok(recipients)
4128}
4129
4130fn doctor_check_wrapped_keys(
4131 repo_root: &std::path::Path,
4132 recipients: &[RecipientKey],
4133 json: bool,
4134 failures: &mut Vec<String>,
4135) -> Result<()> {
4136 let wrapped_files = wrapped_key_files(repo_root)?;
4137 if wrapped_files.is_empty() {
4138 if !json {
4139 println!("check wrapped keys: FAIL");
4140 }
4141 failures.push("no wrapped keys found".to_string());
4142 } else if !json {
4143 println!("check wrapped keys: PASS ({})", wrapped_files.len());
4144 }
4145
4146 for recipient in recipients {
4147 let wrapped = wrapped_store_dir(repo_root).join(format!("{}.age", recipient.fingerprint));
4148 if !wrapped.exists() {
4149 failures.push(format!(
4150 "missing wrapped key for recipient {}",
4151 recipient.fingerprint
4152 ));
4153 }
4154 }
4155 Ok(())
4156}
4157
4158fn doctor_check_unlock_session(
4159 common_dir: &std::path::Path,
4160 manifest: &RepositoryManifest,
4161 json: bool,
4162 failures: &mut Vec<String>,
4163) -> Result<()> {
4164 let session = read_unlock_session(common_dir)?;
4165 if let Some(session) = session {
4166 let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
4167 .decode(session.key_b64)
4168 .context("unlock session key is invalid base64")?;
4169 if decoded.len() == 32 {
4170 if !json {
4171 println!("check unlock session: PASS (UNLOCKED)");
4172 }
4173 } else {
4174 if !json {
4175 println!("check unlock session: FAIL");
4176 }
4177 failures.push(format!(
4178 "unlock session key length is {}, expected 32",
4179 decoded.len()
4180 ));
4181 }
4182
4183 if let Some(expected) = &manifest.repo_key_id {
4184 let actual = repo_key_id_from_bytes(&decoded);
4185 if &actual != expected {
4186 failures.push(format!(
4187 "unlock session repo key mismatch: expected {expected}, got {actual}"
4188 ));
4189 }
4190 if session.repo_key_id.as_deref() != Some(expected.as_str()) {
4191 failures.push(format!(
4192 "unlock session metadata repo_key_id mismatch: expected {expected}, got {}",
4193 session.repo_key_id.as_deref().unwrap_or("missing")
4194 ));
4195 }
4196 }
4197 } else if !json {
4198 println!("check unlock session: PASS (LOCKED)");
4199 }
4200 Ok(())
4201}
4202
4203fn doctor_output_results(
4204 repo_root: &std::path::Path,
4205 common_dir: &std::path::Path,
4206 manifest: &RepositoryManifest,
4207 helper: Option<&(PathBuf, String)>,
4208 json: bool,
4209 failures: Vec<String>,
4210) -> Result<()> {
4211 if json {
4212 println!(
4213 "{}",
4214 serde_json::to_string_pretty(&serde_json::json!({
4215 "ok": failures.is_empty(),
4216 "repo": repo_root.display().to_string(),
4217 "common_dir": common_dir.display().to_string(),
4218 "algorithm": format!("{:?}", manifest.encryption_algorithm),
4219 "strict_mode": manifest.strict_mode,
4220 "repo_key_id": manifest.repo_key_id,
4221 "protected_patterns": read_gitattributes_patterns(repo_root),
4222 "min_recipients": manifest.min_recipients,
4223 "allowed_key_types": manifest.allowed_key_types,
4224 "require_doctor_clean_for_rotate": manifest.require_doctor_clean_for_rotate,
4225 "require_verify_strict_clean_for_rotate_revoke": manifest.require_verify_strict_clean_for_rotate_revoke,
4226 "max_source_staleness_hours": manifest.max_source_staleness_hours,
4227 "agent_helper_resolved": helper.as_ref().map(|(path, _)| path.display().to_string()),
4228 "agent_helper_source": helper.as_ref().map(|(_, source)| source.clone()),
4229 "failures": failures,
4230 }))?
4231 );
4232 } else {
4233 println!("doctor: algorithm {:?}", manifest.encryption_algorithm);
4234 println!("doctor: strict_mode {}", manifest.strict_mode);
4235 println!(
4236 "doctor: repo_key_id {}",
4237 manifest.repo_key_id.as_deref().unwrap_or("missing")
4238 );
4239 println!(
4240 "doctor: protected patterns {}",
4241 read_gitattributes_patterns(repo_root).join(", ")
4242 );
4243 println!("doctor: min_recipients {}", manifest.min_recipients);
4244 println!(
4245 "doctor: allowed_key_types {}",
4246 manifest.allowed_key_types.join(", ")
4247 );
4248 println!(
4249 "doctor: require_doctor_clean_for_rotate {}",
4250 manifest.require_doctor_clean_for_rotate
4251 );
4252 println!(
4253 "doctor: require_verify_strict_clean_for_rotate_revoke {}",
4254 manifest.require_verify_strict_clean_for_rotate_revoke
4255 );
4256 println!(
4257 "doctor: max_source_staleness_hours {}",
4258 manifest
4259 .max_source_staleness_hours
4260 .map_or_else(|| "none".to_string(), |v| v.to_string())
4261 );
4262 }
4263
4264 if failures.is_empty() {
4265 if !json {
4266 println!("doctor: OK");
4267 }
4268 return Ok(());
4269 }
4270
4271 if !json {
4272 println!("doctor: {} issue(s) found", failures.len());
4273 for failure in failures {
4274 eprintln!("- {failure}");
4275 }
4276 }
4277
4278 anyhow::bail!("doctor checks failed")
4279}
4280
4281fn cmd_doctor(json: bool) -> Result<()> {
4282 let mut failures = Vec::new();
4283
4284 let repo_root = current_repo_root()?;
4285 let common_dir = current_common_dir()?;
4286 if !json {
4287 println!("doctor: repo root {}", repo_root.display());
4288 println!("doctor: common dir {}", common_dir.display());
4289 }
4290
4291 let manifest = match read_manifest(&repo_root) {
4292 Ok(manifest) => {
4293 if !json {
4294 println!("check manifest: PASS");
4295 }
4296 manifest
4297 }
4298 Err(err) => {
4299 if !json {
4300 println!("check manifest: FAIL");
4301 }
4302 failures.push(format!("manifest unreadable: {err:#}"));
4303 RepositoryManifest::default()
4304 }
4305 };
4306
4307 if manifest.min_recipients == 0 {
4308 failures.push("manifest min_recipients must be at least 1".to_string());
4309 }
4310 if manifest.allowed_key_types.is_empty() {
4311 failures.push("manifest allowed_key_types cannot be empty".to_string());
4312 }
4313
4314 doctor_check_filters(&repo_root, json, &mut failures)?;
4315 let recipients = doctor_check_recipients(&repo_root, &manifest, json, &mut failures)?;
4316 doctor_check_wrapped_keys(&repo_root, &recipients, json, &mut failures)?;
4317
4318 let helper = resolve_agent_helper(&repo_root)?;
4319 if !json {
4320 match &helper {
4321 Some((path, source)) => {
4322 println!(
4323 "check agent helper: PASS ({} from {})",
4324 path.display(),
4325 source
4326 );
4327 }
4328 None => {
4329 println!("check agent helper: PASS (none detected)");
4330 }
4331 }
4332 }
4333
4334 doctor_check_unlock_session(&common_dir, &manifest, json, &mut failures)?;
4335 doctor_output_results(
4336 &repo_root,
4337 &common_dir,
4338 &manifest,
4339 helper.as_ref(),
4340 json,
4341 failures,
4342 )
4343}
4344
4345fn read_stdin_all() -> Result<Vec<u8>> {
4346 let mut input = Vec::new();
4347 std::io::stdin()
4348 .read_to_end(&mut input)
4349 .context("failed to read stdin")?;
4350 Ok(input)
4351}
4352
4353fn write_stdout_all(bytes: &[u8]) -> Result<()> {
4354 std::io::stdout()
4355 .write_all(bytes)
4356 .context("failed to write stdout")?;
4357 Ok(())
4358}
4359
4360fn cmd_clean(path: &str) -> Result<()> {
4361 let repo_root = current_repo_root()?;
4362 let manifest = read_manifest(&repo_root)?;
4363 let key = repo_key_from_session()?;
4364 let input = read_stdin_all()?;
4365 let output = clean(manifest.encryption_algorithm, key.as_deref(), path, &input)?;
4366 write_stdout_all(&output)
4367}
4368
4369fn cmd_smudge(path: &str) -> Result<()> {
4370 let key = repo_key_from_session()?;
4371 let input = read_stdin_all()?;
4372 let output = smudge(key.as_deref(), path, &input)?;
4373 write_stdout_all(&output)
4374}
4375
4376fn cmd_diff(path: &str, file: Option<&str>) -> Result<()> {
4377 let key = repo_key_from_session()?;
4378 let input = if let Some(file_path) = file {
4379 fs::read(file_path)
4380 .with_context(|| format!("failed to read diff input file {file_path}"))?
4381 } else {
4382 read_stdin_all()?
4383 };
4384 let output = diff(key.as_deref(), path, &input)?;
4385 write_stdout_all(&output)
4386}
4387
4388fn cmd_filter_process() -> Result<()> {
4389 let cwd = std::env::current_dir().context("failed to read current dir")?;
4390 let repo_root = resolve_repo_root_for_filter(&cwd);
4391 let common_dir = resolve_common_dir_for_filter(&cwd);
4392
4393 let stdin = std::io::stdin();
4394 let stdout = std::io::stdout();
4395 let mut reader = BufReader::new(stdin.lock());
4396 let mut writer = BufWriter::new(stdout.lock());
4397
4398 let mut pending_headers = handle_filter_handshake(&mut reader, &mut writer)?;
4399
4400 loop {
4401 let headers = if let Some(headers) = pending_headers.take() {
4402 headers
4403 } else {
4404 let Some(headers) = read_pkt_kv_list(&mut reader)? else {
4405 break;
4406 };
4407 headers
4408 };
4409
4410 if headers.is_empty() {
4411 continue;
4412 }
4413
4414 let command = headers
4415 .iter()
4416 .find_map(|(k, v)| (k == "command").then_some(v.clone()));
4417
4418 let Some(command) = command else {
4419 write_status_only(&mut writer, "error")?;
4420 continue;
4421 };
4422
4423 if command == "list_available_blobs" {
4424 write_empty_success_list_available_blobs(&mut writer)?;
4425 continue;
4426 }
4427
4428 let pathname = headers
4429 .iter()
4430 .find_map(|(k, v)| (k == "pathname").then_some(v.clone()))
4431 .unwrap_or_default();
4432
4433 let input = read_pkt_content(&mut reader)?;
4434
4435 let result = run_filter_command(&repo_root, &common_dir, &command, &pathname, &input);
4436 match result {
4437 Ok(output) => write_filter_success(&mut writer, &output)?,
4438 Err(err) => {
4439 eprintln!("Error: {err:#}");
4440 write_status_only(&mut writer, "error")?;
4441 }
4442 }
4443 }
4444
4445 drop(reader);
4446
4447 writer
4448 .flush()
4449 .context("failed to flush filter-process writer")?;
4450 Ok(())
4451}
4452
4453fn resolve_repo_root_for_filter(cwd: &std::path::Path) -> PathBuf {
4454 if let Some(work_tree) = std::env::var_os("GIT_WORK_TREE") {
4455 let p = PathBuf::from(work_tree);
4456 if p.is_absolute() {
4457 return p;
4458 }
4459 return cwd.join(p);
4460 }
4461
4462 if std::env::var_os("GIT_DIR").is_some() {
4463 return cwd.to_path_buf();
4464 }
4465
4466 cwd.to_path_buf()
4467}
4468
4469fn resolve_common_dir_for_filter(cwd: &std::path::Path) -> PathBuf {
4470 if let Some(common_dir) = std::env::var_os("GIT_COMMON_DIR") {
4471 let p = PathBuf::from(common_dir);
4472 if p.is_absolute() {
4473 return p;
4474 }
4475 if let Some(git_dir) = std::env::var_os("GIT_DIR") {
4476 let git_dir = PathBuf::from(git_dir);
4477 let git_dir_abs = if git_dir.is_absolute() {
4478 git_dir
4479 } else {
4480 cwd.join(git_dir)
4481 };
4482 let base = git_dir_abs
4483 .parent()
4484 .map_or_else(|| cwd.to_path_buf(), std::path::Path::to_path_buf);
4485 return base.join(p);
4486 }
4487 return cwd.join(p);
4488 }
4489
4490 if let Some(git_dir) = std::env::var_os("GIT_DIR") {
4491 let p = PathBuf::from(git_dir);
4492 let git_dir_abs = if p.is_absolute() { p } else { cwd.join(p) };
4493 if let Some(parent) = git_dir_abs.parent()
4494 && parent.file_name().is_some_and(|name| name == "worktrees")
4495 && let Some(common) = parent.parent()
4496 && common.join("HEAD").exists()
4497 {
4498 return common.to_path_buf();
4499 }
4500 return git_dir_abs;
4501 }
4502
4503 cwd.join(".git")
4504}
4505
4506fn resolve_repo_root_for_command(cwd: &std::path::Path) -> Result<PathBuf> {
4507 if std::env::var_os("GIT_DIR").is_some() {
4508 return Ok(resolve_repo_root_for_filter(cwd));
4509 }
4510 git_toplevel(cwd)
4511}
4512
4513fn resolve_common_dir_for_command(cwd: &std::path::Path) -> Result<PathBuf> {
4514 if std::env::var_os("GIT_DIR").is_some() {
4515 return Ok(resolve_common_dir_for_filter(cwd));
4516 }
4517 git_common_dir(cwd)
4518}
4519
4520#[derive(Debug)]
4521enum PktRead {
4522 Data(Vec<u8>),
4523 Flush,
4524 Eof,
4525}
4526
4527fn read_pkt_line(reader: &mut impl Read) -> Result<PktRead> {
4528 let mut len_buf = [0_u8; 4];
4529 match reader.read_exact(&mut len_buf) {
4530 Ok(()) => {}
4531 Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(PktRead::Eof),
4532 Err(err) => return Err(err).context("failed reading pkt-line length"),
4533 }
4534
4535 let len_str = std::str::from_utf8(&len_buf).context("pkt-line header is not utf8 hex")?;
4536 let len = usize::from_str_radix(len_str, 16).context("invalid pkt-line length")?;
4537
4538 if len == 0 {
4539 return Ok(PktRead::Flush);
4540 }
4541 if len < 4 {
4542 anyhow::bail!("invalid pkt-line length < 4");
4543 }
4544
4545 let data_len = len - 4;
4546 let mut data = vec![0_u8; data_len];
4547 reader
4548 .read_exact(&mut data)
4549 .context("failed reading pkt-line payload")?;
4550 Ok(PktRead::Data(data))
4551}
4552
4553fn write_pkt_data_line(writer: &mut impl Write, data: &[u8]) -> Result<()> {
4554 if data.len() > 65516 {
4555 anyhow::bail!("pkt-line payload too large");
4556 }
4557 let total = data.len() + 4;
4558 writer
4559 .write_all(format!("{total:04x}").as_bytes())
4560 .context("failed writing pkt-line length")?;
4561 writer
4562 .write_all(data)
4563 .context("failed writing pkt-line payload")?;
4564 Ok(())
4565}
4566
4567fn write_pkt_text_line(writer: &mut impl Write, text: &str) -> Result<()> {
4568 let mut line = String::with_capacity(text.len() + 1);
4569 line.push_str(text);
4570 line.push('\n');
4571 write_pkt_data_line(writer, line.as_bytes())
4572}
4573
4574fn write_pkt_flush(writer: &mut impl Write) -> Result<()> {
4575 writer
4576 .write_all(b"0000")
4577 .context("failed writing pkt-line flush")?;
4578 Ok(())
4579}
4580
4581fn read_pkt_kv_list(reader: &mut impl Read) -> Result<Option<Vec<(String, String)>>> {
4582 let first = read_pkt_line(reader)?;
4583 let mut items = Vec::new();
4584
4585 match first {
4586 PktRead::Eof => return Ok(None),
4587 PktRead::Flush => return Ok(Some(items)),
4588 PktRead::Data(data) => items.push(parse_kv(&data)?),
4589 }
4590
4591 loop {
4592 match read_pkt_line(reader)? {
4593 PktRead::Data(data) => items.push(parse_kv(&data)?),
4594 PktRead::Flush => return Ok(Some(items)),
4595 PktRead::Eof => anyhow::bail!("unexpected EOF while reading key/value pkt-list"),
4596 }
4597 }
4598}
4599
4600fn parse_kv(data: &[u8]) -> Result<(String, String)> {
4601 let text = std::str::from_utf8(data)
4602 .context("pkt key/value line is not utf8")?
4603 .trim_end_matches('\n');
4604 let mut split = text.splitn(2, '=');
4605 let key = split.next().unwrap_or_default();
4606 let value = split
4607 .next()
4608 .ok_or_else(|| anyhow::anyhow!("pkt key/value line missing '='"))?;
4609 Ok((key.to_string(), value.to_string()))
4610}
4611
4612fn read_pkt_content(reader: &mut impl Read) -> Result<Vec<u8>> {
4613 let mut content = Vec::new();
4614 loop {
4615 match read_pkt_line(reader)? {
4616 PktRead::Data(data) => content.extend_from_slice(&data),
4617 PktRead::Flush => return Ok(content),
4618 PktRead::Eof => anyhow::bail!("unexpected EOF while reading pkt content"),
4619 }
4620 }
4621}
4622
4623fn write_pkt_content(writer: &mut impl Write, content: &[u8]) -> Result<()> {
4624 const CHUNK: usize = 65516;
4625 for chunk in content.chunks(CHUNK) {
4626 write_pkt_data_line(writer, chunk)?;
4627 }
4628 write_pkt_flush(writer)
4629}
4630
4631fn handle_filter_handshake(
4632 reader: &mut impl Read,
4633 writer: &mut impl Write,
4634) -> Result<Option<Vec<(String, String)>>> {
4635 let hello = read_pkt_kv_or_literal_list(reader)?;
4636 let has_client = hello.iter().any(|s| s == "git-filter-client");
4637 let has_v2 = hello.iter().any(|s| s == "version=2");
4638
4639 if !has_client || !has_v2 {
4640 anyhow::bail!("unsupported filter-process handshake");
4641 }
4642
4643 write_pkt_text_line(writer, "git-filter-server")?;
4644 write_pkt_text_line(writer, "version=2")?;
4645 write_pkt_flush(writer)?;
4646 writer
4647 .flush()
4648 .context("failed flushing version negotiation response")?;
4649
4650 let mut client_capabilities: Vec<String> = hello
4651 .iter()
4652 .filter(|line| line.starts_with("capability="))
4653 .cloned()
4654 .collect();
4655 let mut pending_headers: Option<Vec<(String, String)>> = None;
4656 let mut has_capability_exchange = !client_capabilities.is_empty();
4657
4658 if client_capabilities.is_empty() {
4659 let next_list = read_pkt_kv_or_literal_list(reader)?;
4660 if next_list.iter().any(|line| line.starts_with("capability=")) {
4661 client_capabilities = next_list;
4662 has_capability_exchange = true;
4663 } else if !next_list.is_empty() {
4664 let mut parsed = Vec::new();
4665 for line in next_list {
4666 parsed.push(parse_kv(line.as_bytes())?);
4667 }
4668 pending_headers = Some(parsed);
4669 }
4670 }
4671
4672 let supports_clean =
4673 client_capabilities.iter().any(|s| s == "capability=clean") || pending_headers.is_some();
4674 let supports_smudge =
4675 client_capabilities.iter().any(|s| s == "capability=smudge") || pending_headers.is_some();
4676
4677 if !supports_clean || !supports_smudge {
4678 anyhow::bail!("git filter client did not advertise clean+smudge capabilities");
4679 }
4680
4681 if has_capability_exchange {
4682 write_pkt_text_line(writer, "capability=clean")?;
4683 write_pkt_text_line(writer, "capability=smudge")?;
4684 write_pkt_flush(writer)?;
4685 }
4686 writer
4687 .flush()
4688 .context("failed flushing handshake response")?;
4689 Ok(pending_headers)
4690}
4691
4692fn read_pkt_kv_or_literal_list(reader: &mut impl Read) -> Result<Vec<String>> {
4693 let mut out = Vec::new();
4694 loop {
4695 match read_pkt_line(reader)? {
4696 PktRead::Data(data) => {
4697 let text = String::from_utf8(data)
4698 .context("handshake packet not utf8")?
4699 .trim_end_matches('\n')
4700 .to_string();
4701 out.push(text);
4702 }
4703 PktRead::Flush => return Ok(out),
4704 PktRead::Eof => anyhow::bail!("unexpected EOF during handshake"),
4705 }
4706 }
4707}
4708
4709fn run_filter_command(
4710 repo_root: &std::path::Path,
4711 common_dir: &std::path::Path,
4712 command: &str,
4713 pathname: &str,
4714 input: &[u8],
4715) -> Result<Vec<u8>> {
4716 let manifest = read_manifest(repo_root)?;
4717 let key = repo_key_from_session_in(common_dir, Some(&manifest))?;
4718
4719 match command {
4720 "clean" => clean(
4721 manifest.encryption_algorithm,
4722 key.as_deref(),
4723 pathname,
4724 input,
4725 ),
4726 "smudge" => smudge(key.as_deref(), pathname, input),
4727 _ => anyhow::bail!("unsupported filter command: {command}"),
4728 }
4729}
4730
4731fn write_status_only(writer: &mut impl Write, status: &str) -> Result<()> {
4732 write_pkt_text_line(writer, &format!("status={status}"))?;
4733 write_pkt_flush(writer)?;
4734 writer
4735 .flush()
4736 .context("failed flushing status-only response")?;
4737 Ok(())
4738}
4739
4740fn write_filter_success(writer: &mut impl Write, content: &[u8]) -> Result<()> {
4741 write_pkt_text_line(writer, "status=success")?;
4742 write_pkt_flush(writer)?;
4743 write_pkt_content(writer, content)?;
4744 write_pkt_flush(writer)?;
4745 writer.flush().context("failed flushing success response")?;
4746 Ok(())
4747}
4748
4749fn write_empty_success_list_available_blobs(writer: &mut impl Write) -> Result<()> {
4750 write_pkt_flush(writer)?;
4751 write_pkt_text_line(writer, "status=success")?;
4752 write_pkt_flush(writer)?;
4753 writer
4754 .flush()
4755 .context("failed flushing list_available_blobs response")?;
4756 Ok(())
4757}
4758
4759fn ssh_key_prefix(key_line: &str) -> String {
4762 let parts: Vec<&str> = key_line.split_whitespace().collect();
4763 if parts.len() >= 2 {
4764 format!("{} {}", parts[0], parts[1])
4765 } else {
4766 key_line.trim().to_string()
4767 }
4768}
4769
4770fn local_public_key_contents() -> Vec<String> {
4773 discover_ssh_key_files()
4774 .into_iter()
4775 .filter_map(|(_private, pub_path)| std::fs::read_to_string(&pub_path).ok())
4776 .flat_map(|contents| {
4777 contents
4778 .lines()
4779 .filter(|l| !l.trim().is_empty())
4780 .map(String::from)
4781 .collect::<Vec<_>>()
4782 })
4783 .collect()
4784}
4785
4786#[cfg(test)]
4787mod tests {
4788 use super::*;
4789 use proptest::prelude::*;
4790 use std::io::Cursor;
4791
4792 #[test]
4793 fn pkt_text_roundtrip_includes_newline_and_trims_on_read() {
4794 let mut buf = Vec::new();
4795 write_pkt_text_line(&mut buf, "command=clean").expect("write should succeed");
4796 write_pkt_flush(&mut buf).expect("flush should succeed");
4797
4798 let mut cursor = Cursor::new(buf);
4799 let list = read_pkt_kv_or_literal_list(&mut cursor).expect("read should succeed");
4800 assert_eq!(list, vec!["command=clean".to_string()]);
4801 }
4802
4803 #[test]
4804 fn parse_kv_trims_lf_and_parses_value_with_equals() {
4805 let (key, value) = parse_kv(b"pathname=secrets/a=b.env\n").expect("kv parse should work");
4806 assert_eq!(key, "pathname");
4807 assert_eq!(value, "secrets/a=b.env");
4808 }
4809
4810 #[test]
4811 fn handshake_with_capability_exchange_succeeds() {
4812 let mut input = Vec::new();
4813 write_pkt_text_line(&mut input, "git-filter-client").expect("write should succeed");
4814 write_pkt_text_line(&mut input, "version=2").expect("write should succeed");
4815 write_pkt_flush(&mut input).expect("flush should succeed");
4816 write_pkt_text_line(&mut input, "capability=clean").expect("write should succeed");
4817 write_pkt_text_line(&mut input, "capability=smudge").expect("write should succeed");
4818 write_pkt_flush(&mut input).expect("flush should succeed");
4819
4820 let mut reader = Cursor::new(input);
4821 let mut output = Vec::new();
4822 let pending =
4823 handle_filter_handshake(&mut reader, &mut output).expect("handshake should work");
4824 assert!(pending.is_none());
4825 assert!(!output.is_empty());
4826 }
4827
4828 #[test]
4829 fn gitattributes_migration_rewrites_git_crypt_lines() {
4830 let input = "# comment\nsecret.env filter=git-crypt diff=git-crypt\nsecret.env filter=git-crypt diff=git-crypt\nplain.txt text\n";
4831 let plan = build_gitattributes_migration_plan(input);
4832 assert_eq!(plan.legacy_lines_found, 2);
4833 assert_eq!(plan.legacy_lines_replaced, 2);
4834 assert_eq!(plan.duplicate_lines_removed, 1);
4835 assert_eq!(plan.rewritable_lines, 2);
4836 assert!(plan.ambiguous_lines.is_empty());
4837 assert!(
4838 plan.rewritten_text
4839 .contains("secret.env filter=git-sshripped diff=git-sshripped")
4840 );
4841 assert!(!plan.rewritten_text.contains("filter=git-crypt"));
4842 assert_eq!(plan.patterns, vec!["secret.env".to_string()]);
4843 }
4844
4845 #[test]
4846 fn gitattributes_migration_classifies_ambiguous_lines() {
4847 let input = "secrets/** filter=git-crypt diff=git-crypt merge=binary\n";
4848 let plan = build_gitattributes_migration_plan(input);
4849 assert_eq!(plan.legacy_lines_found, 1);
4850 assert_eq!(plan.rewritable_lines, 0);
4851 assert_eq!(plan.ambiguous_lines.len(), 1);
4852 }
4853
4854 #[test]
4855 fn gitattributes_migration_collects_negation_lines() {
4856 let input = "hosts/** filter=git-crypt diff=git-crypt\nhosts/meta.nix !filter !diff\n";
4857 let plan = build_gitattributes_migration_plan(input);
4858 assert_eq!(plan.legacy_lines_found, 1);
4859 assert_eq!(plan.legacy_lines_replaced, 1);
4860 assert!(plan.patterns.contains(&"hosts/**".to_string()));
4861 assert!(plan.patterns.contains(&"!hosts/meta.nix".to_string()));
4862 assert_eq!(plan.patterns.len(), 2);
4863 assert!(
4864 plan.rewritten_text
4865 .contains("hosts/** filter=git-sshripped diff=git-sshripped")
4866 );
4867 assert!(plan.rewritten_text.contains("hosts/meta.nix !filter !diff"));
4868 }
4869
4870 #[test]
4871 fn gitattributes_migration_handles_only_diff_negation() {
4872 let input = "data/** filter=git-crypt diff=git-crypt\ndata/public.txt !diff\n";
4873 let plan = build_gitattributes_migration_plan(input);
4874 assert!(plan.patterns.contains(&"!data/public.txt".to_string()));
4875 }
4876
4877 #[test]
4878 fn github_refresh_error_classification_works() {
4879 let auth = anyhow::anyhow!("request failed with status 401");
4880 let perm = anyhow::anyhow!("request failed with status 403");
4881 let rate = anyhow::anyhow!("request failed with status 429");
4882 assert_eq!(classify_github_refresh_error(&auth), "auth_missing");
4883 assert_eq!(classify_github_refresh_error(&perm), "permission_denied");
4884 assert_eq!(classify_github_refresh_error(&rate), "rate_limited");
4885 }
4886
4887 proptest! {
4888 #[test]
4889 fn parse_kv_accepts_well_formed_lines(
4890 key in "[a-z]{1,16}",
4891 value in "[a-zA-Z0-9_./=-]{0,64}"
4892 ) {
4893 let line = format!("{key}={value}\n");
4894 let (parsed_key, parsed_value) = parse_kv(line.as_bytes()).expect("parse should work");
4895 prop_assert_eq!(parsed_key, key);
4896 prop_assert_eq!(parsed_value, value);
4897 }
4898
4899 #[test]
4900 fn read_pkt_line_rejects_invalid_short_lengths(raw in 1_u8..4_u8) {
4901 let len = format!("{raw:04x}");
4902 let mut cursor = Cursor::new(len.into_bytes());
4903 let result = read_pkt_line(&mut cursor);
4904 prop_assert!(result.is_err());
4905 }
4906 }
4907}