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 },
277}
278
279fn is_rate_limited(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> bool {
280 matches!(
281 err,
282 auths_sdk::workflows::log_submit::LogSubmitError::LogError(
283 auths_sdk::ports::LogError::RateLimited { .. }
284 )
285 )
286}
287
288fn rate_limit_secs(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> u64 {
289 match err {
290 auths_sdk::workflows::log_submit::LogSubmitError::LogError(
291 auths_sdk::ports::LogError::RateLimited { retry_after_secs },
292 ) => *retry_after_secs,
293 _ => 10,
294 }
295}
296
297pub use auths_sdk::domains::signing::service::dsse_pae;
299
300fn submit_to_log(
307 attestation_json: &str,
308 log: &Option<String>,
309 allow_unlogged: bool,
310 dsse_signature: Option<&[u8]>,
311) -> Result<Option<serde_json::Value>> {
312 if allow_unlogged {
313 eprintln!(
314 "WARNING: Signing without transparency log. \
315 This artifact will not be verifiable against any log."
316 );
317 return Ok(None);
318 }
319
320 if log.is_none() {
322 return Ok(None);
323 }
324
325 let sig_bytes = dsse_signature
326 .ok_or_else(|| anyhow::anyhow!("DSSE signature required for log submission"))?;
327
328 let attestation_value: serde_json::Value = serde_json::from_str(attestation_json)
329 .map_err(|e| anyhow::anyhow!("Failed to parse attestation: {e}"))?;
330
331 let pk_hex = if let Some(s) = attestation_value["device_public_key"].as_str() {
333 s.to_string()
334 } else if let Some(key_field) = attestation_value["device_public_key"]["key"].as_str() {
335 key_field.to_string()
336 } else {
337 return Err(anyhow::anyhow!("missing device_public_key"));
338 };
339 let pk_bytes =
340 hex::decode(&pk_hex).map_err(|e| anyhow::anyhow!("invalid public key hex: {e}"))?;
341
342 let pk_curve = match attestation_value["device_public_key"]["curve"].as_str() {
343 Some("ed25519") | Some("Ed25519") => auths_crypto::CurveType::Ed25519,
344 _ => auths_crypto::CurveType::P256,
345 };
346
347 let rt = tokio::runtime::Runtime::new()
348 .map_err(|e| anyhow::anyhow!("Failed to create async runtime: {e}"))?;
349
350 let log_client: std::sync::Arc<dyn auths_sdk::ports::TransparencyLog> = match log.as_deref() {
351 Some("sigstore-rekor") => std::sync::Arc::new(
352 auths_infra_rekor::RekorClient::public()
353 .map_err(|e| anyhow::anyhow!("Failed to create Rekor client: {e}"))?,
354 ),
355 Some(other) => bail!("Unknown log '{}'. Available: sigstore-rekor", other),
356 None => unreachable!(),
357 };
358
359 let submit = || {
360 rt.block_on(auths_sdk::workflows::log_submit::submit_attestation_to_log(
361 attestation_json.as_bytes(),
362 &pk_bytes,
363 pk_curve,
364 sig_bytes,
365 log_client.as_ref(),
366 ))
367 };
368
369 let submission_result = match submit() {
370 Ok(bundle) => Ok(bundle),
371 Err(ref e) if is_rate_limited(e) => {
372 let secs = rate_limit_secs(e);
373 eprintln!("Rate limited by transparency log. Retrying in {secs}s...");
374 std::thread::sleep(std::time::Duration::from_secs(secs));
375 submit()
376 }
377 Err(e) => Err(e),
378 };
379
380 match submission_result {
381 Ok(bundle) => {
382 eprintln!(
383 " Logged to {} at index {}",
384 bundle.log_id, bundle.leaf_index
385 );
386 Ok(Some(serde_json::to_value(&bundle).map_err(|e| {
387 anyhow::anyhow!("Failed to serialize: {e}")
388 })?))
389 }
390 Err(e) => Err(anyhow::anyhow!("Transparency log submission failed: {e}")),
391 }
392}
393
394fn merge_transparency(attestation_json: &str, transparency: serde_json::Value) -> Result<String> {
396 let mut attestation: serde_json::Value = serde_json::from_str(attestation_json)
397 .map_err(|e| anyhow::anyhow!("Failed to re-parse attestation: {e}"))?;
398 if let serde_json::Value::Object(ref mut map) = attestation {
399 map.insert("transparency".to_string(), transparency);
400 }
401 serde_json::to_string_pretty(&attestation)
402 .map_err(|e| anyhow::anyhow!("Failed to serialize attestation: {e}"))
403}
404
405fn resolve_commit_sha_from_flags(
407 commit: Option<String>,
408 no_commit: bool,
409) -> Result<Option<String>> {
410 if no_commit {
411 return Ok(None);
412 }
413 commit
418 .map(|sha| validate_commit_sha(&sha).map_err(anyhow::Error::from))
419 .transpose()
420}
421
422pub fn handle_artifact(
424 cmd: ArtifactCommand,
425 repo_opt: Option<PathBuf>,
426 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
427 env_config: &EnvironmentConfig,
428) -> Result<()> {
429 match cmd.command {
430 ArtifactSubcommand::Sign {
431 file,
432 sig_output,
433 force,
434 key,
435 device_key,
436 expires_in,
437 note,
438 commit,
439 no_commit,
440 ci,
441 curve,
442 ci_platform,
443 oidc_token,
444 oidc_audience,
445 oidc_issuer,
446 oidc_jwks,
447 log,
448 allow_unlogged,
449 } => {
450 if ci {
451 use auths_sdk::domains::signing::ci_env::{
453 CiEnvironment, CiPlatform, detect_ci_environment,
454 };
455
456 let commit_sha = match commit {
457 Some(sha) => sha,
458 None => bail!("--ci requires --commit <sha>. Pass the commit SHA explicitly."),
459 };
460
461 let ci_env = match ci_platform.as_deref() {
464 Some("local") => CiEnvironment {
465 platform: CiPlatform::Local,
466 repository: None,
467 workflow_ref: None,
468 sha: None,
469 run_id: None,
470 actor: None,
471 runner_os: None,
472 },
473 Some(name) => CiEnvironment {
474 platform: CiPlatform::Generic,
475 repository: None,
476 workflow_ref: None,
477 sha: None,
478 run_id: None,
479 actor: None,
480 runner_os: Some(name.to_string()),
481 },
482 None => match detect_ci_environment() {
483 Some(env) => env,
484 None => bail!(
485 "No CI environment detected. If this is intentional (e.g., testing), \
486 pass --ci-platform local. Otherwise run inside GitHub Actions, \
487 GitLab CI, or a recognized CI runner."
488 ),
489 },
490 };
491
492 let ci_env_json = serde_json::to_value(&ci_env)
493 .map_err(|e| anyhow::anyhow!("Failed to serialize CI env: {}", e))?;
494
495 let data = std::fs::read(&file)
496 .with_context(|| format!("Failed to read artifact {:?}", file))?;
497 let artifact_name = file.file_name().map(|n| n.to_string_lossy().to_string());
498
499 #[allow(clippy::disallowed_methods)]
500 let now = chrono::Utc::now();
501
502 let oidc_binding = match &oidc_token {
505 Some(token_path) => {
506 let audience = oidc_audience.as_deref().ok_or_else(|| {
507 anyhow::anyhow!(
508 "--oidc-token requires --oidc-audience <AUD> \
509 (the audience the token was minted for)"
510 )
511 })?;
512 let binding = oidc::resolve_oidc_binding(
513 token_path,
514 &oidc_issuer,
515 audience,
516 oidc_jwks.as_deref(),
517 &ci_env.platform,
518 now,
519 )?;
520 eprintln!(
521 " OIDC identity verified: {} (issuer {})",
522 binding.subject, binding.issuer
523 );
524 Some(binding)
525 }
526 None => None,
527 };
528
529 let result = auths_sdk::domains::signing::service::sign_artifact_ephemeral(
530 now,
531 EphemeralSignRequest {
532 data: &data,
533 artifact_name,
534 commit_sha,
535 curve: curve.unwrap_or_default(),
536 expires_in,
537 note,
538 ci_env: Some(ci_env_json),
539 oidc_binding,
540 },
541 )
542 .map_err(|e| anyhow::anyhow!("Ephemeral signing failed: {}", e))?;
543
544 let transparency_json = submit_to_log(
546 &result.attestation_json,
547 &log,
548 allow_unlogged,
549 result.dsse_signature.as_deref(),
550 )?;
551
552 let final_json = if let Some(transparency) = transparency_json {
553 merge_transparency(&result.attestation_json, transparency)?
554 } else {
555 result.attestation_json.clone()
556 };
557
558 let output_path = sig_output.unwrap_or_else(|| {
559 let mut p = file.clone();
560 let new_name = format!(
561 "{}.auths.json",
562 p.file_name().unwrap_or_default().to_string_lossy()
563 );
564 p.set_file_name(new_name);
565 p
566 });
567
568 sign::ensure_writable(&output_path, force)?;
569
570 std::fs::write(&output_path, &final_json)
571 .with_context(|| format!("Failed to write signature to {:?}", output_path))?;
572
573 println!(
574 "Signed {:?} -> {:?} (ephemeral CI key)",
575 file.file_name().unwrap_or_default(),
576 output_path
577 );
578 println!(" RID: {}", result.rid);
579 println!(" Digest: sha256:{}", result.digest);
580
581 Ok(())
582 } else {
583 let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
585 let resolved_alias = match device_key {
586 Some(alias) => alias,
587 None => crate::commands::key_detect::auto_detect_device_key(
588 repo_opt.as_deref(),
589 env_config,
590 )?,
591 };
592 sign::handle_sign(
593 &file,
594 sig_output,
595 key.as_deref(),
596 &resolved_alias,
597 expires_in,
598 note,
599 commit_sha,
600 repo_opt,
601 passphrase_provider,
602 env_config,
603 &log,
604 allow_unlogged,
605 force,
606 )
607 }
608 }
609 ArtifactSubcommand::Publish {
610 file,
611 signature,
612 package,
613 registry,
614 key,
615 device_key,
616 expires_in,
617 note,
618 commit,
619 no_commit,
620 } => {
621 let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
622 let sig_path = match (signature, file.as_ref()) {
623 (Some(sig), _) => sig,
624 (None, Some(artifact)) => {
625 let default_sig = derive_signature_path(artifact);
626 if default_sig.exists() {
627 default_sig
628 } else {
629 let resolved_alias = match device_key {
630 Some(alias) => alias,
631 None => crate::commands::key_detect::auto_detect_device_key(
632 repo_opt.as_deref(),
633 env_config,
634 )?,
635 };
636 sign::handle_sign(
637 artifact,
638 None,
639 key.as_deref(),
640 &resolved_alias,
641 expires_in,
642 note,
643 commit_sha,
644 repo_opt.clone(),
645 passphrase_provider,
646 env_config,
647 &None,
648 false,
649 false,
650 )?;
651 default_sig
652 }
653 }
654 (None, None) => bail!(
655 "Provide an artifact file to sign-and-publish, or --signature for an existing signature"
656 ),
657 };
658 publish::handle_publish(
659 &sig_path,
660 package.as_deref(),
661 &crate::commands::verify_helpers::require_registry(registry.clone())?,
662 )
663 }
664 ArtifactSubcommand::Verify {
665 file,
666 signature,
667 identity_bundle,
668 witness_receipts,
669 witness_keys,
670 witness_threshold,
671 verify_commit,
672 signature_only,
673 offline,
674 roots,
675 member,
676 signed_at,
677 json,
678 oidc_policy,
679 oidc_policy_did,
680 log_evidence,
681 log_key,
682 expect_signer,
683 } => {
684 if offline {
685 return verify::handle_offline_verify(
686 &file,
687 roots.as_deref(),
688 member.as_deref(),
689 signed_at,
690 json,
691 );
692 }
693 let ephemeral_anchor = if signature_only {
694 verify::EphemeralAnchor::SignatureOnly
695 } else {
696 verify::EphemeralAnchor::Required
697 };
698 let rt = tokio::runtime::Runtime::new()?;
699 rt.block_on(verify::handle_verify(
700 &file,
701 verify::VerifyArtifactArgs {
702 signature,
703 identity_bundle,
704 witness_receipts,
705 witness_keys,
706 witness_threshold,
707 verify_commit,
708 ephemeral_anchor,
709 oidc_policy,
710 oidc_policy_did,
711 log_evidence,
712 log_key,
713 expect_signer,
714 },
715 ))
716 }
717 }
718}
719
720fn derive_signature_path(file: &Path) -> PathBuf {
721 let mut p = file.to_path_buf();
722 let new_name = format!(
723 "{}.auths.json",
724 p.file_name().unwrap_or_default().to_string_lossy()
725 );
726 p.set_file_name(new_name);
727 p
728}
729
730impl crate::commands::executable::ExecutableCommand for ArtifactCommand {
731 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
732 handle_artifact(
733 self.clone(),
734 ctx.repo_path.clone(),
735 ctx.passphrase_provider.clone(),
736 &ctx.env_config,
737 )
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use clap::Parser;
745
746 #[derive(Parser)]
747 struct Cli {
748 #[command(subcommand)]
749 command: ArtifactSubcommand,
750 }
751
752 #[test]
753 fn derive_signature_path_appends_auths_json() {
754 let path = derive_signature_path(Path::new("/tmp/my-pkg-1.0.0.tar.gz"));
755 assert_eq!(path, PathBuf::from("/tmp/my-pkg-1.0.0.tar.gz.auths.json"));
756 }
757
758 #[test]
759 fn derive_signature_path_handles_bare_filename() {
760 let path = derive_signature_path(Path::new("artifact.bin"));
761 assert_eq!(path, PathBuf::from("artifact.bin.auths.json"));
762 }
763
764 #[test]
765 fn publish_accepts_file_positional_arg() {
766 let cli = Cli::try_parse_from(["test", "publish", "my-file.tar.gz"]).unwrap();
767 match cli.command {
768 ArtifactSubcommand::Publish {
769 file, signature, ..
770 } => {
771 assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
772 assert!(signature.is_none());
773 }
774 _ => panic!("expected Publish"),
775 }
776 }
777
778 #[test]
779 fn publish_accepts_signature_flag_without_file() {
780 let cli =
781 Cli::try_parse_from(["test", "publish", "--signature", "my-file.auths.json"]).unwrap();
782 match cli.command {
783 ArtifactSubcommand::Publish {
784 file, signature, ..
785 } => {
786 assert!(file.is_none());
787 assert_eq!(signature, Some(PathBuf::from("my-file.auths.json")));
788 }
789 _ => panic!("expected Publish"),
790 }
791 }
792
793 #[test]
794 fn publish_accepts_both_file_and_signature() {
795 let cli = Cli::try_parse_from([
796 "test",
797 "publish",
798 "my-file.tar.gz",
799 "--signature",
800 "custom.auths.json",
801 ])
802 .unwrap();
803 match cli.command {
804 ArtifactSubcommand::Publish {
805 file, signature, ..
806 } => {
807 assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
808 assert_eq!(signature, Some(PathBuf::from("custom.auths.json")));
809 }
810 _ => panic!("expected Publish"),
811 }
812 }
813
814 #[test]
815 fn publish_accepts_no_args() {
816 let cli = Cli::try_parse_from(["test", "publish"]).unwrap();
817 match cli.command {
818 ArtifactSubcommand::Publish {
819 file, signature, ..
820 } => {
821 assert!(file.is_none());
822 assert!(signature.is_none());
823 }
824 _ => panic!("expected Publish"),
825 }
826 }
827
828 #[test]
829 fn verify_oidc_policy_did_conflicts_with_policy_file() {
830 let err = Cli::try_parse_from([
833 "test",
834 "verify",
835 "a.tar.gz",
836 "--oidc-policy",
837 "p.json",
838 "--oidc-policy-did",
839 "did:keri:EOrg",
840 ]);
841 assert!(err.is_err(), "the two policy sources must be exclusive");
842
843 let ok = Cli::try_parse_from([
844 "test",
845 "verify",
846 "a.tar.gz",
847 "--oidc-policy-did",
848 "did:keri:EOrg",
849 ])
850 .unwrap();
851 match ok.command {
852 ArtifactSubcommand::Verify {
853 oidc_policy,
854 oidc_policy_did,
855 ..
856 } => {
857 assert!(oidc_policy.is_none());
858 assert_eq!(oidc_policy_did.as_deref(), Some("did:keri:EOrg"));
859 }
860 _ => panic!("expected Verify"),
861 }
862 }
863
864 #[test]
865 fn verify_log_evidence_and_log_key_are_a_pair() {
866 assert!(
870 Cli::try_parse_from(["test", "verify", "a.tar.gz", "--log-evidence", "e.json"])
871 .is_err(),
872 "--log-evidence without --log-key must be rejected"
873 );
874 assert!(
875 Cli::try_parse_from(["test", "verify", "a.tar.gz", "--log-key", "ab12"]).is_err(),
876 "--log-key without --log-evidence must be rejected"
877 );
878
879 let ok = Cli::try_parse_from([
880 "test",
881 "verify",
882 "a.tar.gz",
883 "--log-evidence",
884 "e.json",
885 "--log-key",
886 "ab12",
887 ])
888 .unwrap();
889 match ok.command {
890 ArtifactSubcommand::Verify {
891 log_evidence,
892 log_key,
893 ..
894 } => {
895 assert_eq!(log_evidence, Some(PathBuf::from("e.json")));
896 assert_eq!(log_key.as_deref(), Some("ab12"));
897 }
898 _ => panic!("expected Verify"),
899 }
900 }
901
902 #[test]
903 fn publish_forwards_signing_flags() {
904 let cli = Cli::try_parse_from([
905 "test",
906 "publish",
907 "my-file.tar.gz",
908 "--key",
909 "main",
910 "--device-key",
911 "device-1",
912 "--expires-in",
913 "3600",
914 "--note",
915 "release build",
916 ])
917 .unwrap();
918 match cli.command {
919 ArtifactSubcommand::Publish {
920 key,
921 device_key,
922 expires_in,
923 note,
924 ..
925 } => {
926 assert_eq!(key.as_deref(), Some("main"));
927 assert_eq!(device_key.as_deref(), Some("device-1"));
928 assert_eq!(expires_in, Some(3600));
929 assert_eq!(note.as_deref(), Some("release build"));
930 }
931 _ => panic!("expected Publish"),
932 }
933 }
934
935 #[test]
936 fn commit_sha_is_not_inferred_from_ambient_git_state() {
937 let resolved = resolve_commit_sha_from_flags(None, false).unwrap();
942 assert_eq!(
943 resolved, None,
944 "no --commit must embed no commit_sha, got {resolved:?}"
945 );
946 }
947
948 #[test]
949 fn explicit_commit_sha_is_bound() {
950 let sha = "a".repeat(40);
951 let resolved = resolve_commit_sha_from_flags(Some(sha.clone()), false).unwrap();
952 assert_eq!(resolved, Some(sha));
953 }
954
955 #[test]
956 fn no_commit_flag_embeds_nothing() {
957 assert_eq!(resolve_commit_sha_from_flags(None, true).unwrap(), None);
958 }
959}