1pub mod core;
2pub mod file;
3pub mod oidc;
4pub mod publish;
5pub mod sign;
6pub mod verify;
7
8use clap::{Args, Subcommand};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use anyhow::{Context, Result, bail};
13use auths_sdk::core_config::EnvironmentConfig;
14use auths_sdk::domains::signing::service::EphemeralSignRequest;
15use auths_sdk::signing::PassphraseProvider;
16use auths_sdk::signing::validate_commit_sha;
17
18#[derive(Args, Debug, Clone)]
19#[command(
20 about = "Sign and verify arbitrary artifacts (tarballs, binaries, etc.).",
21 after_help = "Examples:
22 auths artifact sign package.tar.gz # Sign an artifact
23 auths artifact sign package.tar.gz --expires-in 2592000
24 # Sign with 30-day expiry
25 auths artifact verify package.tar.gz.auths.json
26 # Verify artifact signature
27 auths artifact publish package.tar.gz --package npm:react@18.3.0
28 # Sign and publish to registry
29
30Signature Files:
31 Signatures are stored as <file>.auths.json next to the artifact.
32 Contains identity, device, and signature information.
33
34Related:
35 auths sign — Sign commits and other files
36 auths verify — Verify signatures
37 auths trust — Manage trusted identities"
38)]
39pub struct ArtifactCommand {
40 #[command(subcommand)]
41 pub command: ArtifactSubcommand,
42}
43
44#[derive(Subcommand, Debug, Clone)]
45pub enum ArtifactSubcommand {
46 Sign {
48 #[arg(help = "Path to the artifact file to sign.")]
50 file: PathBuf,
51
52 #[arg(long = "sig-output", value_name = "PATH")]
54 sig_output: Option<PathBuf>,
55
56 #[arg(long)]
58 force: bool,
59
60 #[arg(
62 long,
63 help = "Local alias of the identity key. Omit for device-only CI signing."
64 )]
65 key: Option<String>,
66
67 #[arg(
70 long,
71 help = "Local alias of the device key. Auto-detected when only one key exists."
72 )]
73 device_key: Option<String>,
74
75 #[arg(long = "expires-in", value_name = "N")]
77 expires_in: Option<u64>,
78
79 #[arg(long)]
81 note: Option<String>,
82
83 #[arg(long, conflicts_with = "no_commit")]
85 commit: Option<String>,
86
87 #[arg(long, conflicts_with = "commit")]
89 no_commit: bool,
90
91 #[arg(long)]
93 ci: bool,
94
95 #[arg(long, value_name = "CURVE", requires = "ci")]
98 curve: Option<auths_crypto::CurveType>,
99
100 #[arg(long, requires = "ci")]
102 ci_platform: Option<String>,
103
104 #[arg(
108 long,
109 value_name = "TOKEN-FILE",
110 requires = "ci",
111 requires = "oidc_audience"
112 )]
113 oidc_token: Option<PathBuf>,
114
115 #[arg(long, value_name = "AUD", requires = "oidc_token")]
117 oidc_audience: Option<String>,
118
119 #[arg(
121 long,
122 value_name = "URL",
123 requires = "oidc_token",
124 default_value = oidc::DEFAULT_OIDC_ISSUER
125 )]
126 oidc_issuer: String,
127
128 #[arg(long, value_name = "JWKS-FILE", requires = "oidc_token")]
131 oidc_jwks: Option<PathBuf>,
132
133 #[arg(long, value_name = "LOG_ID")]
135 log: Option<String>,
136
137 #[arg(long)]
140 allow_unlogged: bool,
141 },
142
143 Publish {
147 #[arg(help = "Artifact file to sign and publish (auto-signs if no --signature).")]
149 file: Option<PathBuf>,
150
151 #[arg(long, value_name = "PATH")]
153 signature: Option<PathBuf>,
154
155 #[arg(long)]
157 package: Option<String>,
158
159 #[arg(long, env = "AUTHS_REGISTRY_URL")]
161 registry: Option<String>,
162
163 #[arg(long)]
165 key: Option<String>,
166
167 #[arg(long)]
169 device_key: Option<String>,
170
171 #[arg(long = "expires-in", value_name = "N")]
173 expires_in: Option<u64>,
174
175 #[arg(long)]
177 note: Option<String>,
178
179 #[arg(long, conflicts_with = "no_commit")]
181 commit: Option<String>,
182
183 #[arg(long, conflicts_with = "commit")]
185 no_commit: bool,
186 },
187
188 Verify {
190 #[arg(help = "Path to the artifact file to verify.")]
192 file: PathBuf,
193
194 #[arg(long, value_name = "PATH")]
196 signature: Option<PathBuf>,
197
198 #[arg(long, value_parser)]
200 identity_bundle: Option<PathBuf>,
201
202 #[arg(long = "witness-signatures")]
204 witness_receipts: Option<PathBuf>,
205
206 #[arg(long, num_args = 1..)]
208 witness_keys: Vec<String>,
209
210 #[arg(long = "witnesses-required", default_value = "1")]
212 witness_threshold: usize,
213
214 #[arg(long)]
216 verify_commit: bool,
217
218 #[arg(long, conflicts_with = "verify_commit")]
224 signature_only: bool,
225
226 #[arg(long)]
228 offline: bool,
229
230 #[arg(long, value_name = "PATH")]
232 roots: Option<PathBuf>,
233
234 #[arg(long = "member", visible_alias = "member-did")]
236 member: Option<String>,
237
238 #[arg(long)]
240 signed_at: Option<u128>,
241
242 #[arg(long)]
244 json: bool,
245
246 #[arg(long, value_name = "POLICY-FILE")]
250 oidc_policy: Option<PathBuf>,
251
252 #[arg(long, value_name = "ORG-DID", conflicts_with = "oidc_policy")]
257 oidc_policy_did: Option<String>,
258
259 #[arg(long, value_name = "EVIDENCE-FILE", requires = "log_key")]
264 log_evidence: Option<PathBuf>,
265
266 #[arg(long, value_name = "HEX", requires = "log_evidence")]
269 log_key: Option<String>,
270
271 #[arg(long = "expect-signer", value_name = "DID")]
275 expect_signer: Option<String>,
276 #[arg(long = "require-rooted-signer")]
280 require_rooted_signer: bool,
281 },
282}
283
284fn is_rate_limited(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> bool {
285 matches!(
286 err,
287 auths_sdk::workflows::log_submit::LogSubmitError::LogError(
288 auths_sdk::ports::LogError::RateLimited { .. }
289 )
290 )
291}
292
293fn rate_limit_secs(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> u64 {
294 match err {
295 auths_sdk::workflows::log_submit::LogSubmitError::LogError(
296 auths_sdk::ports::LogError::RateLimited { retry_after_secs },
297 ) => *retry_after_secs,
298 _ => 10,
299 }
300}
301
302pub use auths_sdk::domains::signing::service::dsse_pae;
304
305fn submit_to_log(
312 attestation_json: &str,
313 log: &Option<String>,
314 allow_unlogged: bool,
315 dsse_signature: Option<&[u8]>,
316) -> Result<Option<serde_json::Value>> {
317 if allow_unlogged {
318 eprintln!(
319 "WARNING: Signing without transparency log. \
320 This artifact will not be verifiable against any log."
321 );
322 return Ok(None);
323 }
324
325 if log.is_none() {
327 return Ok(None);
328 }
329
330 let sig_bytes = dsse_signature
331 .ok_or_else(|| anyhow::anyhow!("DSSE signature required for log submission"))?;
332
333 let attestation_value: serde_json::Value = serde_json::from_str(attestation_json)
334 .map_err(|e| anyhow::anyhow!("Failed to parse attestation: {e}"))?;
335
336 let pk_hex = if let Some(s) = attestation_value["device_public_key"].as_str() {
338 s.to_string()
339 } else if let Some(key_field) = attestation_value["device_public_key"]["key"].as_str() {
340 key_field.to_string()
341 } else {
342 return Err(anyhow::anyhow!("missing device_public_key"));
343 };
344 let pk_bytes =
345 hex::decode(&pk_hex).map_err(|e| anyhow::anyhow!("invalid public key hex: {e}"))?;
346
347 let pk_curve = match attestation_value["device_public_key"]["curve"].as_str() {
348 Some("ed25519") | Some("Ed25519") => auths_crypto::CurveType::Ed25519,
349 _ => auths_crypto::CurveType::P256,
350 };
351
352 let rt = tokio::runtime::Runtime::new()
353 .map_err(|e| anyhow::anyhow!("Failed to create async runtime: {e}"))?;
354
355 let log_client: std::sync::Arc<dyn auths_sdk::ports::TransparencyLog> = match log.as_deref() {
356 Some("sigstore-rekor") => std::sync::Arc::new(
357 auths_infra_rekor::RekorClient::public()
358 .map_err(|e| anyhow::anyhow!("Failed to create Rekor client: {e}"))?,
359 ),
360 Some(other) => bail!("Unknown log '{}'. Available: sigstore-rekor", other),
361 None => unreachable!(),
362 };
363
364 let submit = || {
365 rt.block_on(auths_sdk::workflows::log_submit::submit_attestation_to_log(
366 attestation_json.as_bytes(),
367 &pk_bytes,
368 pk_curve,
369 sig_bytes,
370 log_client.as_ref(),
371 ))
372 };
373
374 let submission_result = match submit() {
375 Ok(bundle) => Ok(bundle),
376 Err(ref e) if is_rate_limited(e) => {
377 let secs = rate_limit_secs(e);
378 eprintln!("Rate limited by transparency log. Retrying in {secs}s...");
379 std::thread::sleep(std::time::Duration::from_secs(secs));
380 submit()
381 }
382 Err(e) => Err(e),
383 };
384
385 match submission_result {
386 Ok(bundle) => {
387 eprintln!(
388 " Logged to {} at index {}",
389 bundle.log_id, bundle.leaf_index
390 );
391 Ok(Some(serde_json::to_value(&bundle).map_err(|e| {
392 anyhow::anyhow!("Failed to serialize: {e}")
393 })?))
394 }
395 Err(e) => Err(anyhow::anyhow!("Transparency log submission failed: {e}")),
396 }
397}
398
399fn merge_transparency(attestation_json: &str, transparency: serde_json::Value) -> Result<String> {
401 let mut attestation: serde_json::Value = serde_json::from_str(attestation_json)
402 .map_err(|e| anyhow::anyhow!("Failed to re-parse attestation: {e}"))?;
403 if let serde_json::Value::Object(ref mut map) = attestation {
404 map.insert("transparency".to_string(), transparency);
405 }
406 serde_json::to_string_pretty(&attestation)
407 .map_err(|e| anyhow::anyhow!("Failed to serialize attestation: {e}"))
408}
409
410fn resolve_commit_sha_from_flags(
412 commit: Option<String>,
413 no_commit: bool,
414) -> Result<Option<String>> {
415 if no_commit {
416 return Ok(None);
417 }
418 commit
423 .map(|sha| validate_commit_sha(&sha).map_err(anyhow::Error::from))
424 .transpose()
425}
426
427pub fn handle_artifact(
429 cmd: ArtifactCommand,
430 repo_opt: Option<PathBuf>,
431 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
432 env_config: &EnvironmentConfig,
433) -> Result<()> {
434 match cmd.command {
435 ArtifactSubcommand::Sign {
436 file,
437 sig_output,
438 force,
439 key,
440 device_key,
441 expires_in,
442 note,
443 commit,
444 no_commit,
445 ci,
446 curve,
447 ci_platform,
448 oidc_token,
449 oidc_audience,
450 oidc_issuer,
451 oidc_jwks,
452 log,
453 allow_unlogged,
454 } => {
455 if ci {
456 use auths_sdk::domains::signing::ci_env::{
458 CiEnvironment, CiPlatform, detect_ci_environment,
459 };
460
461 let commit_sha = match commit {
462 Some(sha) => sha,
463 None => bail!("--ci requires --commit <sha>. Pass the commit SHA explicitly."),
464 };
465
466 let ci_env = match ci_platform.as_deref() {
469 Some("local") => CiEnvironment {
470 platform: CiPlatform::Local,
471 repository: None,
472 workflow_ref: None,
473 sha: None,
474 run_id: None,
475 actor: None,
476 runner_os: None,
477 },
478 Some(name) => CiEnvironment {
479 platform: CiPlatform::Generic,
480 repository: None,
481 workflow_ref: None,
482 sha: None,
483 run_id: None,
484 actor: None,
485 runner_os: Some(name.to_string()),
486 },
487 None => match detect_ci_environment() {
488 Some(env) => env,
489 None => bail!(
490 "No CI environment detected. If this is intentional (e.g., testing), \
491 pass --ci-platform local. Otherwise run inside GitHub Actions, \
492 GitLab CI, or a recognized CI runner."
493 ),
494 },
495 };
496
497 let ci_env_json = serde_json::to_value(&ci_env)
498 .map_err(|e| anyhow::anyhow!("Failed to serialize CI env: {}", e))?;
499
500 let data = std::fs::read(&file)
501 .with_context(|| format!("Failed to read artifact {:?}", file))?;
502 let artifact_name = file.file_name().map(|n| n.to_string_lossy().to_string());
503
504 #[allow(clippy::disallowed_methods)]
505 let now = chrono::Utc::now();
506
507 let oidc_binding = match &oidc_token {
510 Some(token_path) => {
511 let audience = oidc_audience.as_deref().ok_or_else(|| {
512 anyhow::anyhow!(
513 "--oidc-token requires --oidc-audience <AUD> \
514 (the audience the token was minted for)"
515 )
516 })?;
517 let binding = oidc::resolve_oidc_binding(
518 token_path,
519 &oidc_issuer,
520 audience,
521 oidc_jwks.as_deref(),
522 &ci_env.platform,
523 now,
524 )?;
525 eprintln!(
526 " OIDC identity verified: {} (issuer {})",
527 binding.subject, binding.issuer
528 );
529 Some(binding)
530 }
531 None => None,
532 };
533
534 let result = auths_sdk::domains::signing::service::sign_artifact_ephemeral(
535 now,
536 EphemeralSignRequest {
537 data: &data,
538 artifact_name,
539 commit_sha,
540 curve: curve.unwrap_or_default(),
541 expires_in,
542 note,
543 ci_env: Some(ci_env_json),
544 oidc_binding,
545 },
546 )
547 .map_err(|e| anyhow::anyhow!("Ephemeral signing failed: {}", e))?;
548
549 let transparency_json = submit_to_log(
551 &result.attestation_json,
552 &log,
553 allow_unlogged,
554 result.dsse_signature.as_deref(),
555 )?;
556
557 let final_json = if let Some(transparency) = transparency_json {
558 merge_transparency(&result.attestation_json, transparency)?
559 } else {
560 result.attestation_json.clone()
561 };
562
563 let output_path = sig_output.unwrap_or_else(|| {
564 let mut p = file.clone();
565 let new_name = format!(
566 "{}.auths.json",
567 p.file_name().unwrap_or_default().to_string_lossy()
568 );
569 p.set_file_name(new_name);
570 p
571 });
572
573 sign::ensure_writable(&output_path, force)?;
574
575 std::fs::write(&output_path, &final_json)
576 .with_context(|| format!("Failed to write signature to {:?}", output_path))?;
577
578 println!(
579 "Signed {:?} -> {:?} (ephemeral CI key)",
580 file.file_name().unwrap_or_default(),
581 output_path
582 );
583 println!(" RID: {}", result.rid);
584 println!(" Digest: sha256:{}", result.digest);
585
586 Ok(())
587 } else {
588 let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
590 let resolved_alias = match device_key {
591 Some(alias) => alias,
592 None => crate::commands::key_detect::auto_detect_device_key(
593 repo_opt.as_deref(),
594 env_config,
595 )?,
596 };
597 sign::handle_sign(
598 &file,
599 sig_output,
600 key.as_deref(),
601 &resolved_alias,
602 expires_in,
603 note,
604 commit_sha,
605 repo_opt,
606 passphrase_provider,
607 env_config,
608 &log,
609 allow_unlogged,
610 force,
611 )
612 }
613 }
614 ArtifactSubcommand::Publish {
615 file,
616 signature,
617 package,
618 registry,
619 key,
620 device_key,
621 expires_in,
622 note,
623 commit,
624 no_commit,
625 } => {
626 let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
627 let sig_path = match (signature, file.as_ref()) {
628 (Some(sig), _) => sig,
629 (None, Some(artifact)) => {
630 let default_sig = derive_signature_path(artifact);
631 if default_sig.exists() {
632 default_sig
633 } else {
634 let resolved_alias = match device_key {
635 Some(alias) => alias,
636 None => crate::commands::key_detect::auto_detect_device_key(
637 repo_opt.as_deref(),
638 env_config,
639 )?,
640 };
641 sign::handle_sign(
642 artifact,
643 None,
644 key.as_deref(),
645 &resolved_alias,
646 expires_in,
647 note,
648 commit_sha,
649 repo_opt.clone(),
650 passphrase_provider,
651 env_config,
652 &None,
653 false,
654 false,
655 )?;
656 default_sig
657 }
658 }
659 (None, None) => bail!(
660 "Provide an artifact file to sign-and-publish, or --signature for an existing signature"
661 ),
662 };
663 publish::handle_publish(
664 &sig_path,
665 package.as_deref(),
666 &crate::commands::verify_helpers::require_registry(registry.clone())?,
667 )
668 }
669 ArtifactSubcommand::Verify {
670 file,
671 signature,
672 identity_bundle,
673 witness_receipts,
674 witness_keys,
675 witness_threshold,
676 verify_commit,
677 signature_only,
678 offline,
679 roots,
680 member,
681 signed_at,
682 json,
683 oidc_policy,
684 oidc_policy_did,
685 log_evidence,
686 log_key,
687 expect_signer,
688 require_rooted_signer,
689 } => {
690 if offline {
691 return verify::handle_offline_verify(
692 &file,
693 roots.as_deref(),
694 member.as_deref(),
695 signed_at,
696 json,
697 );
698 }
699 let ephemeral_anchor = if signature_only {
700 verify::EphemeralAnchor::SignatureOnly
701 } else {
702 verify::EphemeralAnchor::Required
703 };
704 let rt = tokio::runtime::Runtime::new()?;
705 rt.block_on(verify::handle_verify(
706 &file,
707 verify::VerifyArtifactArgs {
708 signature,
709 identity_bundle,
710 witness_receipts,
711 witness_keys,
712 witness_threshold,
713 verify_commit,
714 ephemeral_anchor,
715 oidc_policy,
716 oidc_policy_did,
717 log_evidence,
718 log_key,
719 expect_signer,
720 require_rooted_signer,
721 },
722 ))
723 }
724 }
725}
726
727fn derive_signature_path(file: &Path) -> PathBuf {
728 let mut p = file.to_path_buf();
729 let new_name = format!(
730 "{}.auths.json",
731 p.file_name().unwrap_or_default().to_string_lossy()
732 );
733 p.set_file_name(new_name);
734 p
735}
736
737impl crate::commands::executable::ExecutableCommand for ArtifactCommand {
738 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
739 handle_artifact(
740 self.clone(),
741 ctx.repo_path.clone(),
742 ctx.passphrase_provider.clone(),
743 &ctx.env_config,
744 )
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use clap::Parser;
752
753 #[derive(Parser)]
754 struct Cli {
755 #[command(subcommand)]
756 command: ArtifactSubcommand,
757 }
758
759 #[test]
760 fn derive_signature_path_appends_auths_json() {
761 let path = derive_signature_path(Path::new("/tmp/my-pkg-1.0.0.tar.gz"));
762 assert_eq!(path, PathBuf::from("/tmp/my-pkg-1.0.0.tar.gz.auths.json"));
763 }
764
765 #[test]
766 fn derive_signature_path_handles_bare_filename() {
767 let path = derive_signature_path(Path::new("artifact.bin"));
768 assert_eq!(path, PathBuf::from("artifact.bin.auths.json"));
769 }
770
771 #[test]
772 fn publish_accepts_file_positional_arg() {
773 let cli = Cli::try_parse_from(["test", "publish", "my-file.tar.gz"]).unwrap();
774 match cli.command {
775 ArtifactSubcommand::Publish {
776 file, signature, ..
777 } => {
778 assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
779 assert!(signature.is_none());
780 }
781 _ => panic!("expected Publish"),
782 }
783 }
784
785 #[test]
786 fn publish_accepts_signature_flag_without_file() {
787 let cli =
788 Cli::try_parse_from(["test", "publish", "--signature", "my-file.auths.json"]).unwrap();
789 match cli.command {
790 ArtifactSubcommand::Publish {
791 file, signature, ..
792 } => {
793 assert!(file.is_none());
794 assert_eq!(signature, Some(PathBuf::from("my-file.auths.json")));
795 }
796 _ => panic!("expected Publish"),
797 }
798 }
799
800 #[test]
801 fn publish_accepts_both_file_and_signature() {
802 let cli = Cli::try_parse_from([
803 "test",
804 "publish",
805 "my-file.tar.gz",
806 "--signature",
807 "custom.auths.json",
808 ])
809 .unwrap();
810 match cli.command {
811 ArtifactSubcommand::Publish {
812 file, signature, ..
813 } => {
814 assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
815 assert_eq!(signature, Some(PathBuf::from("custom.auths.json")));
816 }
817 _ => panic!("expected Publish"),
818 }
819 }
820
821 #[test]
822 fn publish_accepts_no_args() {
823 let cli = Cli::try_parse_from(["test", "publish"]).unwrap();
824 match cli.command {
825 ArtifactSubcommand::Publish {
826 file, signature, ..
827 } => {
828 assert!(file.is_none());
829 assert!(signature.is_none());
830 }
831 _ => panic!("expected Publish"),
832 }
833 }
834
835 #[test]
836 fn verify_oidc_policy_did_conflicts_with_policy_file() {
837 let err = Cli::try_parse_from([
840 "test",
841 "verify",
842 "a.tar.gz",
843 "--oidc-policy",
844 "p.json",
845 "--oidc-policy-did",
846 "did:keri:EOrg",
847 ]);
848 assert!(err.is_err(), "the two policy sources must be exclusive");
849
850 let ok = Cli::try_parse_from([
851 "test",
852 "verify",
853 "a.tar.gz",
854 "--oidc-policy-did",
855 "did:keri:EOrg",
856 ])
857 .unwrap();
858 match ok.command {
859 ArtifactSubcommand::Verify {
860 oidc_policy,
861 oidc_policy_did,
862 ..
863 } => {
864 assert!(oidc_policy.is_none());
865 assert_eq!(oidc_policy_did.as_deref(), Some("did:keri:EOrg"));
866 }
867 _ => panic!("expected Verify"),
868 }
869 }
870
871 #[test]
872 fn verify_log_evidence_and_log_key_are_a_pair() {
873 assert!(
877 Cli::try_parse_from(["test", "verify", "a.tar.gz", "--log-evidence", "e.json"])
878 .is_err(),
879 "--log-evidence without --log-key must be rejected"
880 );
881 assert!(
882 Cli::try_parse_from(["test", "verify", "a.tar.gz", "--log-key", "ab12"]).is_err(),
883 "--log-key without --log-evidence must be rejected"
884 );
885
886 let ok = Cli::try_parse_from([
887 "test",
888 "verify",
889 "a.tar.gz",
890 "--log-evidence",
891 "e.json",
892 "--log-key",
893 "ab12",
894 ])
895 .unwrap();
896 match ok.command {
897 ArtifactSubcommand::Verify {
898 log_evidence,
899 log_key,
900 ..
901 } => {
902 assert_eq!(log_evidence, Some(PathBuf::from("e.json")));
903 assert_eq!(log_key.as_deref(), Some("ab12"));
904 }
905 _ => panic!("expected Verify"),
906 }
907 }
908
909 #[test]
910 fn publish_forwards_signing_flags() {
911 let cli = Cli::try_parse_from([
912 "test",
913 "publish",
914 "my-file.tar.gz",
915 "--key",
916 "main",
917 "--device-key",
918 "device-1",
919 "--expires-in",
920 "3600",
921 "--note",
922 "release build",
923 ])
924 .unwrap();
925 match cli.command {
926 ArtifactSubcommand::Publish {
927 key,
928 device_key,
929 expires_in,
930 note,
931 ..
932 } => {
933 assert_eq!(key.as_deref(), Some("main"));
934 assert_eq!(device_key.as_deref(), Some("device-1"));
935 assert_eq!(expires_in, Some(3600));
936 assert_eq!(note.as_deref(), Some("release build"));
937 }
938 _ => panic!("expected Publish"),
939 }
940 }
941
942 #[test]
943 fn commit_sha_is_not_inferred_from_ambient_git_state() {
944 let resolved = resolve_commit_sha_from_flags(None, false).unwrap();
949 assert_eq!(
950 resolved, None,
951 "no --commit must embed no commit_sha, got {resolved:?}"
952 );
953 }
954
955 #[test]
956 fn explicit_commit_sha_is_bound() {
957 let sha = "a".repeat(40);
958 let resolved = resolve_commit_sha_from_flags(Some(sha.clone()), false).unwrap();
959 assert_eq!(resolved, Some(sha));
960 }
961
962 #[test]
963 fn no_commit_flag_embeds_nothing() {
964 assert_eq!(resolve_commit_sha_from_flags(None, true).unwrap(), None);
965 }
966}