use std::time::Duration;
use reqwest::{header, Client as ReqwestClient, Method, Url};
use crate::attestation_verify::PinnedKeyMap;
use crate::config::Config;
use crate::errors::{from_http, CleanLibraryError, TransportError};
use crate::types::{
AuditResponse, PolicyPreviewRequest, PolicyPreviewResponse, ScanRequest, ScanResponse, Verdict,
};
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone)]
pub struct Client {
http: ReqwestClient,
base_url: Url,
api_key: Option<String>,
api_version: String,
}
#[derive(Debug, Clone)]
pub enum RemediationOutcome {
Present(serde_json::Value),
NotInSubstrate,
Transient(String),
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AuditFilters<'a> {
pub since: Option<&'a str>,
pub until: Option<&'a str>,
pub decision: Option<&'a str>,
pub ecosystem: Option<&'a str>,
}
impl Client {
pub fn from_config(config: &Config) -> Result<Self, CleanLibraryError> {
Self::build(&config.endpoint.url, config.auth.api_key.clone(), &config.endpoint.api_version)
}
pub fn new(endpoint: &str, api_key: Option<String>) -> Result<Self, CleanLibraryError> {
Self::build(endpoint, api_key, "v1")
}
fn build(
endpoint: &str,
api_key: Option<String>,
api_version: &str,
) -> Result<Self, CleanLibraryError> {
let base_url = Url::parse(endpoint)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", endpoint, e)))?;
let is_localhost = matches!(
base_url.host_str(),
Some("localhost") | Some("127.0.0.1") | Some("::1")
);
if base_url.scheme() != "https" && !is_localhost {
return Err(TransportError::TlsRequired(endpoint.to_string()).into());
}
let http = ReqwestClient::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.user_agent(concat!("cleanlib-cli/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(TransportError::Network)?;
Ok(Self {
http,
base_url,
api_key,
api_version: api_version.to_string(),
})
}
pub async fn verify_attestation(&self, verdict: &Verdict) -> Result<(), CleanLibraryError> {
let envelope = verdict.attestation.as_ref().ok_or_else(|| {
CleanLibraryError::AttestationInvalid {
reason_code: "ATTESTATION_ABSENT".to_string(),
message: "verdict carries no attestation (attestation_status = \
signature_absent, or a v1/pre-attestation response)"
.to_string(),
}
})?;
let lookup = PinnedKeyMap::default();
crate::attestation_verify::verify_attestation(envelope, &lookup).await
}
pub async fn fetch_verdict(
&self,
ecosystem: &str,
package: &str,
version: &str,
) -> Result<Verdict, CleanLibraryError> {
let path = format!(
"{}/customer/verdicts/{}/{}/{}",
self.api_version,
urlencode(ecosystem),
urlencode(package),
urlencode(version),
);
let url = self
.base_url
.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
let response = self.send(Method::GET, url).await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.map_err(TransportError::Network)?;
if !status.is_success() {
return Err(from_http(status.as_u16(), &headers, &body));
}
serde_json::from_str(&body)
.map_err(|e| CleanLibraryError::Parse(format!("verdict response: {}", e)))
}
pub async fn scan(&self, req: &ScanRequest) -> Result<ScanResponse, CleanLibraryError> {
let path = format!("{}/scan", self.api_version);
let url = self
.base_url
.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
let body = serde_json::to_vec(req)
.map_err(|e| CleanLibraryError::Parse(format!("scan body: {}", e)))?;
let response = self
.send_with_body(Method::POST, url, body, "application/json")
.await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.map_err(TransportError::Network)?;
if !status.is_success() {
return Err(from_http(status.as_u16(), &headers, &body));
}
let mut resp: ScanResponse = serde_json::from_str(&body)
.map_err(|e| CleanLibraryError::Parse(format!("scan response: {}", e)))?;
resp.request_id = extract_request_id(&headers);
Ok(resp)
}
pub async fn policy_preview(
&self,
req: &PolicyPreviewRequest,
) -> Result<PolicyPreviewResponse, CleanLibraryError> {
let path = format!("{}/policy/preview", self.api_version);
let url = self
.base_url
.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
let body = serde_json::to_vec(req)
.map_err(|e| CleanLibraryError::Parse(format!("policy_preview body: {}", e)))?;
let response = self.send_with_body(Method::POST, url, body, "application/json").await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.map_err(TransportError::Network)?;
if !status.is_success() {
return Err(from_http(status.as_u16(), &headers, &body));
}
let mut resp: PolicyPreviewResponse = serde_json::from_str(&body)
.map_err(|e| CleanLibraryError::Parse(format!("policy_preview response: {}", e)))?;
resp.request_id = extract_request_id(&headers);
Ok(resp)
}
pub async fn fetch_artifact(
&self,
ecosystem: &str,
package: &str,
version: &str,
) -> Result<Vec<u8>, CleanLibraryError> {
let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
let response = self.send(Method::GET, url).await?;
let status = response.status();
let headers = response.headers().clone();
if !status.is_success() {
let body = response.text().await.map_err(TransportError::Network)?;
return Err(from_http(status.as_u16(), &headers, &body));
}
emit_decision_headers(&headers);
let bytes = response.bytes().await.map_err(TransportError::Network)?;
Ok(bytes.to_vec())
}
pub async fn get_remediation(
&self,
ecosystem: &str,
package: &str,
) -> Result<RemediationOutcome, CleanLibraryError> {
self.remediation_with_mode(ecosystem, package, true).await
}
pub async fn get_remediation_direct(
&self,
ecosystem: &str,
package: &str,
) -> Result<RemediationOutcome, CleanLibraryError> {
self.remediation_with_mode(ecosystem, package, false).await
}
async fn remediation_with_mode(
&self,
ecosystem: &str,
package: &str,
facade: bool,
) -> Result<RemediationOutcome, CleanLibraryError> {
let url = build_remediation_url(&self.base_url, &self.api_version, facade, ecosystem, package)?;
let response = self.send(Method::GET, url).await?;
let status = response.status();
if status.as_u16() == 404 {
return Ok(RemediationOutcome::NotInSubstrate);
}
if status.is_server_error() {
return Ok(RemediationOutcome::Transient(format!("HTTP {}", status.as_u16())));
}
if !status.is_success() {
let headers = response.headers().clone();
let body = response.text().await.map_err(TransportError::Network)?;
return Err(from_http(status.as_u16(), &headers, &body));
}
let body = response.text().await.map_err(TransportError::Network)?;
let json: serde_json::Value =
serde_json::from_str(&body).map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
Ok(RemediationOutcome::Present(json))
}
pub async fn fetch_artifact_stream<W>(
&self,
ecosystem: &str,
package: &str,
version: &str,
writer: &mut W,
) -> Result<u64, CleanLibraryError>
where
W: tokio::io::AsyncWrite + Unpin,
{
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
let response = self.send(Method::GET, url).await?;
let status = response.status();
let headers = response.headers().clone();
if !status.is_success() {
let body = response.text().await.map_err(TransportError::Network)?;
return Err(from_http(status.as_u16(), &headers, &body));
}
emit_decision_headers(&headers);
let mut total: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let bytes = chunk.map_err(TransportError::Network)?;
writer
.write_all(&bytes)
.await
.map_err(|e| CleanLibraryError::Parse(format!("write artifact chunk: {}", e)))?;
total += bytes.len() as u64;
}
writer
.flush()
.await
.map_err(|e| CleanLibraryError::Parse(format!("flush artifact stream: {}", e)))?;
Ok(total)
}
pub async fn audit(
&self,
filters: AuditFilters<'_>,
) -> Result<AuditResponse, CleanLibraryError> {
let path = format!("{}/audit", self.api_version);
let mut url = self
.base_url
.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
{
let mut q = url.query_pairs_mut();
if let Some(s) = filters.since {
q.append_pair("since", s);
}
if let Some(u) = filters.until {
q.append_pair("until", u);
}
if let Some(d) = filters.decision {
q.append_pair("decision", d);
}
if let Some(e) = filters.ecosystem {
q.append_pair("ecosystem", e);
}
}
let response = self.send(Method::GET, url).await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.map_err(TransportError::Network)?;
if !status.is_success() {
return Err(from_http(status.as_u16(), &headers, &body));
}
let mut resp: AuditResponse = serde_json::from_str(&body)
.map_err(|e| CleanLibraryError::Parse(format!("audit response: {}", e)))?;
resp.request_id = extract_request_id(&headers);
Ok(resp)
}
pub async fn probe_auth(&self) -> Result<(), CleanLibraryError> {
let path = format!("{}/audit", self.api_version);
let url = self
.base_url
.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
let response = self.send(Method::GET, url).await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.map_err(TransportError::Network)?;
if status.is_success() {
return Ok(());
}
Err(from_http(status.as_u16(), &headers, &body))
}
async fn send_with_body(
&self,
method: Method,
url: Url,
body: Vec<u8>,
content_type: &str,
) -> Result<reqwest::Response, CleanLibraryError> {
let mut req = self.http.request(method, url).body(body);
req = req.header(header::CONTENT_TYPE, content_type);
if let Some(key) = &self.api_key {
req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
}
req.send().await.map_err(|e| {
if e.is_timeout() {
CleanLibraryError::Transport(TransportError::Timeout)
} else {
CleanLibraryError::Transport(TransportError::Network(e))
}
})
}
pub async fn send(
&self,
method: Method,
url: Url,
) -> Result<reqwest::Response, CleanLibraryError> {
let mut req = self.http.request(method, url);
if let Some(key) = &self.api_key {
req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
}
req.send().await.map_err(|e| {
if e.is_timeout() {
CleanLibraryError::Transport(TransportError::Timeout)
} else {
CleanLibraryError::Transport(TransportError::Network(e))
}
})
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub async fn get_ecosystems(&self) -> Result<Vec<String>, CleanLibraryError> {
let url = self.base_url.join("/health")
.map_err(|e| CleanLibraryError::Transport(
TransportError::InvalidUrl(e.to_string())
))?;
let resp = self.http.get(url).send().await.map_err(|e| {
CleanLibraryError::Transport(TransportError::Network(e))
})?;
let body_text = resp.text().await.map_err(|e| {
CleanLibraryError::Transport(TransportError::Network(e))
})?;
let body: serde_json::Value = serde_json::from_str(&body_text)
.map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
let ecosystems = body["ecosystems_mounted"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
Ok(ecosystems)
}
}
pub(crate) fn extract_request_id(
headers: &reqwest::header::HeaderMap,
) -> Option<String> {
headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn emit_decision_headers(headers: &reqwest::header::HeaderMap) {
if let Some(decision) = headers
.get("X-CleanLibrary-Decision")
.and_then(|v| v.to_str().ok())
{
eprintln!("# decision: {}", decision);
}
if let Some(reason) = headers
.get("X-CleanLibrary-Reason")
.and_then(|v| v.to_str().ok())
{
eprintln!("# reason: {}", reason);
}
}
fn build_fetch_url(
base: &Url,
api_version: &str,
ecosystem: &str,
package: &str,
version: &str,
) -> Result<Url, TransportError> {
let path = format!(
"{}/fetch/{}/{}/{}",
api_version,
urlencode(ecosystem),
urlencode(package),
urlencode(version),
);
base.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
}
fn build_remediation_url(
base: &Url,
api_version: &str,
facade: bool,
ecosystem: &str,
package: &str,
) -> Result<Url, TransportError> {
let path = if facade {
format!(
"{}/customer/remediation/{}/{}",
api_version,
urlencode(ecosystem),
urlencode(package),
)
} else {
format!(
"api/{}/remediation/{}/{}",
api_version,
urlencode(ecosystem),
urlencode(package),
)
};
base.join(&path)
.map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
_ => {
let mut buf = [0u8; 4];
let encoded = c.encode_utf8(&mut buf);
for b in encoded.bytes() {
out.push_str(&format!("%{:02X}", b));
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, EndpointConfig};
fn cfg(url: &str) -> Config {
let mut c = Config::default();
c.endpoint = EndpointConfig {
url: url.to_string(),
api_version: "v1".to_string(),
};
c
}
#[test]
fn refuses_remote_plaintext() {
let err = Client::from_config(&cfg("http://cleanapp.clnstrt.dev")).unwrap_err();
assert!(matches!(
err,
CleanLibraryError::Transport(TransportError::TlsRequired(_))
));
}
#[test]
fn allows_localhost_plaintext_for_testing() {
let client = Client::new("http://localhost:8080", None).unwrap();
assert_eq!(client.base_url().host_str(), Some("localhost"));
}
#[test]
fn allows_127_loopback_plaintext() {
let client = Client::new("http://127.0.0.1:8080", None).unwrap();
assert_eq!(client.base_url().host_str(), Some("127.0.0.1"));
}
#[test]
fn accepts_https_endpoint() {
let client = Client::from_config(&cfg("https://cleanapp.clnstrt.dev")).unwrap();
assert_eq!(client.base_url().as_str(), "https://cleanapp.clnstrt.dev/");
}
#[test]
fn rejects_invalid_url() {
let err = Client::from_config(&cfg("not a url")).unwrap_err();
assert!(matches!(
err,
CleanLibraryError::Transport(TransportError::InvalidUrl(_))
));
}
#[test]
fn urlencode_npm_scoped_pkg() {
assert_eq!(urlencode("@my-org/foo"), "%40my-org%2Ffoo");
}
#[test]
fn remediation_url_facade_is_customer_path() {
let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
let u = build_remediation_url(&base, "v1", true, "npm", "cors").unwrap();
assert_eq!(u.path(), "/v1/customer/remediation/npm/cors");
}
#[test]
fn remediation_url_direct_is_api_v1_path() {
let base = Url::parse("https://cleanlib-enrich.clnstrt.dev").unwrap();
let u = build_remediation_url(&base, "v1", false, "npm", "cors").unwrap();
assert_eq!(u.path(), "/api/v1/remediation/npm/cors");
}
#[test]
fn remediation_url_scoped_package_slash_is_percent_encoded() {
let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
let u = build_remediation_url(&base, "v1", true, "npm", "@babel/core").unwrap();
assert!(u.as_str().contains("%2F"), "scope slash not encoded: {u}");
assert!(
u.as_str().ends_with("/npm/%40babel%2Fcore"),
"want a single %2F-encoded scoped segment, got {u}"
);
}
#[test]
fn urlencode_passes_simple() {
assert_eq!(urlencode("lodash"), "lodash");
assert_eq!(urlencode("4.17.21"), "4.17.21");
assert_eq!(urlencode("github.com/sirupsen/logrus"), "github.com%2Fsirupsen%2Flogrus");
}
#[test]
fn urlencode_maven_coordinate_encodes_colon() {
assert_eq!(
urlencode("org.springframework:spring-beans"),
"org.springframework%3Aspring-beans"
);
assert_eq!(
urlencode("org.apache.logging.log4j:log4j-core"),
"org.apache.logging.log4j%3Alog4j-core"
);
}
#[test]
fn urlencode_handles_unicode() {
assert_eq!(urlencode("é"), "%C3%A9");
}
fn base() -> Url {
Url::parse("https://cleanapp.clnstrt.dev").unwrap()
}
#[test]
fn fetch_url_routes_through_app_v1_fetch_for_npm_bare() {
let url = build_fetch_url(&base(), "v1", "npm", "lodash", "4.17.21").unwrap();
assert_eq!(
url.as_str(),
"https://cleanapp.clnstrt.dev/v1/fetch/npm/lodash/4.17.21"
);
}
#[test]
fn fetch_url_npm_scoped_encodes_at_and_slash() {
let url = build_fetch_url(&base(), "v1", "npm", "@my-org/foo", "1.0.0").unwrap();
assert_eq!(
url.as_str(),
"https://cleanapp.clnstrt.dev/v1/fetch/npm/%40my-org%2Ffoo/1.0.0"
);
}
#[test]
fn fetch_url_go_module_path_slashes_encoded() {
let url = build_fetch_url(&base(), "v1", "go", "github.com/sirupsen/logrus", "v1.9.0")
.unwrap();
assert_eq!(
url.as_str(),
"https://cleanapp.clnstrt.dev/v1/fetch/go/github.com%2Fsirupsen%2Flogrus/v1.9.0"
);
}
#[test]
fn fetch_url_pypi_routes_through_v1_fetch_not_registry_mimic() {
let url = build_fetch_url(&base(), "v1", "pypi", "requests", "2.31.0").unwrap();
assert_eq!(
url.as_str(),
"https://cleanapp.clnstrt.dev/v1/fetch/pypi/requests/2.31.0"
);
assert!(!url.as_str().contains("/pypi/requests/requests-"));
}
#[test]
fn fetch_url_ecosystem_client_side_unopinionated() {
for eco in ["maven", "crates", "nuget", "rubygems", "composer"] {
let url = build_fetch_url(&base(), "v1", eco, "somepkg", "1.0.0").unwrap();
assert_eq!(
url.as_str(),
format!("https://cleanapp.clnstrt.dev/v1/fetch/{}/somepkg/1.0.0", eco)
);
}
}
#[test]
fn fetch_url_maven_coordinates_group_id_encoded() {
let url = build_fetch_url(&base(), "v1", "maven", "junit:junit", "4.13.2").unwrap();
assert_eq!(
url.as_str(),
"https://cleanapp.clnstrt.dev/v1/fetch/maven/junit%3Ajunit/4.13.2"
);
}
#[test]
fn fetch_url_honors_configured_api_version() {
let url = build_fetch_url(&base(), "v2", "npm", "lodash", "4.17.21").unwrap();
assert_eq!(
url.as_str(),
"https://cleanapp.clnstrt.dev/v2/fetch/npm/lodash/4.17.21"
);
}
fn header_map(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
let mut m = reqwest::header::HeaderMap::new();
for (k, v) in pairs {
m.insert(
reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
reqwest::header::HeaderValue::from_str(v).unwrap(),
);
}
m
}
#[test]
fn cleanlib_480_extract_request_id_reads_lowercase_header() {
let h = header_map(&[("x-request-id", "01M1KWQ41SPRAP551FGW5ZN4RF")]);
assert_eq!(
extract_request_id(&h).as_deref(),
Some("01M1KWQ41SPRAP551FGW5ZN4RF")
);
}
#[test]
fn cleanlib_480_extract_request_id_reads_mixed_case_header() {
let h = header_map(&[("X-Request-Id", "01ABC")]);
assert_eq!(extract_request_id(&h).as_deref(), Some("01ABC"));
}
#[test]
fn cleanlib_480_extract_request_id_absent_returns_none() {
let h = header_map(&[("content-type", "application/json")]);
assert!(extract_request_id(&h).is_none());
}
#[test]
fn cleanlib_480_extract_request_id_non_utf8_value_returns_none() {
let mut h = reqwest::header::HeaderMap::new();
h.insert(
reqwest::header::HeaderName::from_static("x-request-id"),
reqwest::header::HeaderValue::from_bytes(&[0x80, 0xFF]).unwrap(),
);
assert!(extract_request_id(&h).is_none());
}
}