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