Skip to main content

auths_cli/commands/
git.rs

1//! Git integration commands for Auths.
2//!
3//! Provides commands for managing Git allowed_signers files based on
4//! Auths device authorizations.
5
6use anyhow::{Context, Result, bail};
7use auths_id::storage::attestation::AttestationSource;
8use auths_storage::git::RegistryAttestationStorage;
9use clap::{Parser, Subcommand};
10use ssh_key::PublicKey as SshPublicKey;
11use ssh_key::public::Ed25519PublicKey;
12use std::fs;
13#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15use std::path::PathBuf;
16
17#[derive(Parser, Debug, Clone)]
18#[command(about = "Git integration commands.")]
19pub struct GitCommand {
20    #[command(subcommand)]
21    pub command: GitSubcommand,
22
23    #[command(flatten)]
24    pub overrides: crate::commands::registry_overrides::RegistryOverrides,
25}
26
27#[derive(Subcommand, Debug, Clone)]
28pub enum GitSubcommand {
29    /// Generate allowed_signers file from Auths device authorizations.
30    ///
31    /// Scans the identity repository for authorized devices and outputs
32    /// an allowed_signers file compatible with Git's ssh.allowedSignersFile.
33    #[command(name = "allowed-signers")]
34    AllowedSigners(AllowedSignersCommand),
35
36    /// Install Git hooks for automatic allowed_signers regeneration.
37    ///
38    /// Installs a post-merge hook that regenerates the allowed_signers file
39    /// when identity refs change after a git pull/merge.
40    #[command(name = "install-hooks")]
41    InstallHooks(InstallHooksCommand),
42}
43
44#[derive(Parser, Debug, Clone)]
45pub struct AllowedSignersCommand {
46    /// Path to the Auths identity repository.
47    #[arg(long, default_value = "~/.auths")]
48    pub repo: PathBuf,
49
50    /// Output file path. If not specified, outputs to stdout.
51    // Named `output_file` rather than `output` because the top-level `Cli` struct
52    // has a global `--output` (OutputFormat) argument; clap panics with "Mismatch
53    // between definition and access of `output`" when both use the same field name.
54    #[arg(long = "output", short = 'o')]
55    pub output_file: Option<PathBuf>,
56}
57
58#[derive(Parser, Debug, Clone)]
59pub struct InstallHooksCommand {
60    /// Path to the Git repository where hooks should be installed.
61    /// Defaults to the current directory.
62    #[arg(long, default_value = ".")]
63    pub repo: PathBuf,
64
65    /// Path to the Auths identity repository.
66    #[arg(long, default_value = "~/.auths")]
67    pub auths_repo: PathBuf,
68
69    /// Path where allowed_signers file should be written.
70    #[arg(long, default_value = ".auths/allowed_signers")]
71    pub allowed_signers_path: PathBuf,
72
73    /// Overwrite existing hook without prompting.
74    #[arg(long)]
75    pub force: bool,
76}
77
78/// Handle git subcommand.
79pub fn handle_git(
80    cmd: GitCommand,
81    repo_override: Option<PathBuf>,
82    attestation_prefix_override: Option<String>,
83    attestation_blob_name_override: Option<String>,
84) -> Result<()> {
85    match cmd.command {
86        GitSubcommand::AllowedSigners(subcmd) => handle_allowed_signers(
87            subcmd,
88            repo_override,
89            attestation_prefix_override,
90            attestation_blob_name_override,
91        ),
92        GitSubcommand::InstallHooks(subcmd) => handle_install_hooks(subcmd, repo_override),
93    }
94}
95
96fn handle_install_hooks(
97    cmd: InstallHooksCommand,
98    auths_repo_override: Option<PathBuf>,
99) -> Result<()> {
100    // Find the .git directory
101    let git_dir = find_git_dir(&cmd.repo)?;
102    let hooks_dir = git_dir.join("hooks");
103
104    // Create hooks directory if it doesn't exist
105    if !hooks_dir.exists() {
106        fs::create_dir_all(&hooks_dir)
107            .with_context(|| format!("Failed to create hooks directory: {:?}", hooks_dir))?;
108    }
109
110    let post_merge_path = hooks_dir.join("post-merge");
111
112    // Check if hook already exists
113    if post_merge_path.exists() && !cmd.force {
114        // Read existing content to check if it's an Auths hook
115        let existing = fs::read_to_string(&post_merge_path)
116            .with_context(|| format!("Failed to read existing hook: {:?}", post_merge_path))?;
117
118        if existing.contains("auths git allowed-signers") {
119            println!(
120                "Auths post-merge hook already installed at {:?}",
121                post_merge_path
122            );
123            println!("Use --force to overwrite.");
124            return Ok(());
125        } else {
126            bail!(
127                "A post-merge hook already exists at {:?}\n\
128                 It was not created by Auths. Use --force to overwrite, or manually \n\
129                 add the following to your existing hook:\n\n\
130                 auths git allowed-signers --output {}",
131                post_merge_path,
132                cmd.allowed_signers_path.display()
133            );
134        }
135    }
136
137    // Resolve auths repo path
138    let auths_repo = if let Some(override_path) = auths_repo_override {
139        override_path
140    } else {
141        expand_tilde(&cmd.auths_repo)?
142    };
143
144    // Generate hook script
145    let hook_script = generate_post_merge_hook(&auths_repo, &cmd.allowed_signers_path);
146
147    // Write hook
148    fs::write(&post_merge_path, &hook_script)
149        .with_context(|| format!("Failed to write hook: {:?}", post_merge_path))?;
150
151    // Make executable (chmod 755) - Unix only
152    #[cfg(unix)]
153    {
154        let mut perms = fs::metadata(&post_merge_path)?.permissions();
155        perms.set_mode(0o755);
156        fs::set_permissions(&post_merge_path, perms)
157            .with_context(|| format!("Failed to set hook permissions: {:?}", post_merge_path))?;
158    }
159
160    println!("Installed post-merge hook at {:?}", post_merge_path);
161    println!(
162        "The hook will regenerate {:?} after each merge/pull.",
163        cmd.allowed_signers_path
164    );
165
166    // Create the .auths directory if needed
167    if let Some(parent) = cmd.allowed_signers_path.parent()
168        && !parent.as_os_str().is_empty()
169        && !parent.exists()
170    {
171        fs::create_dir_all(parent)
172            .with_context(|| format!("Failed to create directory: {:?}", parent))?;
173        println!("Created directory {:?}", parent);
174    }
175
176    // Generate initial allowed_signers file
177    println!("\nGenerating initial allowed_signers file...");
178    let storage = RegistryAttestationStorage::new(&auths_repo);
179
180    match storage.load_all_attestations() {
181        Ok(attestations) => {
182            let mut entries: Vec<String> = Vec::new();
183            for att in attestations {
184                if att.is_revoked() {
185                    continue;
186                }
187                let principal = get_principal(&att);
188                if let Ok(ssh_key) = public_key_to_ssh(att.device_public_key.as_bytes()) {
189                    entries.push(format!("{} namespaces=\"git\" {}", principal, ssh_key));
190                }
191            }
192            entries.sort();
193            entries.dedup();
194
195            let output = if entries.is_empty() {
196                String::new()
197            } else {
198                format!("{}\n", entries.join("\n"))
199            };
200
201            fs::write(&cmd.allowed_signers_path, &output)
202                .with_context(|| format!("Failed to write {:?}", cmd.allowed_signers_path))?;
203
204            println!(
205                "Wrote {} entries to {:?}",
206                entries.len(),
207                cmd.allowed_signers_path
208            );
209        }
210        Err(e) => {
211            eprintln!("Warning: Could not generate initial allowed_signers: {}", e);
212            eprintln!("You may need to run 'auths git allowed-signers' manually.");
213        }
214    }
215
216    Ok(())
217}
218
219/// Find the .git directory for a repository path.
220fn find_git_dir(repo_path: &std::path::Path) -> Result<PathBuf> {
221    let repo_path = if repo_path.to_string_lossy() == "." {
222        std::env::current_dir().context("Failed to get current directory")?
223    } else {
224        repo_path.to_path_buf()
225    };
226
227    // Check for .git directory
228    let git_dir = repo_path.join(".git");
229    if git_dir.is_dir() {
230        return Ok(git_dir);
231    }
232
233    // Check if .git is a file (worktree or submodule)
234    if git_dir.is_file() {
235        let content = fs::read_to_string(&git_dir)
236            .with_context(|| format!("Failed to read {:?}", git_dir))?;
237
238        // Format: "gitdir: <path>"
239        if let Some(path) = content.strip_prefix("gitdir: ") {
240            let linked_path = PathBuf::from(path.trim());
241            if linked_path.is_absolute() {
242                return Ok(linked_path);
243            } else {
244                return Ok(repo_path.join(linked_path));
245            }
246        }
247    }
248
249    // Check if we're inside a git directory
250    if repo_path.join("HEAD").exists() && repo_path.join("config").exists() {
251        return Ok(repo_path);
252    }
253
254    bail!(
255        "Not a git repository: {:?}\n\
256         Could not find .git directory.",
257        repo_path
258    );
259}
260
261/// Generate the post-merge hook script.
262fn generate_post_merge_hook(
263    auths_repo: &std::path::Path,
264    allowed_signers_path: &std::path::Path,
265) -> String {
266    format!(
267        r#"#!/bin/bash
268# Auto-generated by auths git install-hooks
269# Regenerates allowed_signers file after merge/pull
270
271# Run auths to regenerate allowed_signers
272auths git allowed-signers --repo "{}" --output "{}"
273"#,
274        auths_repo.display(),
275        allowed_signers_path.display()
276    )
277}
278
279fn handle_allowed_signers(
280    cmd: AllowedSignersCommand,
281    repo_override: Option<PathBuf>,
282    _attestation_prefix_override: Option<String>,
283    _attestation_blob_name_override: Option<String>,
284) -> Result<()> {
285    // Resolve repository path
286    let repo_path = if let Some(override_path) = repo_override {
287        override_path
288    } else {
289        expand_tilde(&cmd.repo)?
290    };
291
292    // Note: Layout config overrides are deprecated with registry backend.
293    // The registry uses a fixed path structure under refs/auths/registry.
294
295    // Create attestation storage
296    let storage = RegistryAttestationStorage::new(&repo_path);
297
298    // Load all attestations
299    let attestations = storage
300        .load_all_attestations()
301        .context("Failed to load attestations from repository")?;
302
303    // Generate allowed_signers entries
304    let mut entries: Vec<String> = Vec::new();
305
306    for att in attestations {
307        // Skip revoked attestations
308        if att.is_revoked() {
309            continue;
310        }
311
312        // Get principal (email) from payload or generate from DID
313        let principal = get_principal(&att);
314
315        // Convert device public key to SSH format
316        let ssh_key = match public_key_to_ssh(att.device_public_key.as_bytes()) {
317            Ok(key) => key,
318            Err(e) => {
319                eprintln!("Warning: skipping device {} - {}", att.subject, e);
320                continue;
321            }
322        };
323
324        // Format: principal namespaces="git" keytype key
325        let entry = format!("{} namespaces=\"git\" {}", principal, ssh_key);
326        entries.push(entry);
327    }
328
329    // Sort for deterministic output
330    entries.sort();
331    entries.dedup();
332
333    // Output
334    let output = entries.join("\n");
335    let output = if output.is_empty() {
336        output
337    } else {
338        format!("{}\n", output)
339    };
340
341    if let Some(output_path) = cmd.output_file {
342        fs::write(&output_path, &output)
343            .with_context(|| format!("Failed to write to {:?}", output_path))?;
344        eprintln!("Wrote {} entries to {:?}", entries.len(), output_path);
345    } else {
346        print!("{}", output);
347    }
348
349    Ok(())
350}
351
352/// Extract principal (email) from attestation payload, or generate from DID.
353pub(crate) fn get_principal(att: &auths_verifier::core::Attestation) -> String {
354    // Check for email in payload
355    if let Some(ref payload) = att.payload
356        && let Some(email) = payload.get("email").and_then(|v| v.as_str())
357        && !email.is_empty()
358    {
359        return email.to_string();
360    }
361
362    // Fallback: generate from device DID
363    // did:key:z6Mk... -> z6Mk...@auths.local
364    let did_str = att.subject.to_string();
365    let local_part = did_str.strip_prefix("did:key:").unwrap_or(&did_str);
366
367    format!("{}@auths.local", local_part)
368}
369
370/// Convert raw Ed25519 public key bytes to SSH public key string.
371pub(crate) fn public_key_to_ssh(public_key_bytes: &[u8]) -> Result<String> {
372    if public_key_bytes.len() != 32 {
373        anyhow::bail!(
374            "Invalid Ed25519 public key length: expected 32, got {}",
375            public_key_bytes.len()
376        );
377    }
378
379    // Create Ed25519PublicKey from raw bytes
380    let ed25519_pk = Ed25519PublicKey::try_from(public_key_bytes)
381        .context("Failed to parse Ed25519 public key")?;
382
383    // Wrap in SshPublicKey
384    let ssh_pk = SshPublicKey::from(ed25519_pk);
385
386    // Format as OpenSSH string (e.g., "ssh-ed25519 AAAA...")
387    ssh_pk
388        .to_openssh()
389        .context("Failed to format SSH public key")
390}
391
392/// Expand ~ to home directory.
393fn expand_tilde(path: &std::path::Path) -> Result<PathBuf> {
394    let path_str = path.to_string_lossy();
395    if path_str.starts_with("~/") || path_str == "~" {
396        let home = dirs::home_dir().context("Failed to determine home directory")?;
397        if path_str == "~" {
398            Ok(home)
399        } else {
400            Ok(home.join(&path_str[2..]))
401        }
402    } else {
403        Ok(path.to_path_buf())
404    }
405}
406
407use crate::commands::executable::ExecutableCommand;
408use crate::config::CliConfig;
409
410impl ExecutableCommand for GitCommand {
411    fn execute(&self, ctx: &CliConfig) -> Result<()> {
412        handle_git(
413            self.clone(),
414            ctx.repo_path.clone(),
415            self.overrides.attestation_prefix.clone(),
416            self.overrides.attestation_blob.clone(),
417        )
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use tempfile::TempDir;
425
426    #[test]
427    fn test_allowed_signers_output_flag_parses() {
428        // Regression: `AllowedSignersCommand.output` used to collide with the global
429        // `Cli.output: OutputFormat` argument, causing clap to panic with
430        // "Mismatch between definition and access of `output`".
431        // The field is now named `output_file` with `long = "output"`.
432        let cmd = AllowedSignersCommand::try_parse_from([
433            "allowed-signers",
434            "--output",
435            "/tmp/allowed_signers",
436        ])
437        .expect("--output flag must parse without panic");
438        assert_eq!(cmd.output_file, Some(PathBuf::from("/tmp/allowed_signers")));
439    }
440
441    #[test]
442    fn test_allowed_signers_no_output_defaults_to_none() {
443        let cmd = AllowedSignersCommand::try_parse_from(["allowed-signers"])
444            .expect("allowed-signers with no args must parse");
445        assert!(cmd.output_file.is_none());
446    }
447
448    #[test]
449    fn test_public_key_to_ssh() {
450        // Test with a known Ed25519 public key
451        let pk_bytes: [u8; 32] = [
452            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
453            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
454            0x1d, 0x1e, 0x1f, 0x20,
455        ];
456
457        let result = public_key_to_ssh(&pk_bytes);
458        assert!(result.is_ok(), "Failed: {:?}", result.err());
459
460        let ssh_key = result.unwrap();
461        assert!(ssh_key.starts_with("ssh-ed25519 "), "Got: {}", ssh_key);
462    }
463
464    #[test]
465    fn test_public_key_to_ssh_invalid_length() {
466        let pk_bytes = vec![0u8; 16]; // Too short
467        let result = public_key_to_ssh(&pk_bytes);
468        assert!(result.is_err());
469    }
470
471    #[test]
472    fn test_expand_tilde() {
473        let path = PathBuf::from("~/.auths");
474        let result = expand_tilde(&path);
475        assert!(result.is_ok());
476        let expanded = result.unwrap();
477        assert!(!expanded.to_string_lossy().contains("~"));
478    }
479
480    #[test]
481    fn test_find_git_dir() {
482        let temp = TempDir::new().unwrap();
483        let git_dir = temp.path().join(".git");
484        fs::create_dir(&git_dir).unwrap();
485
486        let result = find_git_dir(temp.path());
487        assert!(result.is_ok());
488        assert_eq!(result.unwrap(), git_dir);
489    }
490
491    #[test]
492    fn test_find_git_dir_not_repo() {
493        let temp = TempDir::new().unwrap();
494        let result = find_git_dir(temp.path());
495        assert!(result.is_err());
496    }
497
498    #[test]
499    fn test_generate_post_merge_hook() {
500        let auths_repo = PathBuf::from("/home/user/.auths");
501        let allowed_signers = PathBuf::from(".auths/allowed_signers");
502
503        let hook = generate_post_merge_hook(&auths_repo, &allowed_signers);
504
505        assert!(hook.starts_with("#!/bin/bash"));
506        assert!(hook.contains("auths git allowed-signers"));
507        assert!(hook.contains("/home/user/.auths"));
508        assert!(hook.contains(".auths/allowed_signers"));
509    }
510}