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    },
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
297/// Re-export DSSE PAE from the SDK for use in CLI signing paths.
298pub use auths_sdk::domains::signing::service::dsse_pae;
299
300/// Submit an attestation to a transparency log and return the JSON to embed.
301///
302/// The `dsse_signature` is the signature over the DSSE PAE of the attestation,
303/// computed by the caller while the signing key is still available.
304///
305/// Returns `None` if `allow_unlogged` is set or `--log` wasn't passed.
306fn 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 wasn't passed, skip silently (non-CI default behavior)
321    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    // device_public_key may be a hex string or {"curve": "...", "key": "..."}
332    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
394/// Merge transparency JSON into an attestation and return the final JSON string.
395fn 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
405/// Resolve the commit SHA from CLI flags.
406fn 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    // A commit SHA records provenance ("this artifact is the subject of that commit"), so
414    // it is bound only when explicitly given with --commit. It is never inferred from the
415    // ambient git HEAD, which would conflate "I signed this file" with "this file came
416    // from the surrounding commit".
417    commit
418        .map(|sha| validate_commit_sha(&sha).map_err(anyhow::Error::from))
419        .transpose()
420}
421
422/// Handle the `artifact` command dispatch.
423pub 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                // Ephemeral CI signing — no keychain, no passphrase
452                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                // Explicit --ci-platform takes precedence over auto-detection so
462                // tests can opt out of the CI runner's auto-detected platform.
463                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                // The keyless exchange, sign side: validate the runner's OIDC
503                // token and embed the verified claims in the signed envelope.
504                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                // Submit to transparency log (unless --allow-unlogged)
545                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                // Standard device-key signing
584                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        // A pinned file and a KEL-resolved policy are two trust postures —
831        // exactly one may be chosen.
832        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        // An inclusion proof without a pinned operator key proves nothing,
867        // and a pinned key with nothing to check is operator error — clap
868        // enforces both-or-neither.
869        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        // Signing without --commit must embed NO commit SHA — never the ambient HEAD of
938        // whatever git tree surrounds the file, which would conflate "I signed this file"
939        // with "this file came from that commit". (This test runs inside a git repo, so a
940        // fallback to HEAD would resolve to a real SHA here.)
941        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}