artifact_publish.rs
1 use std::sync::OnceLock; 2 3 use pyo3::exceptions::{PyConnectionError, PyRuntimeError, PyValueError}; 4 use pyo3::prelude::*; 5 6 use crate::runtime::runtime; 7 8 static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); 9 10 fn http_client() -> &'static reqwest::Client { 11 HTTP_CLIENT.get_or_init(|| { 12 reqwest::Client::builder() 13 .connect_timeout(std::time::Duration::from_secs(30)) 14 .timeout(std::time::Duration::from_secs(60)) 15 .build() 16 .expect("failed to create HTTP client") 17 }) 18 } 19 20 #[pyclass] 21 #[derive(Clone)] 22 pub struct PyArtifactPublishResult { 23 #[pyo3(get)] 24 pub attestation_rid: String, 25 #[pyo3(get)] 26 pub package_name: Option<String>, 27 #[pyo3(get)] 28 pub signer_did: String, 29 } 30 31 #[pymethods] 32 impl PyArtifactPublishResult { 33 fn __repr__(&self) -> String { 34 let rid_short = if self.attestation_rid.len() > 20 { 35 format!("{}...", &self.attestation_rid[..20]) 36 } else { 37 self.attestation_rid.clone() 38 }; 39 let did_tail = if self.signer_did.len() > 12 { 40 &self.signer_did[self.signer_did.len() - 12..] 41 } else { 42 &self.signer_did 43 }; 44 let pkg = match &self.package_name { 45 Some(p) => format!(", pkg={p:?}"), 46 None => String::new(), 47 }; 48 format!("ArtifactPublishResult(rid='{rid_short}'{pkg}, signer='…{did_tail}')") 49 } 50 } 51 52 /// Publish a signed artifact attestation to a registry. 53 /// 54 /// Args: 55 /// * `attestation_json`: The attestation JSON string from `sign_artifact`. 56 /// * `registry_url`: Base URL of the target registry. 57 /// * `package_name`: Optional ecosystem-prefixed package identifier (e.g. `"npm:react@18.3.0"`). 58 /// 59 /// Usage: 60 /// ```ignore 61 /// let result = publish_artifact(py, att_json, "https://registry.example.com", None)?; 62 /// println!("Published: {}", result.attestation_rid); 63 /// ``` 64 #[pyfunction] 65 #[pyo3(signature = (attestation_json, registry_url, package_name=None))] 66 pub fn publish_artifact( 67 py: Python<'_>, 68 attestation_json: String, 69 registry_url: String, 70 package_name: Option<String>, 71 ) -> PyResult<PyArtifactPublishResult> { 72 let attestation: serde_json::Value = serde_json::from_str(&attestation_json) 73 .map_err(|e| PyValueError::new_err(format!("invalid attestation JSON: {e}")))?; 74 75 let url = format!( 76 "{}/v1/artifacts/publish", 77 registry_url.trim_end_matches('/') 78 ); 79 80 py.allow_threads(move || { 81 runtime().block_on(async move { 82 let mut body = serde_json::json!({ "attestation": attestation }); 83 if let Some(ref name) = package_name { 84 body["package_name"] = serde_json::Value::String(name.clone()); 85 } 86 87 let response = http_client() 88 .post(&url) 89 .json(&body) 90 .send() 91 .await 92 .map_err(|e| PyConnectionError::new_err(format!("registry unreachable: {e}")))?; 93 94 match response.status().as_u16() { 95 201 => { 96 #[derive(serde::Deserialize)] 97 struct PublishResponse { 98 attestation_rid: String, 99 package_name: Option<String>, 100 signer_did: String, 101 } 102 let resp: PublishResponse = response.json().await.map_err(|e| { 103 PyRuntimeError::new_err(format!("[AUTHS_NETWORK_ERROR] Invalid registry response: {e}")) 104 })?; 105 Ok(PyArtifactPublishResult { 106 attestation_rid: resp.attestation_rid, 107 package_name: resp.package_name, 108 signer_did: resp.signer_did, 109 }) 110 } 111 409 => Err(PyRuntimeError::new_err( 112 "[AUTHS_REGISTRY_ERROR] Duplicate attestation: artifact attestation already published (duplicate RID)", 113 )), 114 422 => { 115 let body = response.text().await.unwrap_or_default(); 116 Err(PyRuntimeError::new_err(format!( 117 "[AUTHS_VERIFICATION_FAILED] Verification failed: {body}" 118 ))) 119 } 120 status => { 121 let body = response.text().await.unwrap_or_default(); 122 Err(PyRuntimeError::new_err(format!( 123 "[AUTHS_NETWORK_ERROR] Registry error ({status}): {body}" 124 ))) 125 } 126 } 127 }) 128 }) 129 }