1use std::time::Duration;
9
10use reqwest::{header, Client as ReqwestClient, Method, Url};
11
12use crate::config::Config;
13use crate::errors::{from_http, CleanLibraryError, TransportError};
14use crate::types::{
15 AuditResponse, PolicyPreviewRequest, PolicyPreviewResponse, ScanRequest, ScanResponse, Verdict,
16};
17
18const DEFAULT_TIMEOUT_SECS: u64 = 30;
21
22#[derive(Debug, Clone)]
25pub struct Client {
26 http: ReqwestClient,
27 base_url: Url,
28 api_key: Option<String>,
29 api_version: String,
30}
31
32impl Client {
33 pub fn from_config(config: &Config) -> Result<Self, CleanLibraryError> {
36 Self::build(&config.endpoint.url, config.auth.api_key.clone(), &config.endpoint.api_version)
37 }
38
39 pub fn new(endpoint: &str, api_key: Option<String>) -> Result<Self, CleanLibraryError> {
42 Self::build(endpoint, api_key, "v1")
43 }
44
45 fn build(
46 endpoint: &str,
47 api_key: Option<String>,
48 api_version: &str,
49 ) -> Result<Self, CleanLibraryError> {
50 let base_url = Url::parse(endpoint)
51 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", endpoint, e)))?;
52
53 let is_localhost = matches!(
54 base_url.host_str(),
55 Some("localhost") | Some("127.0.0.1") | Some("::1")
56 );
57 if base_url.scheme() != "https" && !is_localhost {
58 return Err(TransportError::TlsRequired(endpoint.to_string()).into());
59 }
60
61 let http = ReqwestClient::builder()
62 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
63 .user_agent(concat!("cleanlib-cli/", env!("CARGO_PKG_VERSION")))
64 .build()
65 .map_err(TransportError::Network)?;
66
67 Ok(Self {
68 http,
69 base_url,
70 api_key,
71 api_version: api_version.to_string(),
72 })
73 }
74
75 pub async fn fetch_verdict(
78 &self,
79 ecosystem: &str,
80 package: &str,
81 version: &str,
82 ) -> Result<Verdict, CleanLibraryError> {
83 let path = format!(
84 "{}/customer/verdicts/{}/{}/{}",
85 self.api_version,
86 urlencode(ecosystem),
87 urlencode(package),
88 urlencode(version),
89 );
90 let url = self
91 .base_url
92 .join(&path)
93 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
94
95 let response = self.send(Method::GET, url).await?;
96 let status = response.status();
97 let headers = response.headers().clone();
98 let body = response.text().await.map_err(TransportError::Network)?;
99
100 if !status.is_success() {
101 return Err(from_http(status.as_u16(), &headers, &body));
102 }
103
104 serde_json::from_str(&body)
105 .map_err(|e| CleanLibraryError::Parse(format!("verdict response: {}", e)))
106 }
107
108 pub async fn scan(&self, req: &ScanRequest) -> Result<ScanResponse, CleanLibraryError> {
123 let path = format!("{}/scan", self.api_version);
124 let url = self
125 .base_url
126 .join(&path)
127 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
128
129 let body = serde_json::to_vec(req)
130 .map_err(|e| CleanLibraryError::Parse(format!("scan body: {}", e)))?;
131
132 let response = self
133 .send_with_body(Method::POST, url, body, "application/json")
134 .await?;
135 let status = response.status();
136 let headers = response.headers().clone();
137 let body = response.text().await.map_err(TransportError::Network)?;
138
139 if !status.is_success() {
140 return Err(from_http(status.as_u16(), &headers, &body));
141 }
142
143 serde_json::from_str(&body)
144 .map_err(|e| CleanLibraryError::Parse(format!("scan response: {}", e)))
145 }
146
147 pub async fn policy_preview(
159 &self,
160 req: &PolicyPreviewRequest,
161 ) -> Result<PolicyPreviewResponse, CleanLibraryError> {
162 let path = format!("{}/policy/preview", self.api_version);
163 let url = self
164 .base_url
165 .join(&path)
166 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
167
168 let body = serde_json::to_vec(req)
169 .map_err(|e| CleanLibraryError::Parse(format!("policy_preview body: {}", e)))?;
170
171 let response = self.send_with_body(Method::POST, url, body, "application/json").await?;
172 let status = response.status();
173 let headers = response.headers().clone();
174 let body = response.text().await.map_err(TransportError::Network)?;
175
176 if !status.is_success() {
177 return Err(from_http(status.as_u16(), &headers, &body));
178 }
179
180 serde_json::from_str(&body)
181 .map_err(|e| CleanLibraryError::Parse(format!("policy_preview response: {}", e)))
182 }
183
184 pub async fn fetch_artifact(
199 &self,
200 ecosystem: &str,
201 package: &str,
202 version: &str,
203 ) -> Result<Vec<u8>, CleanLibraryError> {
204 let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
205 let response = self.send(Method::GET, url).await?;
206 let status = response.status();
207 let headers = response.headers().clone();
208
209 if !status.is_success() {
210 let body = response.text().await.map_err(TransportError::Network)?;
211 return Err(from_http(status.as_u16(), &headers, &body));
212 }
213
214 emit_decision_headers(&headers);
215
216 let bytes = response.bytes().await.map_err(TransportError::Network)?;
217 Ok(bytes.to_vec())
218 }
219
220 pub async fn fetch_artifact_stream<W>(
228 &self,
229 ecosystem: &str,
230 package: &str,
231 version: &str,
232 writer: &mut W,
233 ) -> Result<u64, CleanLibraryError>
234 where
235 W: tokio::io::AsyncWrite + Unpin,
236 {
237 use futures_util::StreamExt;
238 use tokio::io::AsyncWriteExt;
239
240 let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
241 let response = self.send(Method::GET, url).await?;
242 let status = response.status();
243 let headers = response.headers().clone();
244
245 if !status.is_success() {
246 let body = response.text().await.map_err(TransportError::Network)?;
247 return Err(from_http(status.as_u16(), &headers, &body));
248 }
249
250 emit_decision_headers(&headers);
251
252 let mut total: u64 = 0;
253 let mut stream = response.bytes_stream();
254 while let Some(chunk) = stream.next().await {
255 let bytes = chunk.map_err(TransportError::Network)?;
256 writer
257 .write_all(&bytes)
258 .await
259 .map_err(|e| CleanLibraryError::Parse(format!("write artifact chunk: {}", e)))?;
260 total += bytes.len() as u64;
261 }
262 writer
263 .flush()
264 .await
265 .map_err(|e| CleanLibraryError::Parse(format!("flush artifact stream: {}", e)))?;
266 Ok(total)
267 }
268
269 pub async fn audit(
277 &self,
278 since: Option<&str>,
279 decision: Option<&str>,
280 ecosystem: Option<&str>,
281 ) -> Result<AuditResponse, CleanLibraryError> {
282 let path = format!("{}/audit", self.api_version);
283 let mut url = self
284 .base_url
285 .join(&path)
286 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
287 {
288 let mut q = url.query_pairs_mut();
289 if let Some(s) = since {
290 q.append_pair("since", s);
291 }
292 if let Some(d) = decision {
293 q.append_pair("decision", d);
294 }
295 if let Some(e) = ecosystem {
296 q.append_pair("ecosystem", e);
297 }
298 }
299
300 let response = self.send(Method::GET, url).await?;
301 let status = response.status();
302 let headers = response.headers().clone();
303 let body = response.text().await.map_err(TransportError::Network)?;
304
305 if !status.is_success() {
306 return Err(from_http(status.as_u16(), &headers, &body));
307 }
308
309 serde_json::from_str(&body)
310 .map_err(|e| CleanLibraryError::Parse(format!("audit response: {}", e)))
311 }
312
313 pub async fn probe_auth(&self) -> Result<(), CleanLibraryError> {
314 let path = format!("{}/audit", self.api_version);
315 let url = self
316 .base_url
317 .join(&path)
318 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
319 let response = self.send(Method::GET, url).await?;
320 let status = response.status();
321 let headers = response.headers().clone();
322 let body = response.text().await.map_err(TransportError::Network)?;
323 if status.is_success() {
324 return Ok(());
325 }
326 Err(from_http(status.as_u16(), &headers, &body))
327 }
328
329 async fn send_with_body(
331 &self,
332 method: Method,
333 url: Url,
334 body: Vec<u8>,
335 content_type: &str,
336 ) -> Result<reqwest::Response, CleanLibraryError> {
337 let mut req = self.http.request(method, url).body(body);
338 req = req.header(header::CONTENT_TYPE, content_type);
339 if let Some(key) = &self.api_key {
340 req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
341 }
342 req.send().await.map_err(|e| {
343 if e.is_timeout() {
344 CleanLibraryError::Transport(TransportError::Timeout)
345 } else {
346 CleanLibraryError::Transport(TransportError::Network(e))
347 }
348 })
349 }
350
351 pub async fn send(
354 &self,
355 method: Method,
356 url: Url,
357 ) -> Result<reqwest::Response, CleanLibraryError> {
358 let mut req = self.http.request(method, url);
359 if let Some(key) = &self.api_key {
360 req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
361 }
362 req.send().await.map_err(|e| {
363 if e.is_timeout() {
364 CleanLibraryError::Transport(TransportError::Timeout)
365 } else {
366 CleanLibraryError::Transport(TransportError::Network(e))
367 }
368 })
369 }
370
371 pub fn base_url(&self) -> &Url {
373 &self.base_url
374 }
375
376 pub async fn get_ecosystems(&self) -> Result<Vec<String>, CleanLibraryError> {
378 let url = self.base_url.join("/health")
379 .map_err(|e| CleanLibraryError::Transport(
380 TransportError::InvalidUrl(e.to_string())
381 ))?;
382 let resp = self.http.get(url).send().await.map_err(|e| {
383 CleanLibraryError::Transport(TransportError::Network(e))
384 })?;
385 let body_text = resp.text().await.map_err(|e| {
386 CleanLibraryError::Transport(TransportError::Network(e))
387 })?;
388 let body: serde_json::Value = serde_json::from_str(&body_text)
389 .map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
390 let ecosystems = body["ecosystems_mounted"]
391 .as_array()
392 .unwrap_or(&vec![])
393 .iter()
394 .filter_map(|v| v.as_str().map(|s| s.to_string()))
395 .collect();
396 Ok(ecosystems)
397 }
398}
399
400fn emit_decision_headers(headers: &reqwest::header::HeaderMap) {
404 if let Some(decision) = headers
405 .get("X-CleanLibrary-Decision")
406 .and_then(|v| v.to_str().ok())
407 {
408 eprintln!("# decision: {}", decision);
409 }
410 if let Some(reason) = headers
411 .get("X-CleanLibrary-Reason")
412 .and_then(|v| v.to_str().ok())
413 {
414 eprintln!("# reason: {}", reason);
415 }
416}
417
418fn build_fetch_url(
433 base: &Url,
434 api_version: &str,
435 ecosystem: &str,
436 package: &str,
437 version: &str,
438) -> Result<Url, TransportError> {
439 let path = format!(
440 "{}/fetch/{}/{}/{}",
441 api_version,
442 urlencode(ecosystem),
443 urlencode(package),
444 urlencode(version),
445 );
446 base.join(&path)
447 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
448}
449
450fn urlencode(s: &str) -> String {
454 let mut out = String::with_capacity(s.len());
455 for c in s.chars() {
456 match c {
457 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
458 _ => {
459 let mut buf = [0u8; 4];
460 let encoded = c.encode_utf8(&mut buf);
461 for b in encoded.bytes() {
462 out.push_str(&format!("%{:02X}", b));
463 }
464 }
465 }
466 }
467 out
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::config::{Config, EndpointConfig};
474
475 fn cfg(url: &str) -> Config {
476 let mut c = Config::default();
477 c.endpoint = EndpointConfig {
478 url: url.to_string(),
479 api_version: "v1".to_string(),
480 };
481 c
482 }
483
484 #[test]
485 fn refuses_remote_plaintext() {
486 let err = Client::from_config(&cfg("http://cleanapp.clnstrt.dev")).unwrap_err();
487 assert!(matches!(
488 err,
489 CleanLibraryError::Transport(TransportError::TlsRequired(_))
490 ));
491 }
492
493 #[test]
494 fn allows_localhost_plaintext_for_testing() {
495 let client = Client::new("http://localhost:8080", None).unwrap();
496 assert_eq!(client.base_url().host_str(), Some("localhost"));
497 }
498
499 #[test]
500 fn allows_127_loopback_plaintext() {
501 let client = Client::new("http://127.0.0.1:8080", None).unwrap();
502 assert_eq!(client.base_url().host_str(), Some("127.0.0.1"));
503 }
504
505 #[test]
506 fn accepts_https_endpoint() {
507 let client = Client::from_config(&cfg("https://cleanapp.clnstrt.dev")).unwrap();
508 assert_eq!(client.base_url().as_str(), "https://cleanapp.clnstrt.dev/");
509 }
510
511 #[test]
512 fn rejects_invalid_url() {
513 let err = Client::from_config(&cfg("not a url")).unwrap_err();
514 assert!(matches!(
515 err,
516 CleanLibraryError::Transport(TransportError::InvalidUrl(_))
517 ));
518 }
519
520 #[test]
521 fn urlencode_npm_scoped_pkg() {
522 assert_eq!(urlencode("@my-org/foo"), "%40my-org%2Ffoo");
524 }
525
526 #[test]
527 fn urlencode_passes_simple() {
528 assert_eq!(urlencode("lodash"), "lodash");
529 assert_eq!(urlencode("4.17.21"), "4.17.21");
530 assert_eq!(urlencode("github.com/sirupsen/logrus"), "github.com%2Fsirupsen%2Flogrus");
531 }
532
533 #[test]
534 fn urlencode_maven_coordinate_encodes_colon() {
535 assert_eq!(
541 urlencode("org.springframework:spring-beans"),
542 "org.springframework%3Aspring-beans"
543 );
544 assert_eq!(
545 urlencode("org.apache.logging.log4j:log4j-core"),
546 "org.apache.logging.log4j%3Alog4j-core"
547 );
548 }
549
550 #[test]
551 fn urlencode_handles_unicode() {
552 assert_eq!(urlencode("é"), "%C3%A9");
554 }
555
556 fn base() -> Url {
557 Url::parse("https://cleanapp.clnstrt.dev").unwrap()
558 }
559
560 #[test]
566 fn fetch_url_routes_through_app_v1_fetch_for_npm_bare() {
567 let url = build_fetch_url(&base(), "v1", "npm", "lodash", "4.17.21").unwrap();
568 assert_eq!(
569 url.as_str(),
570 "https://cleanapp.clnstrt.dev/v1/fetch/npm/lodash/4.17.21"
571 );
572 }
573
574 #[test]
575 fn fetch_url_npm_scoped_encodes_at_and_slash() {
576 let url = build_fetch_url(&base(), "v1", "npm", "@my-org/foo", "1.0.0").unwrap();
579 assert_eq!(
580 url.as_str(),
581 "https://cleanapp.clnstrt.dev/v1/fetch/npm/%40my-org%2Ffoo/1.0.0"
582 );
583 }
584
585 #[test]
586 fn fetch_url_go_module_path_slashes_encoded() {
587 let url = build_fetch_url(&base(), "v1", "go", "github.com/sirupsen/logrus", "v1.9.0")
590 .unwrap();
591 assert_eq!(
592 url.as_str(),
593 "https://cleanapp.clnstrt.dev/v1/fetch/go/github.com%2Fsirupsen%2Flogrus/v1.9.0"
594 );
595 }
596
597 #[test]
598 fn fetch_url_pypi_routes_through_v1_fetch_not_registry_mimic() {
599 let url = build_fetch_url(&base(), "v1", "pypi", "requests", "2.31.0").unwrap();
600 assert_eq!(
601 url.as_str(),
602 "https://cleanapp.clnstrt.dev/v1/fetch/pypi/requests/2.31.0"
603 );
604 assert!(!url.as_str().contains("/pypi/requests/requests-"));
606 }
607
608 #[test]
609 fn fetch_url_ecosystem_client_side_unopinionated() {
610 for eco in ["maven", "crates", "nuget", "rubygems", "composer"] {
616 let url = build_fetch_url(&base(), "v1", eco, "somepkg", "1.0.0").unwrap();
617 assert_eq!(
618 url.as_str(),
619 format!("https://cleanapp.clnstrt.dev/v1/fetch/{}/somepkg/1.0.0", eco)
620 );
621 }
622 }
623
624 #[test]
625 fn fetch_url_maven_coordinates_group_id_encoded() {
626 let url = build_fetch_url(&base(), "v1", "maven", "junit:junit", "4.13.2").unwrap();
629 assert_eq!(
630 url.as_str(),
631 "https://cleanapp.clnstrt.dev/v1/fetch/maven/junit%3Ajunit/4.13.2"
632 );
633 }
634
635 #[test]
636 fn fetch_url_honors_configured_api_version() {
637 let url = build_fetch_url(&base(), "v2", "npm", "lodash", "4.17.21").unwrap();
640 assert_eq!(
641 url.as_str(),
642 "https://cleanapp.clnstrt.dev/v2/fetch/npm/lodash/4.17.21"
643 );
644 }
645}