Skip to main content

auths_cli/commands/artifact/
mod.rs

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 an artifact file with your Auths identity.
47    Sign {
48        /// Path to the artifact file to sign.
49        #[arg(help = "Path to the artifact file to sign.")]
50        file: PathBuf,
51
52        /// Output path for the signature file. Defaults to <FILE>.auths.json.
53        #[arg(long = "sig-output", value_name = "PATH")]
54        sig_output: Option<PathBuf>,
55
56        /// Overwrite the --sig-output file if it already exists.
57        #[arg(long)]
58        force: bool,
59
60        /// Local alias of the identity key (used for signing). Omit for CI device-only signing.
61        #[arg(
62            long,
63            help = "Local alias of the identity key. Omit for device-only CI signing."
64        )]
65        key: Option<String>,
66
67        /// Local alias of the device key (used for dual-signing).
68        /// Auto-detected when only one key exists for the identity.
69        #[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        /// Duration in seconds until expiration (per RFC 6749).
76        #[arg(long = "expires-in", value_name = "N")]
77        expires_in: Option<u64>,
78
79        /// Optional note to embed in the attestation.
80        #[arg(long)]
81        note: Option<String>,
82
83        /// Git commit SHA to embed in the attestation (provenance binding; embedded only when given, never inferred from git state).
84        #[arg(long, conflicts_with = "no_commit")]
85        commit: Option<String>,
86
87        /// Do not embed any commit SHA in the attestation.
88        #[arg(long, conflicts_with = "commit")]
89        no_commit: bool,
90
91        /// Use ephemeral CI signing (no keychain needed). Requires --commit.
92        #[arg(long)]
93        ci: bool,
94
95        /// Curve for the ephemeral CI key (`p256` or `ed25519`). Only meaningful
96        /// with --ci; defaults to p256.
97        #[arg(long, value_name = "CURVE", requires = "ci")]
98        curve: Option<auths_crypto::CurveType>,
99
100        /// CI platform override when --ci is used outside a detected CI environment.
101        #[arg(long, requires = "ci")]
102        ci_platform: Option<String>,
103
104        /// Path to the runner's OIDC token (the keyless exchange: the token is
105        /// validated against the issuer's JWKS and the verified claims are
106        /// embedded in the signed attestation as an OIDC binding).
107        #[arg(
108            long,
109            value_name = "TOKEN-FILE",
110            requires = "ci",
111            requires = "oidc_audience"
112        )]
113        oidc_token: Option<PathBuf>,
114
115        /// Expected OIDC token audience (exact match). Required with --oidc-token.
116        #[arg(long, value_name = "AUD", requires = "oidc_token")]
117        oidc_audience: Option<String>,
118
119        /// Expected OIDC token issuer (exact match).
120        #[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        /// Pinned JWKS file for offline/air-gapped token validation
129        /// (default: fetch the issuer's published JWKS over HTTPS).
130        #[arg(long, value_name = "JWKS-FILE", requires = "oidc_token")]
131        oidc_jwks: Option<PathBuf>,
132
133        /// Transparency log to submit to (overrides default from trust config).
134        #[arg(long, value_name = "LOG_ID")]
135        log: Option<String>,
136
137        /// Skip transparency log submission (local testing only).
138        /// Produces an unlogged attestation that verifiers reject by default.
139        #[arg(long)]
140        allow_unlogged: bool,
141    },
142
143    /// Sign and publish an artifact attestation to a registry.
144    ///
145    /// Auto-signs the artifact when no --signature is provided.
146    Publish {
147        /// Artifact file to sign and publish (auto-signs if no --signature).
148        #[arg(help = "Artifact file to sign and publish (auto-signs if no --signature).")]
149        file: Option<PathBuf>,
150
151        /// Path to an existing .auths.json signature file. Defaults to <FILE>.auths.json.
152        #[arg(long, value_name = "PATH")]
153        signature: Option<PathBuf>,
154
155        /// Package identifier for registry indexing (e.g., npm:react@18.3.0).
156        #[arg(long)]
157        package: Option<String>,
158
159        /// Registry URL to publish to.
160        #[arg(long, env = "AUTHS_REGISTRY_URL")]
161        registry: Option<String>,
162
163        /// Local alias of the identity key. Omit for device-only CI signing.
164        #[arg(long)]
165        key: Option<String>,
166
167        /// Local alias of the device key. Auto-detected when only one key exists.
168        #[arg(long)]
169        device_key: Option<String>,
170
171        /// Duration in seconds until expiration.
172        #[arg(long = "expires-in", value_name = "N")]
173        expires_in: Option<u64>,
174
175        /// Optional note to embed in the attestation.
176        #[arg(long)]
177        note: Option<String>,
178
179        /// Git commit SHA to embed in the attestation (provenance binding; embedded only when given, never inferred from git state).
180        #[arg(long, conflicts_with = "no_commit")]
181        commit: Option<String>,
182
183        /// Do not embed any commit SHA in the attestation.
184        #[arg(long, conflicts_with = "commit")]
185        no_commit: bool,
186    },
187
188    /// Verify an artifact's signature against an Auths identity.
189    Verify {
190        /// Path to the artifact file to verify.
191        #[arg(help = "Path to the artifact file to verify.")]
192        file: PathBuf,
193
194        /// Path to the signature file. Defaults to <FILE>.auths.json.
195        #[arg(long, value_name = "PATH")]
196        signature: Option<PathBuf>,
197
198        /// Path to identity bundle JSON (for CI/CD stateless verification).
199        #[arg(long, value_parser)]
200        identity_bundle: Option<PathBuf>,
201
202        /// Path to witness signatures JSON file.
203        #[arg(long = "witness-signatures")]
204        witness_receipts: Option<PathBuf>,
205
206        /// Witness public keys as DID:hex pairs (e.g., "did:key:z6Mk...:abcd1234...").
207        #[arg(long, num_args = 1..)]
208        witness_keys: Vec<String>,
209
210        /// Number of witnesses required (default: 1).
211        #[arg(long = "witnesses-required", default_value = "1")]
212        witness_threshold: usize,
213
214        /// Also verify the source commit's signing attestation.
215        #[arg(long)]
216        verify_commit: bool,
217
218        /// For an ephemeral (`did:key:`) attestation, confirm the signature over
219        /// the artifact digest WITHOUT chasing the commit-anchor leg. This is the
220        /// runner's self-check: it has no maintainer repo/roots, but it can prove
221        /// it signed what it emitted. A pass means "digest matches, ephemeral key
222        /// signed it", not "the signer trust-chains to a maintainer".
223        #[arg(long, conflicts_with = "verify_commit")]
224        signature_only: bool,
225
226        /// Verify an air-gapped org bundle entirely offline (no network access).
227        #[arg(long)]
228        offline: bool,
229
230        /// Override the pinned trust roots path (default: `.auths/roots`).
231        #[arg(long, value_name = "PATH")]
232        roots: Option<PathBuf>,
233
234        /// (offline) Member `did:keri` to classify authority for.
235        #[arg(long = "member", visible_alias = "member-did")]
236        member: Option<String>,
237
238        /// (offline) The artifact's in-band signing KEL position.
239        #[arg(long)]
240        signed_at: Option<u128>,
241
242        /// (offline) Emit the typed verdict as JSON.
243        #[arg(long)]
244        json: bool,
245
246        /// OIDC-subject policy file to JOIN against the attestation's signed
247        /// OIDC binding (issuer + repository [+ workflow_ref] the org trusts).
248        /// Fail-closed: a missing binding or any mismatch fails verification.
249        #[arg(long, value_name = "POLICY-FILE")]
250        oidc_policy: Option<PathBuf>,
251
252        /// Resolve the OIDC-subject policy from the org's KEL instead of a
253        /// pinned file: reads the latest policy digest the org anchored
254        /// (`auths org anchor-oidc-policy`) from the local registry and refuses
255        /// a digest mismatch — the witnessed log is the source of truth.
256        #[arg(long, value_name = "ORG-DID", conflicts_with = "oidc_policy")]
257        oidc_policy_did: Option<String>,
258
259        /// Offline transparency-log inclusion evidence for this artifact
260        /// (`auths log prove --out`). Verified fully offline; the verdict's
261        /// `anchored` field reports the outcome. Requires --log-key — an
262        /// inclusion proof without a pinned operator proves nothing.
263        #[arg(long, value_name = "EVIDENCE-FILE", requires = "log_key")]
264        log_evidence: Option<PathBuf>,
265
266        /// The log operator's Ed25519 public key (64 hex chars), pinned out
267        /// of band — never trusted from the evidence file itself.
268        #[arg(long, value_name = "HEX", requires = "log_evidence")]
269        log_key: Option<String>,
270
271        /// Require the verified signer to be exactly this identity (e.g. a release
272        /// signer). Fails closed on a signer mismatch — an allowlist applied after
273        /// verification, it can only narrow a valid verdict, never widen it.
274        #[arg(long = "expect-signer", value_name = "DID")]
275        expect_signer: Option<String>,
276        /// Require the verified signer to be a rooted did:keri identity (a rotatable, revocable
277        /// key-state log), rejecting a bare did:key self-attestation. Fails closed; applied after
278        /// verification, it can only narrow a valid verdict, never widen it.
279        #[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
302/// Re-export DSSE PAE from the SDK for use in CLI signing paths.
303pub use auths_sdk::domains::signing::service::dsse_pae;
304
305/// Submit an attestation to a transparency log and return the JSON to embed.
306///
307/// The `dsse_signature` is the signature over the DSSE PAE of the attestation,
308/// computed by the caller while the signing key is still available.
309///
310/// Returns `None` if `allow_unlogged` is set or `--log` wasn't passed.
311fn 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 wasn't passed, skip silently (non-CI default behavior)
326    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    // device_public_key may be a hex string or {"curve": "...", "key": "..."}
337    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
399/// Merge transparency JSON into an attestation and return the final JSON string.
400fn 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
410/// Resolve the commit SHA from CLI flags.
411fn 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    // A commit SHA records provenance ("this artifact is the subject of that commit"), so
419    // it is bound only when explicitly given with --commit. It is never inferred from the
420    // ambient git HEAD, which would conflate "I signed this file" with "this file came
421    // from the surrounding commit".
422    commit
423        .map(|sha| validate_commit_sha(&sha).map_err(anyhow::Error::from))
424        .transpose()
425}
426
427/// Handle the `artifact` command dispatch.
428pub 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                // Ephemeral CI signing — no keychain, no passphrase
457                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                // Explicit --ci-platform takes precedence over auto-detection so
467                // tests can opt out of the CI runner's auto-detected platform.
468                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                // The keyless exchange, sign side: validate the runner's OIDC
508                // token and embed the verified claims in the signed envelope.
509                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                // Submit to transparency log (unless --allow-unlogged)
550                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                // Standard device-key signing
589                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        // A pinned file and a KEL-resolved policy are two trust postures —
838        // exactly one may be chosen.
839        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        // An inclusion proof without a pinned operator key proves nothing,
874        // and a pinned key with nothing to check is operator error — clap
875        // enforces both-or-neither.
876        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        // Signing without --commit must embed NO commit SHA — never the ambient HEAD of
945        // whatever git tree surrounds the file, which would conflate "I signed this file"
946        // with "this file came from that commit". (This test runs inside a git repo, so a
947        // fallback to HEAD would resolve to a real SHA here.)
948        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}