1use 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 #[command(name = "allowed-signers")]
34 AllowedSigners(AllowedSignersCommand),
35
36 #[command(name = "install-hooks")]
41 InstallHooks(InstallHooksCommand),
42}
43
44#[derive(Parser, Debug, Clone)]
45pub struct AllowedSignersCommand {
46 #[arg(long, default_value = "~/.auths")]
48 pub repo: PathBuf,
49
50 #[arg(long = "output", short = 'o')]
52 pub output_file: Option<PathBuf>,
53}
54
55#[derive(Parser, Debug, Clone)]
56pub struct InstallHooksCommand {
57 #[arg(long, default_value = ".")]
60 pub repo: PathBuf,
61
62 #[arg(long, default_value = "~/.auths")]
64 pub auths_repo: PathBuf,
65
66 #[arg(long, default_value = ".auths/allowed_signers")]
68 pub allowed_signers_path: PathBuf,
69
70 #[arg(long)]
72 pub force: bool,
73}
74
75pub 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 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 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}