Skip to main content

auths_cli/commands/artifact/
publish.rs

1use std::path::Path;
2use std::time::Duration;
3
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6
7use crate::ux::format::{JsonResponse, Output, is_json_mode};
8
9#[derive(Serialize)]
10struct PublishJsonResponse {
11    attestation_rid: String,
12    registry: String,
13    package_name: Option<String>,
14    signer_did: String,
15}
16
17#[derive(Deserialize)]
18struct ArtifactPublishResponse {
19    attestation_rid: String,
20    package_name: Option<String>,
21    signer_did: String,
22}
23
24/// Publishes a signed artifact attestation to a registry.
25///
26/// Args:
27/// * `signature_path`: Path to the `.auths.json` signature file.
28/// * `package`: Optional package identifier for registry indexing.
29/// * `registry`: Base URL of the target registry.
30///
31/// Usage:
32/// ```ignore
33/// handle_publish(Path::new("artifact.auths.json"), Some("npm:react@18.3.0"), "https://public.auths.dev")?;
34/// ```
35pub fn handle_publish(signature_path: &Path, package: Option<&str>, registry: &str) -> Result<()> {
36    let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
37    rt.block_on(handle_publish_async(signature_path, package, registry))
38}
39
40fn validate_package_identifier(package: &str) -> Result<String> {
41    let trimmed = package.trim();
42    if trimmed.is_empty() {
43        bail!("Package identifier must not be empty.");
44    }
45    if !trimmed.contains(':') {
46        bail!(
47            "Package identifier must contain an ecosystem prefix (e.g., npm:react@18.3.0), got: {}",
48            trimmed
49        );
50    }
51    if trimmed.chars().any(|c| c.is_ascii_control() || c == ' ') {
52        bail!(
53            "Package identifier must not contain whitespace or control characters, got: {}",
54            trimmed
55        );
56    }
57    Ok(trimmed.to_lowercase())
58}
59
60async fn handle_publish_async(
61    signature_path: &Path,
62    package: Option<&str>,
63    registry: &str,
64) -> Result<()> {
65    if !signature_path.exists() {
66        bail!(
67            "Signature file not found: {:?}\nRun `auths artifact sign` first to create a signature file.",
68            signature_path
69        );
70    }
71
72    let sig_contents = std::fs::read_to_string(signature_path)
73        .with_context(|| format!("Failed to read signature file: {:?}", signature_path))?;
74
75    let attestation: serde_json::Value =
76        serde_json::from_str(&sig_contents).with_context(|| {
77            format!(
78                "Failed to parse signature file as JSON: {:?}",
79                signature_path
80            )
81        })?;
82
83    // Validate the package identifier if provided, but do NOT modify the signed
84    // attestation — the payload is part of the signed canonical data.
85    let package_name = if let Some(pkg) = package {
86        Some(validate_package_identifier(pkg)?)
87    } else {
88        let has_name = attestation
89            .get("payload")
90            .and_then(|p| p.get("name"))
91            .and_then(|n| n.as_str())
92            .is_some_and(|s| !s.is_empty());
93        if !has_name && !is_json_mode() {
94            eprintln!(
95                "Warning: No --package specified and no name in attestation payload. \
96                 This artifact won't be discoverable by package query."
97            );
98        }
99        None
100    };
101
102    let registry_url = registry.trim_end_matches('/');
103    let response = transmit_publish(registry_url, &attestation, package_name.as_deref()).await?;
104    let status = response.status();
105
106    match status.as_u16() {
107        201 => {
108            let body: ArtifactPublishResponse = response
109                .json()
110                .await
111                .context("Failed to parse publish response")?;
112
113            if is_json_mode() {
114                let json_resp = JsonResponse::success(
115                    "artifact publish",
116                    PublishJsonResponse {
117                        attestation_rid: body.attestation_rid.clone(),
118                        registry: registry_url.to_string(),
119                        package_name: body.package_name.clone(),
120                        signer_did: body.signer_did.clone(),
121                    },
122                );
123                json_resp.print()?;
124            } else {
125                let out = Output::stdout();
126                if let Some(ref pkg) = body.package_name {
127                    println!("Anchoring signature for {}...", out.info(pkg));
128                }
129                println!(
130                    "{} Cryptographic attestation anchored at {}",
131                    out.success("Success!"),
132                    out.bold(registry_url)
133                );
134                println!("Attestation RID: {}", out.info(&body.attestation_rid));
135                println!();
136                if let Some(ref pkg) = body.package_name {
137                    println!(
138                        "View your trust graph online: {}/registry?q={}",
139                        registry_url, pkg
140                    );
141                }
142            }
143        }
144        409 => {
145            bail!("Artifact attestation already published (duplicate RID).");
146        }
147        422 => {
148            let body = response.text().await.unwrap_or_default();
149            bail!("Signature verification failed at registry: {}", body);
150        }
151        _ => {
152            let body = response.text().await.unwrap_or_default();
153            bail!("Registry error ({}): {}", status, body);
154        }
155    }
156
157    Ok(())
158}
159
160async fn transmit_publish(
161    registry: &str,
162    attestation: &serde_json::Value,
163    package_name: Option<&str>,
164) -> Result<reqwest::Response> {
165    let client = reqwest::Client::builder()
166        .connect_timeout(Duration::from_secs(30))
167        .timeout(Duration::from_secs(60))
168        .build()
169        .context("Failed to create HTTP client")?;
170
171    let endpoint = format!("{}/v1/artifacts/publish", registry);
172    let mut body = serde_json::json!({ "attestation": attestation });
173    if let Some(name) = package_name {
174        body["package_name"] = serde_json::Value::String(name.to_string());
175    }
176    client
177        .post(&endpoint)
178        .json(&body)
179        .send()
180        .await
181        .context("Failed to connect to registry server")
182}