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