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 ensure_repo_root_pin(signer: &LocalSigner) {
135 let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output()
136 else {
137 return;
138 };
139 if !output.status.success() {
140 return;
141 }
142 let toplevel = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
143 let auths_dir = toplevel.join(".auths");
144 let store = crate::adapters::config_store::FileConfigStore;
145 let root_did = signer.root_did.to_string();
146 if auths_sdk::workflows::roots::is_pinned_root(&store, &auths_dir, &root_did).unwrap_or(false) {
147 return;
148 }
149 if auths_sdk::workflows::roots::add_pinned_root(&store, &auths_dir, &root_did).is_ok() {
150 let roots_file = auths_dir.join("roots");
151 let _ =
152 crate::subprocess::git_command(&["add", "--", &roots_file.to_string_lossy()]).output();
153 eprintln!("auths: pinned your identity root in .auths/roots (staged for the next commit)");
154 }
155}
156
157fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> {
167 ensure_repo_root_pin(signer);
168 let trailers = commit_trailer_args(signer, scope);
169 let is_range = range.contains("..");
170 if is_range {
171 let parts: Vec<&str> = range.splitn(2, "..").collect();
172 let base = parts[0];
173 execute_git_rebase(base, &trailers)?;
174 } else {
175 let mut args: Vec<&str> = vec!["commit", "--amend", "--no-edit", "--no-verify"];
176 for trailer in &trailers {
177 args.push("--trailer");
178 args.push(trailer);
179 }
180 let output = crate::subprocess::git_command(&args)
181 .output()
182 .context("Failed to spawn git commit --amend")?;
183 if !output.status.success() {
184 let stderr = String::from_utf8_lossy(&output.stderr);
185 return Err(anyhow!(
186 "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}",
187 stderr.trim()
188 ));
189 }
190 }
191 if crate::ux::format::is_json_mode() {
192 crate::ux::format::JsonResponse::success(
193 "sign",
194 &serde_json::json!({ "target": range, "type": "commit" }),
195 )
196 .print()?;
197 } else {
198 println!("✔ Signed: {}", range);
199 }
200 Ok(())
201}
202
203#[derive(Parser, Debug, Clone)]
205#[command(
206 about = "Sign a Git commit or artifact file.",
207 after_help = "Examples:
208 auths sign README.md # Sign a file → README.md.auths.json
209 auths sign HEAD # Sign the current commit
210 auths sign main..HEAD # Re-sign commits after main
211
212Artifacts:
213 Signing files creates a .auths.json attestation with your identity and device.
214 Use `auths verify` to check the signature.
215
216Commits:
217 Commit signing requires a linked device and Git configuration.
218 Verify with `auths verify HEAD` or `git log --show-signature`.
219
220Related:
221 auths verify — Verify signatures
222 auths device list — Check linked devices"
223)]
224pub struct SignCommand {
225 #[arg(help = "Commit ref, range, or artifact file path")]
227 pub target: String,
228
229 #[arg(long = "sig-output", value_name = "PATH")]
231 pub sig_output: Option<PathBuf>,
232
233 #[arg(long)]
235 pub key: Option<String>,
236
237 #[arg(long)]
239 pub device_key: Option<String>,
240
241 #[arg(long = "expires-in", value_name = "N")]
243 pub expires_in: Option<u64>,
244
245 #[arg(long)]
247 pub note: Option<String>,
248
249 #[arg(long, value_delimiter = ',')]
253 pub scope: Vec<String>,
254}
255
256pub fn handle_sign_unified(
263 cmd: SignCommand,
264 repo_opt: Option<PathBuf>,
265 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
266 env_config: &EnvironmentConfig,
267) -> Result<()> {
268 match parse_sign_target(&cmd.target) {
269 SignTarget::Artifact(path) => {
270 let device_key_alias = match cmd.device_key.as_deref() {
271 Some(alias) => alias.to_string(),
272 None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?,
273 };
274 let commit_sha = super::git_helpers::resolve_head_silent();
275 handle_artifact_sign(
276 &path,
277 cmd.sig_output,
278 cmd.key.as_deref(),
279 &device_key_alias,
280 cmd.expires_in,
281 cmd.note,
282 commit_sha,
283 repo_opt,
284 passphrase_provider,
285 env_config,
286 &None,
287 false,
288 )
289 }
290 SignTarget::CommitRange(range) => {
291 let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?;
292 sign_commit_range(&range, &signer, &cmd.scope)
293 }
294 }
295}
296
297impl crate::commands::executable::ExecutableCommand for SignCommand {
298 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
299 handle_sign_unified(
300 self.clone(),
301 ctx.repo_path.clone(),
302 ctx.passphrase_provider.clone(),
303 &ctx.env_config,
304 )
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn commit_trailer_args_emit_auths_id_and_device() {
314 let signer = LocalSigner {
315 signer_did: "did:keri:Edevice".to_string(),
316 root_did: "did:keri:Eroot".to_string(),
317 anchor_seq: None,
318 };
319 let trailers = commit_trailer_args(&signer, &[]);
320 assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
321 assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice");
322 assert_eq!(
323 trailers.len(),
324 2,
325 "no anchor seq + no scope → only Auths-Id/Auths-Device"
326 );
327 }
328
329 #[test]
330 fn trailer_carries_signing_sequence() {
331 let signer = LocalSigner {
332 signer_did: "did:keri:Edevice".to_string(),
333 root_did: "did:keri:Eroot".to_string(),
334 anchor_seq: Some(7),
335 };
336 let trailers = commit_trailer_args(&signer, &[]);
337 assert_eq!(trailers.len(), 3);
338 assert_eq!(trailers[2], "Auths-Anchor-Seq: 7");
339 }
340
341 #[test]
342 fn commit_trailer_args_emit_scope_claim() {
343 let signer = LocalSigner {
344 signer_did: "did:keri:Eagent".to_string(),
345 root_did: "did:keri:Eroot".to_string(),
346 anchor_seq: Some(3),
347 };
348 let trailers =
349 commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]);
350 assert_eq!(trailers.len(), 4);
352 assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
353 assert_eq!(
355 trailers[3],
356 auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()])
357 );
358 }
359
360 #[test]
361 fn commit_trailer_args_no_scope_omits_trailer() {
362 let signer = LocalSigner {
363 signer_did: "did:keri:Eagent".to_string(),
364 root_did: "did:keri:Eroot".to_string(),
365 anchor_seq: None,
366 };
367 let trailers = commit_trailer_args(&signer, &[]);
368 assert!(
369 !trailers.iter().any(|t| t.starts_with("Auths-Scope")),
370 "no scope claim → no Auths-Scope trailer (backward compatible)"
371 );
372 }
373
374 #[test]
375 fn commit_trailer_args_root_machine_signs_directly() {
376 let signer = LocalSigner {
378 signer_did: "did:keri:Eroot".to_string(),
379 root_did: "did:keri:Eroot".to_string(),
380 anchor_seq: None,
381 };
382 let trailers = commit_trailer_args(&signer, &[]);
383 assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
384 assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot");
385 }
386
387 #[test]
388 fn test_parse_sign_target_commit_ref() {
389 let target = parse_sign_target("HEAD");
390 assert!(matches!(target, SignTarget::CommitRange(_)));
391 }
392
393 #[test]
394 fn test_parse_sign_target_range() {
395 let target = parse_sign_target("main..HEAD");
396 assert!(matches!(target, SignTarget::CommitRange(_)));
397 }
398
399 #[test]
400 fn test_parse_sign_target_nonexistent_path_is_commit_range() {
401 let target = parse_sign_target("/nonexistent/artifact.tar.gz");
402 assert!(matches!(target, SignTarget::CommitRange(_)));
403 }
404
405 #[test]
406 fn test_parse_sign_target_file() {
407 use std::fs::File;
408 use tempfile::tempdir;
409 let dir = tempdir().unwrap();
410 let file_path = dir.path().join("artifact.tar.gz");
411 File::create(&file_path).unwrap();
412 let target = parse_sign_target(file_path.to_str().unwrap());
413 assert!(matches!(target, SignTarget::Artifact(_)));
414 }
415}