sign.rs
1 //! Unified sign command: signs a file artifact or a git commit range. 2 3 use anyhow::{Context, Result, anyhow}; 4 use clap::Parser; 5 use std::path::{Path, PathBuf}; 6 use std::sync::Arc; 7 8 use auths_core::config::EnvironmentConfig; 9 use auths_core::signing::PassphraseProvider; 10 use auths_core::storage::keychain::KeyStorage; 11 use auths_id::storage::identity::IdentityStorage; 12 use auths_storage::git::RegistryIdentityStorage; 13 14 use super::artifact::sign::handle_sign as handle_artifact_sign; 15 16 /// Represents the resolved target for a sign operation. 17 pub enum SignTarget { 18 Artifact(PathBuf), 19 CommitRange(String), 20 } 21 22 /// Resolves raw CLI input into a concrete target type. 23 /// 24 /// Checks the filesystem first. If no file exists at the path, assumes a Git reference. 25 /// 26 /// Args: 27 /// * `raw_target` - The raw string input from the CLI. 28 /// 29 /// Usage: 30 /// ```ignore 31 /// let target = parse_sign_target("HEAD"); 32 /// assert!(matches!(target, SignTarget::CommitRange(_))); 33 /// ``` 34 pub fn parse_sign_target(raw_target: &str) -> SignTarget { 35 let path = Path::new(raw_target); 36 if path.exists() { 37 SignTarget::Artifact(path.to_path_buf()) 38 } else { 39 SignTarget::CommitRange(raw_target.to_string()) 40 } 41 } 42 43 /// Execute `git rebase --exec "git commit --amend --no-edit" <base>` to re-sign a range. 44 /// 45 /// Args: 46 /// * `base` - The exclusive base ref (commits after this ref will be re-signed). 47 fn execute_git_rebase(base: &str) -> Result<()> { 48 use std::process::Command; 49 let output = Command::new("git") 50 .args(["rebase", "--exec", "git commit --amend --no-edit", base]) 51 .output() 52 .context("Failed to spawn git rebase")?; 53 if !output.status.success() { 54 let stderr = String::from_utf8_lossy(&output.stderr); 55 return Err(anyhow!("git rebase failed: {}", stderr.trim())); 56 } 57 Ok(()) 58 } 59 60 /// Sign a Git commit range by invoking git-rebase with auths-sign as the signing program. 61 /// 62 /// Args: 63 /// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD"). 64 fn sign_commit_range(range: &str) -> Result<()> { 65 use std::process::Command; 66 let is_range = range.contains(".."); 67 if is_range { 68 let parts: Vec<&str> = range.splitn(2, "..").collect(); 69 let base = parts[0]; 70 execute_git_rebase(base)?; 71 } else { 72 let output = Command::new("git") 73 .args(["commit", "--amend", "--no-edit", "--no-verify"]) 74 .output() 75 .context("Failed to spawn git commit --amend")?; 76 if !output.status.success() { 77 let stderr = String::from_utf8_lossy(&output.stderr); 78 return Err(anyhow!("git commit --amend failed: {}", stderr.trim())); 79 } 80 } 81 if crate::ux::format::is_json_mode() { 82 crate::ux::format::JsonResponse::success( 83 "sign", 84 &serde_json::json!({ "target": range, "type": "commit" }), 85 ) 86 .print()?; 87 } else { 88 println!("✔ Signed: {}", range); 89 } 90 Ok(()) 91 } 92 93 /// Sign a Git commit or artifact file. 94 #[derive(Parser, Debug, Clone)] 95 #[command(about = "Sign a Git commit or artifact file.")] 96 pub struct SignCommand { 97 /// Git ref, commit range (e.g. HEAD, main..HEAD), or path to an artifact file. 98 #[arg(help = "Commit ref, range, or artifact file path")] 99 pub target: String, 100 101 /// Output path for the signature file. Defaults to <FILE>.auths.json. 102 #[arg(long = "sig-output", value_name = "PATH")] 103 pub sig_output: Option<PathBuf>, 104 105 /// Local alias of the identity key (for artifact signing). 106 #[arg(long)] 107 pub identity_key_alias: Option<String>, 108 109 /// Local alias of the device key (for artifact signing, required for files). 110 #[arg(long)] 111 pub device_key_alias: Option<String>, 112 113 /// Number of days until the signature expires (for artifact signing). 114 #[arg(long, visible_alias = "days", value_name = "N")] 115 pub expires_in_days: Option<i64>, 116 117 /// Optional note to embed in the attestation (for artifact signing). 118 #[arg(long)] 119 pub note: Option<String>, 120 } 121 122 /// Handle the unified sign command. 123 /// 124 /// Args: 125 /// * `cmd` - Parsed SignCommand arguments. 126 /// * `repo_opt` - Optional path to the Auths identity repository. 127 /// * `passphrase_provider` - Provider for key passphrases. 128 pub fn handle_sign_unified( 129 cmd: SignCommand, 130 repo_opt: Option<PathBuf>, 131 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>, 132 env_config: &EnvironmentConfig, 133 ) -> Result<()> { 134 match parse_sign_target(&cmd.target) { 135 SignTarget::Artifact(path) => { 136 let device_key_alias = match cmd.device_key_alias.as_deref() { 137 Some(alias) => alias.to_string(), 138 None => auto_detect_device_key(repo_opt.as_deref(), env_config)?, 139 }; 140 handle_artifact_sign( 141 &path, 142 cmd.sig_output, 143 cmd.identity_key_alias.as_deref(), 144 &device_key_alias, 145 cmd.expires_in_days, 146 cmd.note, 147 repo_opt, 148 passphrase_provider, 149 env_config, 150 ) 151 } 152 SignTarget::CommitRange(range) => sign_commit_range(&range), 153 } 154 } 155 156 /// Auto-detect the device key alias when not explicitly provided. 157 /// 158 /// Loads the identity from the registry, then lists all key aliases associated 159 /// with that identity. If exactly one alias exists, it is returned. Otherwise, 160 /// an error with actionable guidance is returned. 161 fn auto_detect_device_key( 162 repo_opt: Option<&Path>, 163 env_config: &EnvironmentConfig, 164 ) -> Result<String> { 165 let repo_path = 166 auths_id::storage::layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?; 167 let identity_storage = RegistryIdentityStorage::new(repo_path.clone()); 168 let identity = identity_storage 169 .load_identity() 170 .map_err(|_| anyhow!("No identity found. Run `auths init` to get started."))?; 171 172 let keychain = auths_core::storage::keychain::get_platform_keychain_with_config(env_config) 173 .context("Failed to access keychain")?; 174 let aliases = keychain 175 .list_aliases_for_identity(&identity.controller_did) 176 .map_err(|e| anyhow!("Failed to list key aliases: {e}"))?; 177 178 match aliases.len() { 179 0 => Err(anyhow!( 180 "No device keys found for identity {}.\n\nRun `auths device link` to authorize a device.", 181 identity.controller_did 182 )), 183 1 => Ok(aliases[0].as_str().to_string()), 184 _ => { 185 let alias_list: Vec<&str> = aliases.iter().map(|a| a.as_str()).collect(); 186 Err(anyhow!( 187 "Multiple device keys found. Specify with --device-key-alias.\n\nAvailable aliases: {}", 188 alias_list.join(", ") 189 )) 190 } 191 } 192 } 193 194 impl crate::commands::executable::ExecutableCommand for SignCommand { 195 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { 196 handle_sign_unified( 197 self.clone(), 198 ctx.repo_path.clone(), 199 ctx.passphrase_provider.clone(), 200 &ctx.env_config, 201 ) 202 } 203 } 204 205 #[cfg(test)] 206 mod tests { 207 use super::*; 208 209 #[test] 210 fn test_parse_sign_target_commit_ref() { 211 let target = parse_sign_target("HEAD"); 212 assert!(matches!(target, SignTarget::CommitRange(_))); 213 } 214 215 #[test] 216 fn test_parse_sign_target_range() { 217 let target = parse_sign_target("main..HEAD"); 218 assert!(matches!(target, SignTarget::CommitRange(_))); 219 } 220 221 #[test] 222 fn test_parse_sign_target_nonexistent_path_is_commit_range() { 223 let target = parse_sign_target("/nonexistent/artifact.tar.gz"); 224 assert!(matches!(target, SignTarget::CommitRange(_))); 225 } 226 227 #[test] 228 fn test_parse_sign_target_file() { 229 use std::fs::File; 230 use tempfile::tempdir; 231 let dir = tempdir().unwrap(); 232 let file_path = dir.path().join("artifact.tar.gz"); 233 File::create(&file_path).unwrap(); 234 let target = parse_sign_target(file_path.to_str().unwrap()); 235 assert!(matches!(target, SignTarget::Artifact(_))); 236 } 237 }