use anyhow::{Context, Result, anyhow};
use clap::Parser;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use auths_sdk::core_config::EnvironmentConfig;
use auths_sdk::domains::identity::local::{LocalSigner, resolve_local_signer};
use auths_sdk::signing::PassphraseProvider;
use super::artifact::sign::handle_sign as handle_artifact_sign;
pub enum SignTarget {
Artifact(PathBuf),
CommitRange(String),
}
pub fn parse_sign_target(raw_target: &str) -> SignTarget {
let path = Path::new(raw_target);
if path.exists() {
SignTarget::Artifact(path.to_path_buf())
} else {
if looks_like_artifact_path(raw_target) {
eprintln!(
"Warning: '{}' looks like an artifact file path, but the file does not exist.\n\
Treating as a git commit range. If you meant to sign a file, check the path.",
raw_target
);
}
SignTarget::CommitRange(raw_target.to_string())
}
}
fn looks_like_artifact_path(target: &str) -> bool {
if target.starts_with("./") || target.starts_with("../") || target.contains('/') {
let lower = target.to_lowercase();
return ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
}
let lower = target.to_lowercase();
ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
}
const ARTIFACT_EXTENSIONS: &[&str] = &[
".tar.gz", ".tgz", ".zip", ".whl", ".gem", ".jar", ".deb", ".rpm", ".dmg", ".exe", ".msi",
".pkg", ".nupkg",
];
fn commit_trailer_args(signer: &LocalSigner, scope: &[String]) -> Vec<String> {
let mut trailers = vec![
format!("Auths-Id: {}", signer.root_did),
format!("Auths-Device: {}", signer.signer_did),
];
if let Some(seq) = signer.anchor_seq {
trailers.push(auths_verifier::anchor_seq_trailer(seq));
}
if !scope.is_empty() {
trailers.push(auths_verifier::scope_trailer(scope));
}
trailers
}
fn resolve_signer_trailer(
repo_opt: Option<&Path>,
env_config: &EnvironmentConfig,
) -> Result<LocalSigner> {
let repo_path =
auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?;
let ctx = crate::factories::storage::build_auths_context(&repo_path, env_config, None)
.context("Failed to build auths context for commit signing")?;
resolve_local_signer(&ctx).map_err(anyhow::Error::from).context(
"Could not resolve the local signing identity. Run `auths init`, or pair this device with `auths pair --join`.",
)
}
fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> {
let trailer_flags: String = trailers
.iter()
.map(|t| format!(" --trailer '{}'", t))
.collect();
let exec_cmd = format!("git commit --amend --no-edit --no-verify{trailer_flags}");
let output = crate::subprocess::git_command(&["rebase", "--exec", &exec_cmd, base])
.output()
.context("Failed to spawn git rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}",
stderr.trim()
));
}
Ok(())
}
fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> {
let trailers = commit_trailer_args(signer, scope);
let is_range = range.contains("..");
if is_range {
let parts: Vec<&str> = range.splitn(2, "..").collect();
let base = parts[0];
execute_git_rebase(base, &trailers)?;
} else {
let mut args: Vec<&str> = vec!["commit", "--amend", "--no-edit", "--no-verify"];
for trailer in &trailers {
args.push("--trailer");
args.push(trailer);
}
let output = crate::subprocess::git_command(&args)
.output()
.context("Failed to spawn git commit --amend")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}",
stderr.trim()
));
}
}
if crate::ux::format::is_json_mode() {
crate::ux::format::JsonResponse::success(
"sign",
&serde_json::json!({ "target": range, "type": "commit" }),
)
.print()?;
} else {
println!("✔ Signed: {}", range);
}
Ok(())
}
#[derive(Parser, Debug, Clone)]
#[command(
about = "Sign a Git commit or artifact file.",
after_help = "Examples:
auths sign README.md # Sign a file → README.md.auths.json
auths sign HEAD # Sign the current commit
auths sign main..HEAD # Re-sign commits after main
Artifacts:
Signing files creates a .auths.json attestation with your identity and device.
Use `auths verify` to check the signature.
Commits:
Commit signing requires a linked device and Git configuration.
Verify with `auths verify HEAD` or `git log --show-signature`.
Related:
auths verify — Verify signatures
auths device list — Check linked devices"
)]
pub struct SignCommand {
#[arg(help = "Commit ref, range, or artifact file path")]
pub target: String,
#[arg(long = "sig-output", value_name = "PATH")]
pub sig_output: Option<PathBuf>,
#[arg(long)]
pub key: Option<String>,
#[arg(long)]
pub device_key: Option<String>,
#[arg(long = "expires-in", value_name = "N")]
pub expires_in: Option<u64>,
#[arg(long)]
pub note: Option<String>,
#[arg(long, value_delimiter = ',')]
pub scope: Vec<String>,
}
pub fn handle_sign_unified(
cmd: SignCommand,
repo_opt: Option<PathBuf>,
passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
env_config: &EnvironmentConfig,
) -> Result<()> {
match parse_sign_target(&cmd.target) {
SignTarget::Artifact(path) => {
let device_key_alias = match cmd.device_key.as_deref() {
Some(alias) => alias.to_string(),
None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?,
};
let commit_sha = super::git_helpers::resolve_head_silent();
handle_artifact_sign(
&path,
cmd.sig_output,
cmd.key.as_deref(),
&device_key_alias,
cmd.expires_in,
cmd.note,
commit_sha,
repo_opt,
passphrase_provider,
env_config,
&None,
false,
)
}
SignTarget::CommitRange(range) => {
let signer = resolve_signer_trailer(repo_opt.as_deref(), env_config)?;
sign_commit_range(&range, &signer, &cmd.scope)
}
}
}
impl crate::commands::executable::ExecutableCommand for SignCommand {
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_sign_unified(
self.clone(),
ctx.repo_path.clone(),
ctx.passphrase_provider.clone(),
&ctx.env_config,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commit_trailer_args_emit_auths_id_and_device() {
let signer = LocalSigner {
signer_did: "did:keri:Edevice".to_string(),
root_did: "did:keri:Eroot".to_string(),
anchor_seq: None,
};
let trailers = commit_trailer_args(&signer, &[]);
assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
assert_eq!(trailers[1], "Auths-Device: did:keri:Edevice");
assert_eq!(
trailers.len(),
2,
"no anchor seq + no scope → only Auths-Id/Auths-Device"
);
}
#[test]
fn trailer_carries_signing_sequence() {
let signer = LocalSigner {
signer_did: "did:keri:Edevice".to_string(),
root_did: "did:keri:Eroot".to_string(),
anchor_seq: Some(7),
};
let trailers = commit_trailer_args(&signer, &[]);
assert_eq!(trailers.len(), 3);
assert_eq!(trailers[2], "Auths-Anchor-Seq: 7");
}
#[test]
fn commit_trailer_args_emit_scope_claim() {
let signer = LocalSigner {
signer_did: "did:keri:Eagent".to_string(),
root_did: "did:keri:Eroot".to_string(),
anchor_seq: Some(3),
};
let trailers =
commit_trailer_args(&signer, &["sign_commit".to_string(), "open-PR".to_string()]);
assert_eq!(trailers.len(), 4);
assert_eq!(trailers[3], "Auths-Scope: sign_commit,open-PR");
assert_eq!(
trailers[3],
auths_verifier::scope_trailer(&["sign_commit".to_string(), "open-PR".to_string()])
);
}
#[test]
fn commit_trailer_args_no_scope_omits_trailer() {
let signer = LocalSigner {
signer_did: "did:keri:Eagent".to_string(),
root_did: "did:keri:Eroot".to_string(),
anchor_seq: None,
};
let trailers = commit_trailer_args(&signer, &[]);
assert!(
!trailers.iter().any(|t| t.starts_with("Auths-Scope")),
"no scope claim → no Auths-Scope trailer (backward compatible)"
);
}
#[test]
fn commit_trailer_args_root_machine_signs_directly() {
let signer = LocalSigner {
signer_did: "did:keri:Eroot".to_string(),
root_did: "did:keri:Eroot".to_string(),
anchor_seq: None,
};
let trailers = commit_trailer_args(&signer, &[]);
assert_eq!(trailers[0], "Auths-Id: did:keri:Eroot");
assert_eq!(trailers[1], "Auths-Device: did:keri:Eroot");
}
#[test]
fn test_parse_sign_target_commit_ref() {
let target = parse_sign_target("HEAD");
assert!(matches!(target, SignTarget::CommitRange(_)));
}
#[test]
fn test_parse_sign_target_range() {
let target = parse_sign_target("main..HEAD");
assert!(matches!(target, SignTarget::CommitRange(_)));
}
#[test]
fn test_parse_sign_target_nonexistent_path_is_commit_range() {
let target = parse_sign_target("/nonexistent/artifact.tar.gz");
assert!(matches!(target, SignTarget::CommitRange(_)));
}
#[test]
fn test_parse_sign_target_file() {
use std::fs::File;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let file_path = dir.path().join("artifact.tar.gz");
File::create(&file_path).unwrap();
let target = parse_sign_target(file_path.to_str().unwrap());
assert!(matches!(target, SignTarget::Artifact(_)));
}
}