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;
10
11use super::artifact::sign::handle_sign as handle_artifact_sign;
12
13/// Represents the resolved target for a sign operation.
14pub enum SignTarget {
15    Artifact(PathBuf),
16    CommitRange(String),
17}
18
19/// Resolves raw CLI input into a concrete target type.
20///
21/// Checks the filesystem first. If no file exists at the path, assumes a Git reference.
22///
23/// Args:
24/// * `raw_target` - The raw string input from the CLI.
25///
26/// Usage:
27/// ```ignore
28/// let target = parse_sign_target("HEAD");
29/// assert!(matches!(target, SignTarget::CommitRange(_)));
30/// ```
31pub fn parse_sign_target(raw_target: &str) -> SignTarget {
32    let path = Path::new(raw_target);
33    if path.exists() {
34        SignTarget::Artifact(path.to_path_buf())
35    } else {
36        SignTarget::CommitRange(raw_target.to_string())
37    }
38}
39
40/// Execute `git rebase --exec "git commit --amend --no-edit" <base>` to re-sign a range.
41///
42/// Args:
43/// * `base` - The exclusive base ref (commits after this ref will be re-signed).
44fn execute_git_rebase(base: &str) -> Result<()> {
45    use std::process::Command;
46    let output = Command::new("git")
47        .args(["rebase", "--exec", "git commit --amend --no-edit", base])
48        .output()
49        .context("Failed to spawn git rebase")?;
50    if !output.status.success() {
51        let stderr = String::from_utf8_lossy(&output.stderr);
52        return Err(anyhow!("git rebase failed: {}", stderr.trim()));
53    }
54    Ok(())
55}
56
57/// Sign a Git commit range by invoking git-rebase with auths-sign as the signing program.
58///
59/// Args:
60/// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD").
61fn sign_commit_range(range: &str) -> Result<()> {
62    use std::process::Command;
63    let is_range = range.contains("..");
64    if is_range {
65        let parts: Vec<&str> = range.splitn(2, "..").collect();
66        let base = parts[0];
67        execute_git_rebase(base)?;
68    } else {
69        let output = Command::new("git")
70            .args(["commit", "--amend", "--no-edit", "--no-verify"])
71            .output()
72            .context("Failed to spawn git commit --amend")?;
73        if !output.status.success() {
74            let stderr = String::from_utf8_lossy(&output.stderr);
75            return Err(anyhow!("git commit --amend failed: {}", stderr.trim()));
76        }
77    }
78    println!("✔ Signed: {}", range);
79    Ok(())
80}
81
82/// Sign a Git commit or artifact file.
83#[derive(Parser, Debug, Clone)]
84#[command(about = "Sign a Git commit or artifact file.")]
85pub struct SignCommand {
86    /// Git ref, commit range (e.g. HEAD, main..HEAD), or path to an artifact file.
87    #[arg(help = "Commit ref, range, or artifact file path")]
88    pub target: String,
89
90    /// Output path for the signature file. Defaults to <FILE>.auths.json.
91    #[arg(long = "sig-output", value_name = "PATH")]
92    pub sig_output: Option<PathBuf>,
93
94    /// Local alias of the identity key (for artifact signing).
95    #[arg(long)]
96    pub identity_key_alias: Option<String>,
97
98    /// Local alias of the device key (for artifact signing, required for files).
99    #[arg(long)]
100    pub device_key_alias: Option<String>,
101
102    /// Number of days until the signature expires (for artifact signing).
103    #[arg(long, value_name = "N")]
104    pub expires_in_days: Option<i64>,
105
106    /// Optional note to embed in the attestation (for artifact signing).
107    #[arg(long)]
108    pub note: Option<String>,
109}
110
111/// Handle the unified sign command.
112///
113/// Args:
114/// * `cmd` - Parsed SignCommand arguments.
115/// * `repo_opt` - Optional path to the Auths identity repository.
116/// * `passphrase_provider` - Provider for key passphrases.
117pub fn handle_sign_unified(
118    cmd: SignCommand,
119    repo_opt: Option<PathBuf>,
120    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
121    env_config: &EnvironmentConfig,
122) -> Result<()> {
123    match parse_sign_target(&cmd.target) {
124        SignTarget::Artifact(path) => {
125            let device_key_alias = cmd.device_key_alias.as_deref().ok_or_else(|| {
126                anyhow!(
127                    "artifact signing requires --device-key-alias\n\nRun: auths sign <file> --device-key-alias <alias>"
128                )
129            })?;
130            handle_artifact_sign(
131                &path,
132                cmd.sig_output,
133                cmd.identity_key_alias.as_deref(),
134                device_key_alias,
135                cmd.expires_in_days,
136                cmd.note,
137                repo_opt,
138                passphrase_provider,
139                env_config,
140            )
141        }
142        SignTarget::CommitRange(range) => sign_commit_range(&range),
143    }
144}
145
146impl crate::commands::executable::ExecutableCommand for SignCommand {
147    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
148        handle_sign_unified(
149            self.clone(),
150            ctx.repo_path.clone(),
151            ctx.passphrase_provider.clone(),
152            &ctx.env_config,
153        )
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_parse_sign_target_commit_ref() {
163        let target = parse_sign_target("HEAD");
164        assert!(matches!(target, SignTarget::CommitRange(_)));
165    }
166
167    #[test]
168    fn test_parse_sign_target_range() {
169        let target = parse_sign_target("main..HEAD");
170        assert!(matches!(target, SignTarget::CommitRange(_)));
171    }
172
173    #[test]
174    fn test_parse_sign_target_nonexistent_path_is_commit_range() {
175        let target = parse_sign_target("/nonexistent/artifact.tar.gz");
176        assert!(matches!(target, SignTarget::CommitRange(_)));
177    }
178
179    #[test]
180    fn test_parse_sign_target_file() {
181        use std::fs::File;
182        use tempfile::tempdir;
183        let dir = tempdir().unwrap();
184        let file_path = dir.path().join("artifact.tar.gz");
185        File::create(&file_path).unwrap();
186        let target = parse_sign_target(file_path.to_str().unwrap());
187        assert!(matches!(target, SignTarget::Artifact(_)));
188    }
189}