auths_cli/commands/
sign.rs1use anyhow::{Context, Result, anyhow};
4use clap::Parser;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use auths_core::config::EnvironmentConfig;
9use auths_core::signing::PassphraseProvider;
10use auths_core::storage::keychain::KeyStorage;
11use auths_id::storage::identity::IdentityStorage;
12use auths_storage::git::RegistryIdentityStorage;
13
14use super::artifact::sign::handle_sign as handle_artifact_sign;
15
16pub enum SignTarget {
18 Artifact(PathBuf),
19 CommitRange(String),
20}
21
22pub 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
43fn 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
60fn 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#[derive(Parser, Debug, Clone)]
95#[command(about = "Sign a Git commit or artifact file.")]
96pub struct SignCommand {
97 #[arg(help = "Commit ref, range, or artifact file path")]
99 pub target: String,
100
101 #[arg(long = "sig-output", value_name = "PATH")]
103 pub sig_output: Option<PathBuf>,
104
105 #[arg(long)]
107 pub identity_key_alias: Option<String>,
108
109 #[arg(long)]
111 pub device_key_alias: Option<String>,
112
113 #[arg(long, visible_alias = "days", value_name = "N")]
115 pub expires_in_days: Option<i64>,
116
117 #[arg(long)]
119 pub note: Option<String>,
120}
121
122pub 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
156fn 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
194impl 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)]
206mod 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}