1use anyhow::{Context, Result, anyhow};
4use clap::Parser;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use auths_sdk::core_config::EnvironmentConfig;
9use auths_sdk::domains::identity::local::{LocalSigner, resolve_local_signer};
10use auths_sdk::signing::PassphraseProvider;
11
12use super::artifact::sign::handle_sign as handle_artifact_sign;
13
14pub enum SignTarget {
16 Artifact(PathBuf),
17 CommitRange(String),
18}
19
20pub fn parse_sign_target(raw_target: &str) -> SignTarget {
33 let path = Path::new(raw_target);
34 if path.exists() {
35 SignTarget::Artifact(path.to_path_buf())
36 } else {
37 if looks_like_artifact_path(raw_target) {
38 eprintln!(
39 "Warning: '{}' looks like an artifact file path, but the file does not exist.\n\
40 Treating as a git commit range. If you meant to sign a file, check the path.",
41 raw_target
42 );
43 }
44 SignTarget::CommitRange(raw_target.to_string())
45 }
46}
47
48fn looks_like_artifact_path(target: &str) -> bool {
50 if target.starts_with("./") || target.starts_with("../") || target.contains('/') {
52 let lower = target.to_lowercase();
53 return ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
54 }
55 let lower = target.to_lowercase();
57 ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
58}
59
60const ARTIFACT_EXTENSIONS: &[&str] = &[
61 ".tar.gz", ".tgz", ".zip", ".whl", ".gem", ".jar", ".deb", ".rpm", ".dmg", ".exe", ".msi",
62 ".pkg", ".nupkg",
63];
64
65fn commit_trailer_args(signer: &LocalSigner, scope: &[String]) -> Vec<String> {
71 let mut trailers = vec![
72 format!("Auths-Id: {}", signer.root_did),
73 format!("Auths-Device: {}", signer.signer_did),
74 ];
75 if let Some(seq) = signer.anchor_seq {
76 trailers.push(auths_verifier::anchor_seq_trailer(seq));
77 }
78 if !scope.is_empty() {
81 trailers.push(auths_verifier::scope_trailer(scope));
82 }
83 trailers
84}
85
86fn resolve_signer_trailer(
91 repo_opt: Option<&Path>,
92 env_config: &EnvironmentConfig,
93) -> Result<LocalSigner> {
94 let repo_path =
95 auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?;
96 let ctx = crate::factories::storage::build_auths_context(&repo_path, env_config, None)
97 .context("Failed to build auths context for commit signing")?;
98 resolve_local_signer(&ctx).map_err(anyhow::Error::from).context(
99 "Could not resolve the local signing identity. Run `auths init`, or pair this device with `auths pair --join`.",
100 )
101}
102
103fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> {
110 let trailer_flags: String = trailers
113 .iter()
114 .map(|t| format!(" --trailer '{}'", t))
115 .collect();
116 let exec_cmd = format!("git commit --amend --no-edit --no-verify{trailer_flags}");
117 let output = crate::subprocess::git_command(&["rebase", "--exec", &exec_cmd, base])
118 .output()
119 .context("Failed to spawn git rebase")?;
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 return Err(anyhow!(
123 "Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}",
124 stderr.trim()
125 ));
126 }
127 Ok(())
128}
129
130fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> {
140 let trailers = commit_trailer_args(signer, scope);
141 let is_range = range.contains("..");
142 if is_range {
143 let parts: Vec<&str> = range.splitn(2, "..").collect();
144 let base = parts[0];
145 execute_git_rebase(base, &trailers)?;
146 } else {
147 let mut args: Vec<&str> = vec!["commit", "--amend", "--no-edit", "--no-verify"];
148 for trailer in &trailers {
149 args.push("--trailer");
150 args.push(trailer);
151 }
152 let output = crate::subprocess::git_command(&args)
153 .output()
154 .context("Failed to spawn git commit --amend")?;
155 if !output.status.success() {
156 let stderr = String::from_utf8_lossy(&output.stderr);
157 return Err(anyhow!(
158 "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}",
159 stderr.trim()
160 ));
161 }
162 }
163 if crate::ux::format::is_json_mode() {
164 crate::ux::format::JsonResponse::success(
165 "sign",
166 &serde_json::json!({ "target": range, "type": "commit" }),
167 )
168 .print()?;
169 } else {
170 println!("✔ Signed: {}", range);
171 }
172 Ok(())
173}
174
175#[derive(Parser, Debug, Clone)]
177#[command(
178 about = "Sign a Git commit or artifact file.",
179 after_help = "Examples:
180 auths sign README.md # Sign a file → README.md.auths.json
181 auths sign HEAD # Sign the current commit
182 auths sign main..HEAD # Re-sign commits after main
183
184Artifacts:
185 Signing files creates a .auths.json attestation with your identity and device.
186 Use `auths verify` to check the signature.
187
188Commits:
189 Commit signing requires a linked device and Git configuration.
190 Verify with `auths verify HEAD` or `git log --show-signature`.
191
192Related:
193 auths verify — Verify signatures
194 auths device list — Check linked devices"
195)]
196pub struct SignCommand {
197 #[arg(help = "Commit ref, range, or artifact file path")]
199 pub target: String,
200
201 #[arg(long = "sig-output", value_name = "PATH")]
203 pub sig_output: Option<PathBuf>,
204
205 #[arg(long)]
207 pub key: Option<String>,
208
209 #[arg(long)]
211 pub device_key: Option<String>,
212
213 #[arg(long = "expires-in", value_name = "N")]
215 pub expires_in: Option<u64>,
216
217 #[arg(long)]
219 pub note: Option<String>,
220
221 #[arg(long, value_delimiter = ',')]
225 pub scope: Vec<String>,
226}
227
228pub fn handle_sign_unified(
235 cmd: SignCommand,
236 repo_opt: Option<PathBuf>,
237 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
238 env_config: &EnvironmentConfig,
239) -> Result<()> {
240 match parse_sign_target(&cmd.target) {
241 SignTarget::Artifact(path) => {
242 let device_key_alias = match cmd.device_key.as_deref() {
243 Some(alias) => alias.to_string(),
244 None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?,
245 };
246 let commit_sha = super::git_helpers::resolve_head_silent();
247 handle_artifact_sign(
248 &path,
249 cmd.sig_output,
250 cmd.key.as_deref(),
251 &device_key_alias,
252 cmd.expires_in,
253 cmd.note,
254 commit_sha,
255 repo_opt,
256 passphrase_provider,
257 env_config,
258 &None,
259 false,
260 )
261 }
262 SignTarget::CommitRange(range) => {
263 let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?;
264 sign_commit_range(&range, &signer, &cmd.scope)
265 }
266 }
267}
268
269impl crate::commands::executable::ExecutableCommand for SignCommand {
270 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
271 handle_sign_unified(
272 self.clone(),
273 ctx.repo_path.clone(),
274 ctx.passphrase_provider.clone(),
275 &ctx.env_config,
276 )
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn commit_trailer_args_emit_auths_id_and_device() {
286 let signer = LocalSigner {
287 signer_did: "did:keri:Edevice".to_string(),
288 root_did: "did:keri:Eroot".to_string(),
289 anchor_seq: None,
290 };
291 let trailers = commit_trailer_args(&signer, &[]);
292 assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
293 assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice");
294 assert_eq!(
295 trailers.len(),
296 2,
297 "no anchor seq + no scope → only Auths-Id/Auths-Device"
298 );
299 }
300
301 #[test]
302 fn trailer_carries_signing_sequence() {
303 let signer = LocalSigner {
304 signer_did: "did:keri:Edevice".to_string(),
305 root_did: "did:keri:Eroot".to_string(),
306 anchor_seq: Some(7),
307 };
308 let trailers = commit_trailer_args(&signer, &[]);
309 assert_eq!(trailers.len(), 3);
310 assert_eq!(trailers[2], "Auths-Anchor-Seq: 7");
311 }
312
313 #[test]
314 fn commit_trailer_args_emit_scope_claim() {
315 let signer = LocalSigner {
316 signer_did: "did:keri:Eagent".to_string(),
317 root_did: "did:keri:Eroot".to_string(),
318 anchor_seq: Some(3),
319 };
320 let trailers =
321 commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]);
322 assert_eq!(trailers.len(), 4);
324 assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
325 assert_eq!(
327 trailers[3],
328 auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()])
329 );
330 }
331
332 #[test]
333 fn commit_trailer_args_no_scope_omits_trailer() {
334 let signer = LocalSigner {
335 signer_did: "did:keri:Eagent".to_string(),
336 root_did: "did:keri:Eroot".to_string(),
337 anchor_seq: None,
338 };
339 let trailers = commit_trailer_args(&signer, &[]);
340 assert!(
341 !trailers.iter().any(|t| t.starts_with("Auths-Scope")),
342 "no scope claim → no Auths-Scope trailer (backward compatible)"
343 );
344 }
345
346 #[test]
347 fn commit_trailer_args_root_machine_signs_directly() {
348 let signer = LocalSigner {
350 signer_did: "did:keri:Eroot".to_string(),
351 root_did: "did:keri:Eroot".to_string(),
352 anchor_seq: None,
353 };
354 let trailers = commit_trailer_args(&signer, &[]);
355 assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
356 assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot");
357 }
358
359 #[test]
360 fn test_parse_sign_target_commit_ref() {
361 let target = parse_sign_target("HEAD");
362 assert!(matches!(target, SignTarget::CommitRange(_)));
363 }
364
365 #[test]
366 fn test_parse_sign_target_range() {
367 let target = parse_sign_target("main..HEAD");
368 assert!(matches!(target, SignTarget::CommitRange(_)));
369 }
370
371 #[test]
372 fn test_parse_sign_target_nonexistent_path_is_commit_range() {
373 let target = parse_sign_target("/nonexistent/artifact.tar.gz");
374 assert!(matches!(target, SignTarget::CommitRange(_)));
375 }
376
377 #[test]
378 fn test_parse_sign_target_file() {
379 use std::fs::File;
380 use tempfile::tempdir;
381 let dir = tempdir().unwrap();
382 let file_path = dir.path().join("artifact.tar.gz");
383 File::create(&file_path).unwrap();
384 let target = parse_sign_target(file_path.to_str().unwrap());
385 assert!(matches!(target, SignTarget::Artifact(_)));
386 }
387}