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_sdk::workflows::git_integration::{
8    format_allowed_signers_file, generate_allowed_signers,
9};
10use auths_storage::git::RegistryAttestationStorage;
11use clap::{Parser, Subcommand};
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14use std::path::PathBuf;
15use std::{fs, path::Path};
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    #[arg(long = "output", short = 'o')]
52    pub output_file: Option<PathBuf>,
53}
54
55#[derive(Parser, Debug, Clone)]
56pub struct InstallHooksCommand {
57    /// Path to the Git repository where hooks should be installed.
58    /// Defaults to the current directory.
59    #[arg(long, default_value = ".")]
60    pub repo: PathBuf,
61
62    /// Path to the Auths identity repository.
63    #[arg(long, default_value = "~/.auths")]
64    pub auths_repo: PathBuf,
65
66    /// Path where allowed_signers file should be written.
67    #[arg(long, default_value = ".auths/allowed_signers")]
68    pub allowed_signers_path: PathBuf,
69
70    /// Overwrite existing hook without prompting.
71    #[arg(long)]
72    pub force: bool,
73}
74
75/// Handle git subcommand.
76pub fn handle_git(
77    cmd: GitCommand,
78    repo_override: Option<PathBuf>,
79    attestation_prefix_override: Option<String>,
80    attestation_blob_name_override: Option<String>,
81) -> Result<()> {
82    match cmd.command {
83        GitSubcommand::AllowedSigners(subcmd) => handle_allowed_signers(
84            subcmd,
85            repo_override,
86            attestation_prefix_override,
87            attestation_blob_name_override,
88        ),
89        GitSubcommand::InstallHooks(subcmd) => handle_install_hooks(subcmd, repo_override),
90    }
91}
92
93fn handle_install_hooks(
94    cmd: InstallHooksCommand,
95    auths_repo_override: Option<PathBuf>,
96) -> Result<()> {
97    let git_dir = find_git_dir(&cmd.repo)?;
98    let hooks_dir = git_dir.join("hooks");
99
100    if !hooks_dir.exists() {
101        fs::create_dir_all(&hooks_dir)
102            .with_context(|| format!("Failed to create hooks directory: {:?}", hooks_dir))?;
103    }
104
105    let post_merge_path = hooks_dir.join("post-merge");
106
107    if post_merge_path.exists() && !cmd.force {
108        let existing = fs::read_to_string(&post_merge_path)
109            .with_context(|| format!("Failed to read existing hook: {:?}", post_merge_path))?;
110
111        if existing.contains("auths git allowed-signers") {
112            println!(
113                "Auths post-merge hook already installed at {:?}",
114                post_merge_path
115            );
116            println!("Use --force to overwrite.");
117            return Ok(());
118        } else {
119            bail!(
120                "A post-merge hook already exists at {:?}\n\
121                 It was not created by Auths. Use --force to overwrite, or manually \n\
122                 add the following to your existing hook:\n\n\
123                 auths git allowed-signers --output {}",
124                post_merge_path,
125                cmd.allowed_signers_path.display()
126            );
127        }
128    }
129
130    let auths_repo = if let Some(override_path) = auths_repo_override {
131        override_path
132    } else {
133        expand_tilde(&cmd.auths_repo)?
134    };
135
136    let hook_script = generate_post_merge_hook(&auths_repo, &cmd.allowed_signers_path);
137
138    fs::write(&post_merge_path, &hook_script)
139        .with_context(|| format!("Failed to write hook: {:?}", post_merge_path))?;
140
141    #[cfg(unix)]
142    {
143        let mut perms = fs::metadata(&post_merge_path)?.permissions();
144        perms.set_mode(0o755);
145        fs::set_permissions(&post_merge_path, perms)
146            .with_context(|| format!("Failed to set hook permissions: {:?}", post_merge_path))?;
147    }
148
149    println!("Installed post-merge hook at {:?}", post_merge_path);
150    println!(
151        "The hook will regenerate {:?} after each merge/pull.",
152        cmd.allowed_signers_path
153    );
154
155    if let Some(parent) = cmd.allowed_signers_path.parent()
156        && !parent.as_os_str().is_empty()
157        && !parent.exists()
158    {
159        fs::create_dir_all(parent)
160            .with_context(|| format!("Failed to create directory: {:?}", parent))?;
161        println!("Created directory {:?}", parent);
162    }
163
164    println!("\nGenerating initial allowed_signers file...");
165    let storage = RegistryAttestationStorage::new(&auths_repo);
166
167    match generate_allowed_signers(&storage) {
168        Ok(entries) => {
169            let output = format_allowed_signers_file(&entries);
170            fs::write(&cmd.allowed_signers_path, &output)
171                .with_context(|| format!("Failed to write {:?}", cmd.allowed_signers_path))?;
172            println!(
173                "Wrote {} entries to {:?}",
174                entries.len(),
175                cmd.allowed_signers_path
176            );
177        }
178        Err(e) => {
179            eprintln!("Warning: Could not generate initial allowed_signers: {}", e);
180            eprintln!("You may need to run 'auths git allowed-signers' manually.");
181        }
182    }
183
184    Ok(())
185}
186
187fn find_git_dir(repo_path: &Path) -> Result<PathBuf> {
188    let repo_path = if repo_path.to_string_lossy() == "." {
189        std::env::current_dir().context("Failed to get current directory")?
190    } else {
191        repo_path.to_path_buf()
192    };
193
194    let git_dir = repo_path.join(".git");
195    if git_dir.is_dir() {
196        return Ok(git_dir);
197    }
198
199    if git_dir.is_file() {
200        let content = fs::read_to_string(&git_dir)
201            .with_context(|| format!("Failed to read {:?}", git_dir))?;
202
203        // Format: "gitdir: <path>"
204        if let Some(path) = content.strip_prefix("gitdir: ") {
205            let linked_path = PathBuf::from(path.trim());
206            if linked_path.is_absolute() {
207                return Ok(linked_path);
208            } else {
209                return Ok(repo_path.join(linked_path));
210            }
211        }
212    }
213
214    if repo_path.join("HEAD").exists() && repo_path.join("config").exists() {
215        return Ok(repo_path);
216    }
217
218    bail!(
219        "Not a git repository: {:?}\n\
220         Could not find .git directory.",
221        repo_path
222    );
223}
224
225fn generate_post_merge_hook(auths_repo: &Path, allowed_signers_path: &Path) -> String {
226    format!(
227        r#"#!/bin/bash
228# Auto-generated by auths git install-hooks
229# Regenerates allowed_signers file after merge/pull
230
231# Run auths to regenerate allowed_signers
232auths git allowed-signers --repo "{}" --output "{}"
233"#,
234        auths_repo.display(),
235        allowed_signers_path.display()
236    )
237}
238
239fn handle_allowed_signers(
240    cmd: AllowedSignersCommand,
241    repo_override: Option<PathBuf>,
242    _attestation_prefix_override: Option<String>,
243    _attestation_blob_name_override: Option<String>,
244) -> Result<()> {
245    let repo_path = if let Some(override_path) = repo_override {
246        override_path
247    } else {
248        expand_tilde(&cmd.repo)?
249    };
250
251    // Note: Layout config overrides are deprecated with registry backend.
252    // The registry uses a fixed path structure under refs/auths/registry.
253
254    let storage = RegistryAttestationStorage::new(&repo_path);
255    let entries = generate_allowed_signers(&storage)
256        .context("Failed to load attestations from repository")?;
257
258    let output = format_allowed_signers_file(&entries);
259
260    if let Some(output_path) = cmd.output_file {
261        fs::write(&output_path, &output)
262            .with_context(|| format!("Failed to write to {:?}", output_path))?;
263        eprintln!("Wrote {} entries to {:?}", entries.len(), output_path);
264    } else {
265        print!("{}", output);
266    }
267
268    Ok(())
269}
270
271fn expand_tilde(path: &Path) -> Result<PathBuf> {
272    let path_str = path.to_string_lossy();
273    if path_str.starts_with("~/") || path_str == "~" {
274        let home = dirs::home_dir().context("Failed to determine home directory")?;
275        if path_str == "~" {
276            Ok(home)
277        } else {
278            Ok(home.join(&path_str[2..]))
279        }
280    } else {
281        Ok(path.to_path_buf())
282    }
283}
284
285use crate::commands::executable::ExecutableCommand;
286use crate::config::CliConfig;
287
288impl ExecutableCommand for GitCommand {
289    fn execute(&self, ctx: &CliConfig) -> Result<()> {
290        handle_git(
291            self.clone(),
292            ctx.repo_path.clone(),
293            self.overrides.attestation_prefix.clone(),
294            self.overrides.attestation_blob.clone(),
295        )
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use auths_sdk::workflows::git_integration::public_key_to_ssh;
303    use tempfile::TempDir;
304
305    #[test]
306    fn test_allowed_signers_output_flag_parses() {
307        let cmd = AllowedSignersCommand::try_parse_from([
308            "allowed-signers",
309            "--output",
310            "/tmp/allowed_signers",
311        ])
312        .expect("--output flag must parse without panic");
313        assert_eq!(cmd.output_file, Some(PathBuf::from("/tmp/allowed_signers")));
314    }
315
316    #[test]
317    fn test_allowed_signers_no_output_defaults_to_none() {
318        let cmd = AllowedSignersCommand::try_parse_from(["allowed-signers"])
319            .expect("allowed-signers with no args must parse");
320        assert!(cmd.output_file.is_none());
321    }
322
323    #[test]
324    fn test_public_key_to_ssh() {
325        let pk_bytes: [u8; 32] = [
326            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
327            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
328            0x1d, 0x1e, 0x1f, 0x20,
329        ];
330
331        let result = public_key_to_ssh(&pk_bytes);
332        assert!(result.is_ok(), "Failed: {:?}", result.err());
333
334        let ssh_key = result.unwrap();
335        assert!(ssh_key.starts_with("ssh-ed25519 "), "Got: {}", ssh_key);
336    }
337
338    #[test]
339    fn test_public_key_to_ssh_invalid_length() {
340        let pk_bytes = vec![0u8; 16];
341        let result = public_key_to_ssh(&pk_bytes);
342        assert!(result.is_err());
343    }
344
345    #[test]
346    fn test_expand_tilde() {
347        let path = PathBuf::from("~/.auths");
348        let result = expand_tilde(&path);
349        assert!(result.is_ok());
350        let expanded = result.unwrap();
351        assert!(!expanded.to_string_lossy().contains("~"));
352    }
353
354    #[test]
355    fn test_find_git_dir() {
356        let temp = TempDir::new().unwrap();
357        let git_dir = temp.path().join(".git");
358        fs::create_dir(&git_dir).unwrap();
359
360        let result = find_git_dir(temp.path());
361        assert!(result.is_ok());
362        assert_eq!(result.unwrap(), git_dir);
363    }
364
365    #[test]
366    fn test_find_git_dir_not_repo() {
367        let temp = TempDir::new().unwrap();
368        let result = find_git_dir(temp.path());
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn test_generate_post_merge_hook() {
374        let auths_repo = PathBuf::from("/home/user/.auths");
375        let allowed_signers = PathBuf::from(".auths/allowed_signers");
376
377        let hook = generate_post_merge_hook(&auths_repo, &allowed_signers);
378
379        assert!(hook.starts_with("#!/bin/bash"));
380        assert!(hook.contains("auths git allowed-signers"));
381        assert!(hook.contains("/home/user/.auths"));
382        assert!(hook.contains(".auths/allowed_signers"));
383    }
384}