Skip to main content

auths_cli/commands/
sign.rs

1//! Unified sign command: signs a file artifact or a git commit range.
2
3use 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
16/// Represents the resolved target for a sign operation.
17pub enum SignTarget {
18    Artifact(PathBuf),
19    CommitRange(String),
20}
21
22/// Resolves raw CLI input into a concrete target type.
23///
24/// Checks the filesystem first. If no file exists at the path, assumes a Git reference.
25///
26/// Args:
27/// * `raw_target` - The raw string input from the CLI.
28///
29/// Usage:
30/// ```ignore
31/// let target = parse_sign_target("HEAD");
32/// assert!(matches!(target, SignTarget::CommitRange(_)));
33/// ```
34pub 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
43/// Execute `git rebase --exec "git commit --amend --no-edit" <base>` to re-sign a range.
44///
45/// Args:
46/// * `base` - The exclusive base ref (commits after this ref will be re-signed).
47fn 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
60/// Sign a Git commit range by invoking git-rebase with auths-sign as the signing program.
61///
62/// Args:
63/// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD").
64fn 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/// Sign a Git commit or artifact file.
94#[derive(Parser, Debug, Clone)]
95#[command(about = "Sign a Git commit or artifact file.")]
96pub struct SignCommand {
97    /// Git ref, commit range (e.g. HEAD, main..HEAD), or path to an artifact file.
98    #[arg(help = "Commit ref, range, or artifact file path")]
99    pub target: String,
100
101    /// Output path for the signature file. Defaults to <FILE>.auths.json.
102    #[arg(long = "sig-output", value_name = "PATH")]
103    pub sig_output: Option<PathBuf>,
104
105    /// Local alias of the identity key (for artifact signing).
106    #[arg(long)]
107    pub identity_key_alias: Option<String>,
108
109    /// Local alias of the device key (for artifact signing, required for files).
110    #[arg(long)]
111    pub device_key_alias: Option<String>,
112
113    /// Number of days until the signature expires (for artifact signing).
114    #[arg(long, visible_alias = "days", value_name = "N")]
115    pub expires_in_days: Option<i64>,
116
117    /// Optional note to embed in the attestation (for artifact signing).
118    #[arg(long)]
119    pub note: Option<String>,
120}
121
122/// Handle the unified sign command.
123///
124/// Args:
125/// * `cmd` - Parsed SignCommand arguments.
126/// * `repo_opt` - Optional path to the Auths identity repository.
127/// * `passphrase_provider` - Provider for key passphrases.
128pub 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
156/// Auto-detect the device key alias when not explicitly provided.
157///
158/// Loads the identity from the registry, then lists all key aliases associated
159/// with that identity. If exactly one alias exists, it is returned. Otherwise,
160/// an error with actionable guidance is returned.
161fn 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}