use anyhow::{Context, Result, anyhow};
use clap::Parser;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use auths_core::config::EnvironmentConfig;
use auths_core::signing::PassphraseProvider;
use auths_core::storage::keychain::KeyStorage;
use auths_id::storage::identity::IdentityStorage;
use auths_storage::git::RegistryIdentityStorage;
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 {
SignTarget::CommitRange(raw_target.to_string())
}
}
fn execute_git_rebase(base: &str) -> Result<()> {
use std::process::Command;
let output = Command::new("git")
.args(["rebase", "--exec", "git commit --amend --no-edit", base])
.output()
.context("Failed to spawn git rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("git rebase failed: {}", stderr.trim()));
}
Ok(())
}
fn sign_commit_range(range: &str) -> Result<()> {
use std::process::Command;
let is_range = range.contains("..");
if is_range {
let parts: Vec<&str> = range.splitn(2, "..").collect();
let base = parts[0];
execute_git_rebase(base)?;
} else {
let output = Command::new("git")
.args(["commit", "--amend", "--no-edit", "--no-verify"])
.output()
.context("Failed to spawn git commit --amend")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("git commit --amend failed: {}", 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.")]
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 identity_key_alias: Option<String>,
#[arg(long)]
pub device_key_alias: Option<String>,
#[arg(long, visible_alias = "days", value_name = "N")]
pub expires_in_days: Option<i64>,
#[arg(long)]
pub note: Option<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_alias.as_deref() {
Some(alias) => alias.to_string(),
None => auto_detect_device_key(repo_opt.as_deref(), env_config)?,
};
handle_artifact_sign(
&path,
cmd.sig_output,
cmd.identity_key_alias.as_deref(),
&device_key_alias,
cmd.expires_in_days,
cmd.note,
repo_opt,
passphrase_provider,
env_config,
)
}
SignTarget::CommitRange(range) => sign_commit_range(&range),
}
}
fn auto_detect_device_key(
repo_opt: Option<&Path>,
env_config: &EnvironmentConfig,
) -> Result<String> {
let repo_path =
auths_id::storage::layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?;
let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
let identity = identity_storage
.load_identity()
.map_err(|_| anyhow!("No identity found. Run `auths init` to get started."))?;
let keychain = auths_core::storage::keychain::get_platform_keychain_with_config(env_config)
.context("Failed to access keychain")?;
let aliases = keychain
.list_aliases_for_identity(&identity.controller_did)
.map_err(|e| anyhow!("Failed to list key aliases: {e}"))?;
match aliases.len() {
0 => Err(anyhow!(
"No device keys found for identity {}.\n\nRun `auths device link` to authorize a device.",
identity.controller_did
)),
1 => Ok(aliases[0].as_str().to_string()),
_ => {
let alias_list: Vec<&str> = aliases.iter().map(|a| a.as_str()).collect();
Err(anyhow!(
"Multiple device keys found. Specify with --device-key-alias.\n\nAvailable aliases: {}",
alias_list.join(", ")
))
}
}
}
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 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(_)));
}
}