Skip to main content

git_sshripped_cli/
lib.rs

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