/ crates / auths-cli / src / commands / sign.rs
sign.rs
  1  //! Unified sign command: signs a file artifact or a git commit range.
  2  
  3  use anyhow::{Context, Result, anyhow};
  4  use clap::Parser;
  5  use std::path::{Path, PathBuf};
  6  use std::sync::Arc;
  7  
  8  use auths_core::config::EnvironmentConfig;
  9  use auths_core::signing::PassphraseProvider;
 10  use auths_core::storage::keychain::KeyStorage;
 11  use auths_id::storage::identity::IdentityStorage;
 12  use auths_storage::git::RegistryIdentityStorage;
 13  
 14  use super::artifact::sign::handle_sign as handle_artifact_sign;
 15  
 16  /// Represents the resolved target for a sign operation.
 17  pub 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  /// ```
 34  pub 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).
 47  fn 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").
 64  fn 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.")]
 96  pub 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.
128  pub 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.
161  fn 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  
194  impl 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)]
206  mod 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  }