1use 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 #[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')]
55 pub output_file: Option<PathBuf>,
56}
57
58#[derive(Parser, Debug, Clone)]
59pub struct InstallHooksCommand {
60 #[arg(long, default_value = ".")]
63 pub repo: PathBuf,
64
65 #[arg(long, default_value = "~/.auths")]
67 pub auths_repo: PathBuf,
68
69 #[arg(long, default_value = ".auths/allowed_signers")]
71 pub allowed_signers_path: PathBuf,
72
73 #[arg(long)]
75 pub force: bool,
76}
77
78pub 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 let git_dir = find_git_dir(&cmd.repo)?;
102 let hooks_dir = git_dir.join("hooks");
103
104 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 if post_merge_path.exists() && !cmd.force {
114 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 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 let hook_script = generate_post_merge_hook(&auths_repo, &cmd.allowed_signers_path);
146
147 fs::write(&post_merge_path, &hook_script)
149 .with_context(|| format!("Failed to write hook: {:?}", post_merge_path))?;
150
151 #[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 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 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
219fn 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 let git_dir = repo_path.join(".git");
229 if git_dir.is_dir() {
230 return Ok(git_dir);
231 }
232
233 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 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 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
261fn 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 let repo_path = if let Some(override_path) = repo_override {
287 override_path
288 } else {
289 expand_tilde(&cmd.repo)?
290 };
291
292 let storage = RegistryAttestationStorage::new(&repo_path);
297
298 let attestations = storage
300 .load_all_attestations()
301 .context("Failed to load attestations from repository")?;
302
303 let mut entries: Vec<String> = Vec::new();
305
306 for att in attestations {
307 if att.is_revoked() {
309 continue;
310 }
311
312 let principal = get_principal(&att);
314
315 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 let entry = format!("{} namespaces=\"git\" {}", principal, ssh_key);
326 entries.push(entry);
327 }
328
329 entries.sort();
331 entries.dedup();
332
333 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
352pub(crate) fn get_principal(att: &auths_verifier::core::Attestation) -> String {
354 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 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
370pub(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 let ed25519_pk = Ed25519PublicKey::try_from(public_key_bytes)
381 .context("Failed to parse Ed25519 public key")?;
382
383 let ssh_pk = SshPublicKey::from(ed25519_pk);
385
386 ssh_pk
388 .to_openssh()
389 .context("Failed to format SSH public key")
390}
391
392fn 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 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 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]; 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}