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