Skip to main content

git_sshripped_cli/
lib.rs

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